added basic ingredient editor

This commit is contained in:
vabene1111 2022-04-14 13:01:27 +02:00
parent 2ee96c2ea4
commit 7befa4a084
10 changed files with 381 additions and 98 deletions

View File

@ -42,7 +42,8 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
api_serializer = None
# extended values are computationally expensive and not needed in normal circumstances
try:
if str2bool(self.context['request'].query_params.get('extended', False)) and self.__class__ == api_serializer:
if str2bool(
self.context['request'].query_params.get('extended', False)) and self.__class__ == api_serializer:
return fields
except (AttributeError, KeyError) as e:
pass
@ -96,7 +97,8 @@ class CustomOnHandField(serializers.Field):
shared_users = getattr(request, '_shared_users', None)
if shared_users is None:
try:
shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [self.context['request'].user.id]
shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [
self.context['request'].user.id]
except AttributeError: # Anonymous users (using share links) don't have shared users
shared_users = []
return obj.onhand_users.filter(id__in=shared_users).exists()
@ -170,7 +172,8 @@ class FoodInheritFieldSerializer(WritableNestedModelSerializer):
class UserPreferenceSerializer(WritableNestedModelSerializer):
food_inherit_default = FoodInheritFieldSerializer(source='space.food_inherit', many=True, allow_null=True, required=False, read_only=True)
food_inherit_default = FoodInheritFieldSerializer(source='space.food_inherit', many=True, allow_null=True,
required=False, read_only=True)
plan_share = UserNameSerializer(many=True, allow_null=True, required=False, read_only=True)
shopping_share = UserNameSerializer(many=True, allow_null=True, required=False)
food_children_exist = serializers.SerializerMethodField('get_food_children_exist')
@ -189,9 +192,12 @@ class UserPreferenceSerializer(WritableNestedModelSerializer):
class Meta:
model = UserPreference
fields = (
'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_fractions', 'use_kj', 'search_style', 'show_recent', 'plan_share',
'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_inherit_default', 'default_delay',
'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days', 'csv_delim', 'csv_prefix',
'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_fractions', 'use_kj', 'search_style',
'show_recent', 'plan_share',
'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping',
'food_inherit_default', 'default_delay',
'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days',
'csv_delim', 'csv_prefix',
'filter_to_supermarket', 'shopping_add_onhand', 'left_handed', 'food_children_exist'
)
@ -393,7 +399,6 @@ class FoodSimpleSerializer(serializers.ModelSerializer):
class Meta:
model = Food
fields = ('id', 'name')
read_only_fields = ['id', 'name']
class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedRecipeMixin):
@ -416,7 +421,8 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
shared_users = getattr(request, '_shared_users', None)
if shared_users is None:
try:
shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [self.context['request'].user.id]
shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [
self.context['request'].user.id]
except AttributeError:
shared_users = []
filter = Q(id__in=obj.substitute.all())
@ -487,8 +493,8 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe')
class IngredientSerializer(WritableNestedModelSerializer):
food = FoodSerializer(allow_null=True)
class IngredientSimpleSerializer(WritableNestedModelSerializer):
food = FoodSimpleSerializer(allow_null=True)
unit = UnitSerializer(allow_null=True)
amount = CustomDecimalField()
@ -508,6 +514,10 @@ class IngredientSerializer(WritableNestedModelSerializer):
)
class IngredientSerializer(IngredientSimpleSerializer):
food = FoodSerializer(allow_null=True)
class StepSerializer(WritableNestedModelSerializer, ExtendedRecipeMixin):
ingredients = IngredientSerializer(many=True)
ingredients_markdown = serializers.SerializerMethodField('get_ingredients_markdown')
@ -699,7 +709,8 @@ class RecipeBookEntrySerializer(serializers.ModelSerializer):
def create(self, validated_data):
book = validated_data['book']
recipe = validated_data['recipe']
if not book.get_owner() == self.context['request'].user and not self.context['request'].user in book.get_shared():
if not book.get_owner() == self.context['request'].user and not self.context[
'request'].user in book.get_shared():
raise NotFound(detail=None, code=None)
obj, created = RecipeBookEntry.objects.get_or_create(book=book, recipe=recipe)
return obj
@ -752,7 +763,8 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer):
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
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)
@ -829,7 +841,8 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
class Meta:
model = ShoppingListEntry
fields = (
'id', 'list_recipe', 'food', 'unit', 'ingredient', 'ingredient_note', 'amount', 'order', 'checked', 'recipe_mealplan',
'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',)
@ -927,7 +940,10 @@ class ExportLogSerializer(serializers.ModelSerializer):
class Meta:
model = ExportLog
fields = ('id', 'type', 'msg', 'running', 'total_recipes', 'exported_recipes', 'cache_duration', 'possibly_not_expired', 'created_by', 'created_at')
fields = (
'id', 'type', 'msg', 'running', 'total_recipes', 'exported_recipes', 'cache_duration',
'possibly_not_expired',
'created_by', 'created_at')
read_only_fields = ('created_by',)
@ -1039,10 +1055,12 @@ class RecipeExportSerializer(WritableNestedModelSerializer):
class RecipeShoppingUpdateSerializer(serializers.ModelSerializer):
list_recipe = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_("Existing shopping list to update"))
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."))
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
@ -1050,9 +1068,12 @@ class RecipeShoppingUpdateSerializer(serializers.ModelSerializer):
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."))
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

View File

@ -0,0 +1,31 @@
{% extends "base.html" %}
{% load render_bundle from webpack_loader %}
{% load static %}
{% load i18n %}
{% load l10n %}
{% block title %}{% trans 'Ingredient Editor' %}{% endblock %}
{% block content %}
<div id="app">
<ingredient-editor-view></ingredient-editor-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.CUSTOM_LOCALE = '{{ request.LANGUAGE_CODE }}'
</script>
{% render_bundle 'ingredient_editor_view' %}
{% endblock %}

View File

@ -47,7 +47,6 @@ router.register(r'user-name', api.UserNameViewSet, basename='username')
router.register(r'user-preference', api.UserPreferenceViewSet)
router.register(r'view-log', api.ViewLogViewSet)
urlpatterns = [
path('', views.index, name='index'),
path('setup/', views.setup, name='view_setup'),
@ -72,6 +71,7 @@ urlpatterns = [
path('settings/', views.user_settings, name='view_settings'),
path('history/', views.history, name='view_history'),
path('supermarket/', views.supermarket, name='view_supermarket'),
path('ingredient-editor/', views.ingredient_editor, name='view_ingredient_editor'),
path('abuse/<slug:token>', views.report_share_abuse, name='view_report_share_abuse'),
path('import/', import_export.import_recipe, name='view_import'),
@ -116,7 +116,8 @@ urlpatterns = [
path('api/share-link/<int:pk>', api.share_link, name='api_share_link'),
path('api/get_facets/', api.get_facets, name='api_get_facets'),
path('dal/keyword/', dal.KeywordAutocomplete.as_view(), name='dal_keyword'), # TODO is this deprecated? not yet, some old forms remain, could likely be changed to generic API endpoints
path('dal/keyword/', dal.KeywordAutocomplete.as_view(), name='dal_keyword'),
# TODO is this deprecated? not yet, some old forms remain, could likely be changed to generic API endpoints
path('dal/food/', dal.IngredientsAutocomplete.as_view(), name='dal_food'), # TODO is this deprecated?
path('dal/unit/', dal.UnitAutocomplete.as_view(), name='dal_unit'), # TODO is this deprecated?

View File

@ -68,7 +68,7 @@ from cookbook.serializer import (AutomationSerializer, BookmarkletImportSerializ
SupermarketCategorySerializer, SupermarketSerializer,
SyncLogSerializer, SyncSerializer, UnitSerializer,
UserFileSerializer, UserNameSerializer, UserPreferenceSerializer,
ViewLogSerializer)
ViewLogSerializer, IngredientSimpleSerializer)
from recipes import settings
@ -119,13 +119,16 @@ class ExtendedRecipeMixin():
# add a recipe count annotation to the query
# explanation on construction https://stackoverflow.com/a/43771738/15762829
recipe_count = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).values(recipe_filter).annotate(count=Count('pk')).values('count')
recipe_count = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).values(
recipe_filter).annotate(count=Count('pk')).values('count')
queryset = queryset.annotate(recipe_count=Coalesce(Subquery(recipe_count), 0))
# add a recipe image annotation to the query
image_subquery = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).exclude(image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1]
image_subquery = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).exclude(
image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1]
if tree:
image_children_subquery = Recipe.objects.filter(**{f"{recipe_filter}__path__startswith": OuterRef('path')},
image_children_subquery = Recipe.objects.filter(
**{f"{recipe_filter}__path__startswith": OuterRef('path')},
space=space).exclude(image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1]
else:
image_children_subquery = None
@ -142,11 +145,14 @@ class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin):
def get_queryset(self):
self.queryset = self.queryset.filter(space=self.request.space).order_by(Lower('name').asc())
query = self.request.query_params.get('query', None)
fuzzy = self.request.user.searchpreference.lookup or any([self.model.__name__.lower() in x for x in self.request.user.searchpreference.trigram.values_list('field', flat=True)])
fuzzy = self.request.user.searchpreference.lookup or any([self.model.__name__.lower() in x for x in
self.request.user.searchpreference.trigram.values_list(
'field', flat=True)])
if query is not None and query not in ["''", '']:
if fuzzy:
if any([self.model.__name__.lower() in x for x in self.request.user.searchpreference.unaccent.values_list('field', flat=True)]):
if any([self.model.__name__.lower() in x for x in
self.request.user.searchpreference.unaccent.values_list('field', flat=True)]):
self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name__unaccent', query))
else:
self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name', query))
@ -154,7 +160,8 @@ class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin):
else:
# TODO have this check unaccent search settings or other search preferences?
filter = Q(name__icontains=query)
if any([self.model.__name__.lower() in x for x in self.request.user.searchpreference.unaccent.values_list('field', flat=True)]):
if any([self.model.__name__.lower() in x for x in
self.request.user.searchpreference.unaccent.values_list('field', flat=True)]):
filter |= Q(name__unaccent__icontains=query)
self.queryset = (
@ -275,10 +282,12 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin, ExtendedRecipeMixin):
except self.model.DoesNotExist:
self.queryset = self.model.objects.none()
else:
return self.annotate_recipe(queryset=super().get_queryset(), request=self.request, serializer=self.serializer_class, tree=True)
return self.annotate_recipe(queryset=super().get_queryset(), request=self.request,
serializer=self.serializer_class, tree=True)
self.queryset = self.queryset.filter(space=self.request.space).order_by(Lower('name').asc())
return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class, tree=True)
return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class,
tree=True)
@decorators.action(detail=True, url_path='move/(?P<parent>[^/.]+)', methods=['PUT'], )
@decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer))
@ -454,12 +463,16 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
pagination_class = DefaultPagination
def get_queryset(self):
self.request._shared_users = [x.id for x in list(self.request.user.get_shopping_share())] + [self.request.user.id]
self.request._shared_users = [x.id for x in list(self.request.user.get_shopping_share())] + [
self.request.user.id]
self.queryset = super().get_queryset()
shopping_status = ShoppingListEntry.objects.filter(space=self.request.space, food=OuterRef('id'), checked=False).values('id')
shopping_status = ShoppingListEntry.objects.filter(space=self.request.space, food=OuterRef('id'),
checked=False).values('id')
# onhand_status = self.queryset.annotate(onhand_status=Exists(onhand_users_set__in=[shared_users]))
return self.queryset.annotate(shopping_status=Exists(shopping_status)).prefetch_related('onhand_users', 'inherit_fields').select_related('recipe', 'supermarket_category')
return self.queryset.annotate(shopping_status=Exists(shopping_status)).prefetch_related('onhand_users',
'inherit_fields').select_related(
'recipe', 'supermarket_category')
@decorators.action(detail=True, methods=['PUT'], serializer_class=FoodShoppingUpdateSerializer, )
# TODO DRF only allows one action in a decorator action without overriding get_operation_id_base() this should be PUT and DELETE probably
@ -470,7 +483,8 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
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()
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)
@ -478,7 +492,8 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
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)
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):
@ -577,8 +592,22 @@ class IngredientViewSet(viewsets.ModelViewSet):
serializer_class = IngredientSerializer
permission_classes = [CustomIsUser]
def get_serializer_class(self):
if self.request and self.request.query_params.get('simple', False):
return IngredientSimpleSerializer
return IngredientSerializer
def get_queryset(self):
return self.queryset.filter(step__recipe__space=self.request.space)
queryset = self.queryset.filter(step__recipe__space=self.request.space)
food = self.request.query_params.get('food', None)
if food and re.match(r'^([1-9])+$', food):
queryset = queryset.filter(food_id=food)
unit = self.request.query_params.get('unit', None)
if unit and re.match(r'^([1-9])+$', unit):
queryset = queryset.filter(unit_id=unit)
return queryset
class StepViewSet(viewsets.ModelViewSet):
@ -587,7 +616,8 @@ class StepViewSet(viewsets.ModelViewSet):
permission_classes = [CustomIsUser]
pagination_class = DefaultPagination
query_params = [
QueryParam(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'), qtype='int'),
QueryParam(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'),
qtype='int'),
QueryParam(name='query', description=_('Query string matched (fuzzy) against object name.'), qtype='string'),
]
schema = QueryParamAutoSchema()
@ -631,33 +661,63 @@ class RecipeViewSet(viewsets.ModelViewSet):
pagination_class = RecipePagination
query_params = [
QueryParam(name='query', description=_('Query string matched (fuzzy) against recipe name. In the future also fulltext search.')),
QueryParam(name='keywords', description=_('ID of keyword a recipe should have. For multiple repeat parameter. Equivalent to keywords_or'), qtype='int'),
QueryParam(name='keywords_or', description=_('Keyword IDs, repeat for multiple. Return recipes with any of the keywords'), qtype='int'),
QueryParam(name='keywords_and', description=_('Keyword IDs, repeat for multiple. Return recipes with all of the keywords.'), qtype='int'),
QueryParam(name='keywords_or_not', description=_('Keyword IDs, repeat for multiple. Exclude recipes with any of the keywords.'), qtype='int'),
QueryParam(name='keywords_and_not', description=_('Keyword IDs, repeat for multiple. Exclude recipes with all of the keywords.'), qtype='int'),
QueryParam(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'), qtype='int'),
QueryParam(name='foods_or', description=_('Food IDs, repeat for multiple. Return recipes with any of the foods'), qtype='int'),
QueryParam(name='foods_and', description=_('Food IDs, repeat for multiple. Return recipes with all of the foods.'), qtype='int'),
QueryParam(name='foods_or_not', description=_('Food IDs, repeat for multiple. Exclude recipes with any of the foods.'), qtype='int'),
QueryParam(name='foods_and_not', description=_('Food IDs, repeat for multiple. Exclude recipes with all of the foods.'), qtype='int'),
QueryParam(name='query', description=_(
'Query string matched (fuzzy) against recipe name. In the future also fulltext search.')),
QueryParam(name='keywords', description=_(
'ID of keyword a recipe should have. For multiple repeat parameter. Equivalent to keywords_or'),
qtype='int'),
QueryParam(name='keywords_or',
description=_('Keyword IDs, repeat for multiple. Return recipes with any of the keywords'),
qtype='int'),
QueryParam(name='keywords_and',
description=_('Keyword IDs, repeat for multiple. Return recipes with all of the keywords.'),
qtype='int'),
QueryParam(name='keywords_or_not',
description=_('Keyword IDs, repeat for multiple. Exclude recipes with any of the keywords.'),
qtype='int'),
QueryParam(name='keywords_and_not',
description=_('Keyword IDs, repeat for multiple. Exclude recipes with all of the keywords.'),
qtype='int'),
QueryParam(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'),
qtype='int'),
QueryParam(name='foods_or',
description=_('Food IDs, repeat for multiple. Return recipes with any of the foods'), qtype='int'),
QueryParam(name='foods_and',
description=_('Food IDs, repeat for multiple. Return recipes with all of the foods.'), qtype='int'),
QueryParam(name='foods_or_not',
description=_('Food IDs, repeat for multiple. Exclude recipes with any of the foods.'), qtype='int'),
QueryParam(name='foods_and_not',
description=_('Food IDs, repeat for multiple. Exclude recipes with all of the foods.'), qtype='int'),
QueryParam(name='units', description=_('ID of unit a recipe should have.'), qtype='int'),
QueryParam(name='rating', description=_('Rating a recipe should have or greater. [0 - 5] Negative value filters rating less than.'), qtype='int'),
QueryParam(name='rating', description=_(
'Rating a recipe should have or greater. [0 - 5] Negative value filters rating less than.'), qtype='int'),
QueryParam(name='books', description=_('ID of book a recipe should be in. For multiple repeat parameter.')),
QueryParam(name='books_or', description=_('Book IDs, repeat for multiple. Return recipes with any of the books'), qtype='int'),
QueryParam(name='books_and', description=_('Book IDs, repeat for multiple. Return recipes with all of the books.'), qtype='int'),
QueryParam(name='books_or_not', description=_('Book IDs, repeat for multiple. Exclude recipes with any of the books.'), qtype='int'),
QueryParam(name='books_and_not', description=_('Book IDs, repeat for multiple. Exclude recipes with all of the books.'), qtype='int'),
QueryParam(name='internal', description=_('If only internal recipes should be returned. [''true''/''<b>false</b>'']')),
QueryParam(name='random', description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')),
QueryParam(name='new', description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')),
QueryParam(name='timescooked', description=_('Filter recipes cooked X times or more. Negative values returns cooked less than X times'), qtype='int'),
QueryParam(name='cookedon', description=_('Filter recipes last cooked on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='createdon', description=_('Filter recipes created on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='updatedon', description=_('Filter recipes updated on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='viewedon', description=_('Filter recipes lasts viewed on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='makenow', description=_('Filter recipes that can be made with OnHand food. [''true''/''<b>false</b>'']')),
QueryParam(name='books_or',
description=_('Book IDs, repeat for multiple. Return recipes with any of the books'), qtype='int'),
QueryParam(name='books_and',
description=_('Book IDs, repeat for multiple. Return recipes with all of the books.'), qtype='int'),
QueryParam(name='books_or_not',
description=_('Book IDs, repeat for multiple. Exclude recipes with any of the books.'), qtype='int'),
QueryParam(name='books_and_not',
description=_('Book IDs, repeat for multiple. Exclude recipes with all of the books.'), qtype='int'),
QueryParam(name='internal',
description=_('If only internal recipes should be returned. [''true''/''<b>false</b>'']')),
QueryParam(name='random',
description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')),
QueryParam(name='new',
description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')),
QueryParam(name='timescooked', description=_(
'Filter recipes cooked X times or more. Negative values returns cooked less than X times'), qtype='int'),
QueryParam(name='cookedon', description=_(
'Filter recipes last cooked on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='createdon', description=_(
'Filter recipes created on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='updatedon', description=_(
'Filter recipes updated on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='viewedon', description=_(
'Filter recipes lasts viewed on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='makenow',
description=_('Filter recipes that can be made with OnHand food. [''true''/''<b>false</b>'']')),
]
schema = QueryParamAutoSchema()
@ -672,7 +732,8 @@ class RecipeViewSet(viewsets.ModelViewSet):
if not (share and self.detail):
self.queryset = self.queryset.filter(space=self.request.space)
params = {x: self.request.GET.get(x) if len({**self.request.GET}[x]) == 1 else self.request.GET.getlist(x) for x in list(self.request.GET)}
params = {x: self.request.GET.get(x) if len({**self.request.GET}[x]) == 1 else self.request.GET.getlist(x) for x
in list(self.request.GET)}
search = RecipeSearch(self.request, **params)
self.queryset = search.get_queryset(self.queryset).prefetch_related('cooklog_set')
return self.queryset
@ -770,7 +831,8 @@ class RecipeViewSet(viewsets.ModelViewSet):
levels = int(request.query_params.get('levels', 1))
except (ValueError, TypeError):
levels = 1
qs = obj.get_related_recipes(levels=levels) # TODO: make levels a user setting, included in request data?, keep solely in the backend?
qs = obj.get_related_recipes(
levels=levels) # TODO: make levels a user setting, included in request data?, keep solely in the backend?
return Response(self.serializer_class(qs, many=True).data)
@ -780,7 +842,8 @@ class ShoppingListRecipeViewSet(viewsets.ModelViewSet):
permission_classes = [CustomIsOwner | CustomIsShared]
def get_queryset(self):
self.queryset = self.queryset.filter(Q(shoppinglist__space=self.request.space) | Q(entries__space=self.request.space))
self.queryset = self.queryset.filter(
Q(shoppinglist__space=self.request.space) | Q(entries__space=self.request.space))
return self.queryset.filter(
Q(shoppinglist__created_by=self.request.user)
| Q(shoppinglist__shared=self.request.user)
@ -794,12 +857,17 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet):
serializer_class = ShoppingListEntrySerializer
permission_classes = [CustomIsOwner | CustomIsShared]
query_params = [
QueryParam(name='id', description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'), qtype='int'),
QueryParam(name='id',
description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'),
qtype='int'),
QueryParam(
name='checked',
description=_('Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''<b>recent</b>'']<br> - ''recent'' includes unchecked items and recently completed items.')
description=_(
'Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''<b>recent</b>'']<br> - ''recent'' includes unchecked items and recently completed items.')
),
QueryParam(name='supermarket', description=_('Returns the shopping list entries sorted by supermarket category order.'), qtype='int'),
QueryParam(name='supermarket',
description=_('Returns the shopping list entries sorted by supermarket category order.'),
qtype='int'),
]
schema = QueryParamAutoSchema()
@ -926,6 +994,7 @@ class CustomFilterViewSet(viewsets.ModelViewSet, StandardFilterMixin):
space=self.request.space).distinct()
return super().get_queryset()
# -------------- non django rest api views --------------------

View File

@ -61,7 +61,8 @@ def search(request):
if request.user.userpreference.search_style == UserPreference.NEW:
return search_v2(request)
f = RecipeFilter(request.GET,
queryset=Recipe.objects.filter(space=request.user.userpreference.space).all().order_by(Lower('name').asc()),
queryset=Recipe.objects.filter(space=request.user.userpreference.space).all().order_by(
Lower('name').asc()),
space=request.space)
if request.user.userpreference.search_style == UserPreference.LARGE:
table = RecipeTable(f.qs)
@ -225,6 +226,11 @@ def supermarket(request):
return render(request, 'supermarket.html', {})
@group_required('user')
def ingredient_editor(request):
return render(request, 'ingredient_editor.html', {})
@group_required('user')
def meal_plan_entry(request, pk):
plan = MealPlan.objects.filter(space=request.space).get(pk=pk)

View File

@ -0,0 +1,118 @@
<template>
<div id="app">
<div class="row">
<div class="col-md-6">
<generic-multiselect @change="food = $event.val; refreshList()"
:model="Models.FOOD"
:multiple="false"></generic-multiselect>
</div>
<div class="col-md-6">
<generic-multiselect @change="unit = $event.val; refreshList()"
:model="Models.UNIT"
:multiple="false"></generic-multiselect>
</div>
</div>
<table class="table table-bordered">
<thead>
<tr>
<th>Amount</th>
<th>Unit</th>
<th>Food</th>
<th>Note</th>
<th>Save</th>
</tr>
</thead>
<tr v-for="i in ingredients" v-bind:key="i.id">
<td style="width: 10vw">
<input type="number" class="form-control" v-model="i.amount">
</td>
<td style="width: 30vw">
<generic-multiselect @change="i.unit = $event.val;"
:initial_selection="i.unit"
:model="Models.UNIT"
:search_on_load="false"
:multiple="false"></generic-multiselect>
</td>
<td style="width: 30vw">
<generic-multiselect @change="i.food = $event.val;"
:initial_selection="i.food"
:model="Models.FOOD"
:search_on_load="false"
:multiple="false"></generic-multiselect>
</td>
<td style="width: 30vw">
<input class="form-control" v-model="i.note">
</td>
<td>
<b-button variant="primary" @click="updateIngredient(i)">Save</b-button>
</td>
</tr>
</table>
</div>
</template>
<script>
import Vue from "vue"
import {BootstrapVue} from "bootstrap-vue"
import "bootstrap-vue/dist/bootstrap-vue.css"
import {ApiMixin, StandardToasts} from "@/utils/utils"
import {ApiApiFactory} from "@/utils/openapi/api";
import GenericMultiselect from "@/components/GenericMultiselect";
Vue.use(BootstrapVue)
export default {
name: "IngredientEditorView",
mixins: [ApiMixin],
components: {GenericMultiselect},
data() {
return {
ingredients: [],
food: null,
unit: null,
}
},
computed: {},
mounted() {
this.$i18n.locale = window.CUSTOM_LOCALE
this.refreshList()
},
methods: {
refreshList: function () {
if (this.food === null && this.unit === null) {
this.ingredients = []
} else {
let apiClient = new ApiApiFactory()
let params = {'query': {'simple': 1}}
if (this.food !== null) {
params.query.food = this.food.id
}
if (this.unit !== null) {
params.query.unit = this.unit.id
}
apiClient.listIngredients(params).then(result => {
this.ingredients = result.data
})
}
},
updateIngredient: function (i) {
let apiClient = new ApiApiFactory()
apiClient.updateIngredient(i.id, i).then(r => {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
}).catch((r, e) => {
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
})
}
},
}
</script>
<style>
</style>

View File

@ -0,0 +1,18 @@
import Vue from 'vue'
import App from './IngredientEditorView.vue'
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

@ -26,11 +26,11 @@
<script>
import Vue from "vue"
import Multiselect from "vue-multiselect"
import { ApiMixin } from "@/utils/utils"
import {ApiMixin} from "@/utils/utils"
export default {
name: "GenericMultiselect",
components: { Multiselect },
components: {Multiselect},
mixins: [ApiMixin],
data() {
return {
@ -42,16 +42,16 @@ export default {
}
},
props: {
placeholder: { type: String, default: undefined },
placeholder: {type: String, default: undefined},
model: {
type: Object,
default() {
return {}
},
},
label: { type: String, default: "name" },
parent_variable: { type: String, default: undefined },
limit: { type: Number, default: 25 },
label: {type: String, default: "name"},
parent_variable: {type: String, default: undefined},
limit: {type: Number, default: 25},
sticky_options: {
type: Array,
default() {
@ -68,10 +68,11 @@ export default {
type: Object,
default: undefined,
},
multiple: { type: Boolean, default: true },
allow_create: { type: Boolean, default: false },
create_placeholder: { type: String, default: "You Forgot to Add a Tag Placeholder" },
clear: { type: Number },
search_on_load: {type: Boolean, default: true},
multiple: {type: Boolean, default: true},
allow_create: {type: Boolean, default: false},
create_placeholder: {type: String, default: "You Forgot to Add a Tag Placeholder"},
clear: {type: Number},
},
watch: {
initial_selection: function (newVal, oldVal) {
@ -82,12 +83,12 @@ export default {
empty[this.label] = `..${this.$t("loading")}..`
this.selected_objects.forEach((x) => {
if (typeof x !== "object") {
this.selected_objects[this.selected_objects.indexOf(x)] = { ...empty, id: x }
this.selected_objects[this.selected_objects.indexOf(x)] = {...empty, id: x}
get_details.push(x)
}
})
get_details.forEach((x) => {
this.genericAPI(this.model, this.Actions.FETCH, { id: x })
this.genericAPI(this.model, this.Actions.FETCH, {id: x})
.then((result) => {
// this.selected_objects[this.selected_objects.map((y) => y.id).indexOf(x)] = result.data
Vue.set(this.selected_objects, this.selected_objects.map((y) => y.id).indexOf(x), result.data)
@ -103,8 +104,8 @@ export default {
if (typeof this.selected_objects !== "object") {
let empty = {}
empty[this.label] = `..${this.$t("loading")}..`
this.selected_objects = { ...empty, id: this.selected_objects }
this.genericAPI(this.model, this.Actions.FETCH, { id: this.selected_objects })
this.selected_objects = {...empty, id: this.selected_objects}
this.genericAPI(this.model, this.Actions.FETCH, {id: this.selected_objects})
.then((result) => {
this.selected_objects = result.data
})
@ -123,7 +124,9 @@ export default {
},
mounted() {
this.id = Math.random()
if (this.search_on_load) {
this.search("")
}
if (this.multiple || !this.initial_single_selection) {
this.selected_objects = this.initial_selection
} else {
@ -171,7 +174,7 @@ export default {
})
},
selectionChanged: function () {
this.$emit("change", { var: this.parent_variable, val: this.selected_objects })
this.$emit("change", {var: this.parent_variable, val: this.selected_objects})
},
addNew(e) {
this.$emit("new", e)

View File

@ -472,7 +472,7 @@ export interface FoodRecipe {
* @type {string}
* @memberof FoodRecipe
*/
name?: string;
name: string;
/**
*
* @type {string}
@ -537,7 +537,7 @@ export interface FoodSubstitute {
* @type {string}
* @memberof FoodSubstitute
*/
name?: string;
name: string;
}
/**
*
@ -746,6 +746,12 @@ export interface Ingredient {
* @memberof Ingredient
*/
no_amount?: boolean;
/**
*
* @type {string}
* @memberof Ingredient
*/
original_text?: string | null;
}
/**
*
@ -1905,6 +1911,12 @@ export interface RecipeIngredients {
* @memberof RecipeIngredients
*/
no_amount?: boolean;
/**
*
* @type {string}
* @memberof RecipeIngredients
*/
original_text?: string | null;
}
/**
*
@ -2173,7 +2185,7 @@ export interface RecipeSimple {
* @type {string}
* @memberof RecipeSimple
*/
name?: string;
name: string;
/**
*
* @type {string}

View File

@ -45,6 +45,10 @@ const pages = {
entry: "./src/apps/MealPlanView/main.js",
chunks: ["chunk-vendors"],
},
ingredient_editor_view: {
entry: "./src/apps/IngredientEditorView/main.js",
chunks: ["chunk-vendors"],
},
shopping_list_view: {
entry: "./src/apps/ShoppingListView/main.js",
chunks: ["chunk-vendors"],