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 api_serializer = None
# extended values are computationally expensive and not needed in normal circumstances # extended values are computationally expensive and not needed in normal circumstances
try: 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 return fields
except (AttributeError, KeyError) as e: except (AttributeError, KeyError) as e:
pass pass
@ -96,7 +97,8 @@ class CustomOnHandField(serializers.Field):
shared_users = getattr(request, '_shared_users', None) shared_users = getattr(request, '_shared_users', None)
if shared_users is None: if shared_users is None:
try: 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 except AttributeError: # Anonymous users (using share links) don't have shared users
shared_users = [] shared_users = []
return obj.onhand_users.filter(id__in=shared_users).exists() return obj.onhand_users.filter(id__in=shared_users).exists()
@ -170,7 +172,8 @@ class FoodInheritFieldSerializer(WritableNestedModelSerializer):
class UserPreferenceSerializer(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) plan_share = UserNameSerializer(many=True, allow_null=True, required=False, read_only=True)
shopping_share = UserNameSerializer(many=True, allow_null=True, required=False) shopping_share = UserNameSerializer(many=True, allow_null=True, required=False)
food_children_exist = serializers.SerializerMethodField('get_food_children_exist') food_children_exist = serializers.SerializerMethodField('get_food_children_exist')
@ -189,9 +192,12 @@ class UserPreferenceSerializer(WritableNestedModelSerializer):
class Meta: class Meta:
model = UserPreference model = UserPreference
fields = ( fields = (
'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_fractions', 'use_kj', 'search_style', 'show_recent', 'plan_share', 'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_fractions', 'use_kj', 'search_style',
'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_inherit_default', 'default_delay', 'show_recent', 'plan_share',
'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days', 'csv_delim', 'csv_prefix', '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' 'filter_to_supermarket', 'shopping_add_onhand', 'left_handed', 'food_children_exist'
) )
@ -393,7 +399,6 @@ class FoodSimpleSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Food model = Food
fields = ('id', 'name') fields = ('id', 'name')
read_only_fields = ['id', 'name']
class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedRecipeMixin): class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedRecipeMixin):
@ -416,7 +421,8 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
shared_users = getattr(request, '_shared_users', None) shared_users = getattr(request, '_shared_users', None)
if shared_users is None: if shared_users is None:
try: 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: except AttributeError:
shared_users = [] shared_users = []
filter = Q(id__in=obj.substitute.all()) filter = Q(id__in=obj.substitute.all())
@ -487,8 +493,8 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe') read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe')
class IngredientSerializer(WritableNestedModelSerializer): class IngredientSimpleSerializer(WritableNestedModelSerializer):
food = FoodSerializer(allow_null=True) food = FoodSimpleSerializer(allow_null=True)
unit = UnitSerializer(allow_null=True) unit = UnitSerializer(allow_null=True)
amount = CustomDecimalField() amount = CustomDecimalField()
@ -508,6 +514,10 @@ class IngredientSerializer(WritableNestedModelSerializer):
) )
class IngredientSerializer(IngredientSimpleSerializer):
food = FoodSerializer(allow_null=True)
class StepSerializer(WritableNestedModelSerializer, ExtendedRecipeMixin): class StepSerializer(WritableNestedModelSerializer, ExtendedRecipeMixin):
ingredients = IngredientSerializer(many=True) ingredients = IngredientSerializer(many=True)
ingredients_markdown = serializers.SerializerMethodField('get_ingredients_markdown') ingredients_markdown = serializers.SerializerMethodField('get_ingredients_markdown')
@ -699,7 +709,8 @@ class RecipeBookEntrySerializer(serializers.ModelSerializer):
def create(self, validated_data): def create(self, validated_data):
book = validated_data['book'] book = validated_data['book']
recipe = validated_data['recipe'] 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) raise NotFound(detail=None, code=None)
obj, created = RecipeBookEntry.objects.get_or_create(book=book, recipe=recipe) obj, created = RecipeBookEntry.objects.get_or_create(book=book, recipe=recipe)
return obj return obj
@ -752,7 +763,8 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer):
def get_name(self, obj): def get_name(self, obj):
if not isinstance(value := obj.servings, Decimal): if not isinstance(value := obj.servings, Decimal):
value = Decimal(value) 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 ( return (
obj.name obj.name
or getattr(obj.mealplan, 'title', None) or getattr(obj.mealplan, 'title', None)
@ -829,7 +841,8 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
class Meta: class Meta:
model = ShoppingListEntry model = ShoppingListEntry
fields = ( 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' 'created_by', 'created_at', 'completed_at', 'delay_until'
) )
read_only_fields = ('id', 'created_by', 'created_at',) read_only_fields = ('id', 'created_by', 'created_at',)
@ -927,7 +940,10 @@ class ExportLogSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = ExportLog 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',) read_only_fields = ('created_by',)
@ -1039,10 +1055,12 @@ class RecipeExportSerializer(WritableNestedModelSerializer):
class RecipeShoppingUpdateSerializer(serializers.ModelSerializer): 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=_( 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.")) "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: class Meta:
model = Recipe model = Recipe
@ -1050,9 +1068,12 @@ class RecipeShoppingUpdateSerializer(serializers.ModelSerializer):
class FoodShoppingUpdateSerializer(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")) amount = serializers.IntegerField(write_only=True, allow_null=True, required=False,
unit = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_("ID of unit to use for the shopping list")) help_text=_("Amount of food to add to 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.")) 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: class Meta:
model = Recipe 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'user-preference', api.UserPreferenceViewSet)
router.register(r'view-log', api.ViewLogViewSet) router.register(r'view-log', api.ViewLogViewSet)
urlpatterns = [ urlpatterns = [
path('', views.index, name='index'), path('', views.index, name='index'),
path('setup/', views.setup, name='view_setup'), path('setup/', views.setup, name='view_setup'),
@ -72,6 +71,7 @@ urlpatterns = [
path('settings/', views.user_settings, name='view_settings'), path('settings/', views.user_settings, name='view_settings'),
path('history/', views.history, name='view_history'), path('history/', views.history, name='view_history'),
path('supermarket/', views.supermarket, name='view_supermarket'), 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('abuse/<slug:token>', views.report_share_abuse, name='view_report_share_abuse'),
path('import/', import_export.import_recipe, name='view_import'), 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/share-link/<int:pk>', api.share_link, name='api_share_link'),
path('api/get_facets/', api.get_facets, name='api_get_facets'), 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/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? 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, SupermarketCategorySerializer, SupermarketSerializer,
SyncLogSerializer, SyncSerializer, UnitSerializer, SyncLogSerializer, SyncSerializer, UnitSerializer,
UserFileSerializer, UserNameSerializer, UserPreferenceSerializer, UserFileSerializer, UserNameSerializer, UserPreferenceSerializer,
ViewLogSerializer) ViewLogSerializer, IngredientSimpleSerializer)
from recipes import settings from recipes import settings
@ -119,13 +119,16 @@ class ExtendedRecipeMixin():
# add a recipe count annotation to the query # add a recipe count annotation to the query
# explanation on construction https://stackoverflow.com/a/43771738/15762829 # 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)) queryset = queryset.annotate(recipe_count=Coalesce(Subquery(recipe_count), 0))
# add a recipe image annotation to the query # 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: 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] space=space).exclude(image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1]
else: else:
image_children_subquery = None image_children_subquery = None
@ -142,11 +145,14 @@ class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin):
def get_queryset(self): def get_queryset(self):
self.queryset = self.queryset.filter(space=self.request.space).order_by(Lower('name').asc()) self.queryset = self.queryset.filter(space=self.request.space).order_by(Lower('name').asc())
query = self.request.query_params.get('query', None) 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 query is not None and query not in ["''", '']:
if fuzzy: 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)) self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name__unaccent', query))
else: else:
self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name', query)) self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name', query))
@ -154,7 +160,8 @@ class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin):
else: else:
# TODO have this check unaccent search settings or other search preferences? # TODO have this check unaccent search settings or other search preferences?
filter = Q(name__icontains=query) 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) filter |= Q(name__unaccent__icontains=query)
self.queryset = ( self.queryset = (
@ -275,10 +282,12 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin, ExtendedRecipeMixin):
except self.model.DoesNotExist: except self.model.DoesNotExist:
self.queryset = self.model.objects.none() self.queryset = self.model.objects.none()
else: 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()) 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.action(detail=True, url_path='move/(?P<parent>[^/.]+)', methods=['PUT'], )
@decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer)) @decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer))
@ -454,12 +463,16 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
pagination_class = DefaultPagination pagination_class = DefaultPagination
def get_queryset(self): 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() 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])) # 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, ) @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 # 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 = list(self.request.user.get_shopping_share())
shared_users.append(request.user) shared_users.append(request.user)
if request.data.get('_delete', False) == 'true': 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.')} content = {'msg': _(f'{obj.name} was removed from the shopping list.')}
return Response(content, status=status.HTTP_204_NO_CONTENT) return Response(content, status=status.HTTP_204_NO_CONTENT)
@ -478,7 +492,8 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
unit = request.data.get('unit', None) unit = request.data.get('unit', None)
content = {'msg': _(f'{obj.name} was added to the shopping list.')} 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) return Response(content, status=status.HTTP_204_NO_CONTENT)
def destroy(self, *args, **kwargs): def destroy(self, *args, **kwargs):
@ -577,8 +592,22 @@ class IngredientViewSet(viewsets.ModelViewSet):
serializer_class = IngredientSerializer serializer_class = IngredientSerializer
permission_classes = [CustomIsUser] 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): 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): class StepViewSet(viewsets.ModelViewSet):
@ -587,7 +616,8 @@ class StepViewSet(viewsets.ModelViewSet):
permission_classes = [CustomIsUser] permission_classes = [CustomIsUser]
pagination_class = DefaultPagination pagination_class = DefaultPagination
query_params = [ 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'), QueryParam(name='query', description=_('Query string matched (fuzzy) against object name.'), qtype='string'),
] ]
schema = QueryParamAutoSchema() schema = QueryParamAutoSchema()
@ -631,33 +661,63 @@ class RecipeViewSet(viewsets.ModelViewSet):
pagination_class = RecipePagination pagination_class = RecipePagination
query_params = [ query_params = [
QueryParam(name='query', description=_('Query string matched (fuzzy) against recipe name. In the future also fulltext search.')), QueryParam(name='query', description=_(
QueryParam(name='keywords', description=_('ID of keyword a recipe should have. For multiple repeat parameter. Equivalent to keywords_or'), qtype='int'), 'Query string matched (fuzzy) against recipe name. In the future also fulltext search.')),
QueryParam(name='keywords_or', description=_('Keyword IDs, repeat for multiple. Return recipes with any of the keywords'), qtype='int'), QueryParam(name='keywords', description=_(
QueryParam(name='keywords_and', description=_('Keyword IDs, repeat for multiple. Return recipes with all of the keywords.'), qtype='int'), 'ID of keyword a recipe should have. For multiple repeat parameter. Equivalent to keywords_or'),
QueryParam(name='keywords_or_not', description=_('Keyword IDs, repeat for multiple. Exclude recipes with any of the keywords.'), qtype='int'), qtype='int'),
QueryParam(name='keywords_and_not', description=_('Keyword IDs, repeat for multiple. Exclude recipes with all of the keywords.'), qtype='int'), QueryParam(name='keywords_or',
QueryParam(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'), qtype='int'), description=_('Keyword IDs, repeat for multiple. Return recipes with any of the keywords'),
QueryParam(name='foods_or', description=_('Food IDs, repeat for multiple. Return recipes with any of the foods'), qtype='int'), qtype='int'),
QueryParam(name='foods_and', description=_('Food IDs, repeat for multiple. Return recipes with all of the foods.'), qtype='int'), QueryParam(name='keywords_and',
QueryParam(name='foods_or_not', description=_('Food IDs, repeat for multiple. Exclude recipes with any of the foods.'), qtype='int'), description=_('Keyword IDs, repeat for multiple. Return recipes with all of the keywords.'),
QueryParam(name='foods_and_not', description=_('Food IDs, repeat for multiple. Exclude recipes with all of the foods.'), qtype='int'), 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='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', 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_or',
QueryParam(name='books_and', description=_('Book IDs, repeat for multiple. Return recipes with all of the books.'), qtype='int'), description=_('Book IDs, repeat for multiple. Return recipes with any 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',
QueryParam(name='books_and_not', description=_('Book IDs, repeat for multiple. Exclude recipes with all of the books.'), qtype='int'), description=_('Book IDs, repeat for multiple. Return 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='books_or_not',
QueryParam(name='random', description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')), description=_('Book IDs, repeat for multiple. Exclude recipes with any of the books.'), qtype='int'),
QueryParam(name='new', description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')), QueryParam(name='books_and_not',
QueryParam(name='timescooked', description=_('Filter recipes cooked X times or more. Negative values returns cooked less than X times'), qtype='int'), description=_('Book IDs, repeat for multiple. Exclude recipes with all of the books.'), qtype='int'),
QueryParam(name='cookedon', description=_('Filter recipes last cooked on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')), QueryParam(name='internal',
QueryParam(name='createdon', description=_('Filter recipes created on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')), description=_('If only internal recipes should be returned. [''true''/''<b>false</b>'']')),
QueryParam(name='updatedon', description=_('Filter recipes updated on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')), QueryParam(name='random',
QueryParam(name='viewedon', description=_('Filter recipes lasts viewed on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')), description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')),
QueryParam(name='makenow', description=_('Filter recipes that can be made with OnHand food. [''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() schema = QueryParamAutoSchema()
@ -672,7 +732,8 @@ class RecipeViewSet(viewsets.ModelViewSet):
if not (share and self.detail): if not (share and self.detail):
self.queryset = self.queryset.filter(space=self.request.space) 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) search = RecipeSearch(self.request, **params)
self.queryset = search.get_queryset(self.queryset).prefetch_related('cooklog_set') self.queryset = search.get_queryset(self.queryset).prefetch_related('cooklog_set')
return self.queryset return self.queryset
@ -770,7 +831,8 @@ class RecipeViewSet(viewsets.ModelViewSet):
levels = int(request.query_params.get('levels', 1)) levels = int(request.query_params.get('levels', 1))
except (ValueError, TypeError): except (ValueError, TypeError):
levels = 1 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) return Response(self.serializer_class(qs, many=True).data)
@ -780,7 +842,8 @@ class ShoppingListRecipeViewSet(viewsets.ModelViewSet):
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)) 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__created_by=self.request.user)
| Q(shoppinglist__shared=self.request.user) | Q(shoppinglist__shared=self.request.user)
@ -794,12 +857,17 @@ 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', 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( QueryParam(
name='checked', 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() schema = QueryParamAutoSchema()
@ -926,6 +994,7 @@ class CustomFilterViewSet(viewsets.ModelViewSet, StandardFilterMixin):
space=self.request.space).distinct() space=self.request.space).distinct()
return super().get_queryset() return super().get_queryset()
# -------------- non django rest api views -------------------- # -------------- non django rest api views --------------------

View File

@ -61,7 +61,8 @@ def search(request):
if request.user.userpreference.search_style == UserPreference.NEW: if request.user.userpreference.search_style == UserPreference.NEW:
return search_v2(request) return search_v2(request)
f = RecipeFilter(request.GET, 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) space=request.space)
if request.user.userpreference.search_style == UserPreference.LARGE: if request.user.userpreference.search_style == UserPreference.LARGE:
table = RecipeTable(f.qs) table = RecipeTable(f.qs)
@ -225,6 +226,11 @@ def supermarket(request):
return render(request, 'supermarket.html', {}) return render(request, 'supermarket.html', {})
@group_required('user')
def ingredient_editor(request):
return render(request, 'ingredient_editor.html', {})
@group_required('user') @group_required('user')
def meal_plan_entry(request, pk): def meal_plan_entry(request, pk):
plan = MealPlan.objects.filter(space=request.space).get(pk=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

@ -68,6 +68,7 @@ export default {
type: Object, type: Object,
default: undefined, default: undefined,
}, },
search_on_load: {type: Boolean, default: true},
multiple: {type: Boolean, default: true}, multiple: {type: Boolean, default: true},
allow_create: {type: Boolean, default: false}, allow_create: {type: Boolean, default: false},
create_placeholder: {type: String, default: "You Forgot to Add a Tag Placeholder"}, create_placeholder: {type: String, default: "You Forgot to Add a Tag Placeholder"},
@ -123,7 +124,9 @@ export default {
}, },
mounted() { mounted() {
this.id = Math.random() this.id = Math.random()
if (this.search_on_load) {
this.search("") this.search("")
}
if (this.multiple || !this.initial_single_selection) { if (this.multiple || !this.initial_single_selection) {
this.selected_objects = this.initial_selection this.selected_objects = this.initial_selection
} else { } else {

View File

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

View File

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