construct values in queryset instead of serializer methods

This commit is contained in:
smilerz 2022-01-11 07:24:59 -06:00
parent 30683fe455
commit f7cb067b52
5 changed files with 82 additions and 40 deletions

View File

@ -7,7 +7,7 @@ class Round(Func):
def str2bool(v): def str2bool(v):
if type(v) == bool: if type(v) == bool or v is None:
return v return v
else: else:
return v.lower() in ("yes", "true", "1") return v.lower() in ("yes", "true", "1")

View File

@ -12,6 +12,7 @@ from rest_framework import serializers
from rest_framework.exceptions import NotFound, ValidationError from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.fields import empty from rest_framework.fields import empty
from cookbook.helper.HelperFunctions import str2bool
from cookbook.helper.shopping_helper import list_from_recipe from cookbook.helper.shopping_helper import list_from_recipe
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food, from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food,
FoodInheritField, ImportLog, Ingredient, Keyword, MealPlan, MealType, FoodInheritField, ImportLog, Ingredient, Keyword, MealPlan, MealType,
@ -21,13 +22,18 @@ from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Fo
SupermarketCategoryRelation, Sync, SyncLog, Unit, UserFile, SupermarketCategoryRelation, Sync, SyncLog, Unit, UserFile,
UserPreference, ViewLog) UserPreference, ViewLog)
from cookbook.templatetags.custom_tags import markdown from cookbook.templatetags.custom_tags import markdown
from recipes.settings import MEDIA_URL, SCRIPT_NAME
class ExtendedRecipeMixin(serializers.ModelSerializer): class ExtendedRecipeMixin(serializers.ModelSerializer):
# adds image and recipe count to serializer when query param extended=1 # adds image and recipe count to serializer when query param extended=1
image = serializers.SerializerMethodField('get_image') # ORM path to this object from Recipe
numrecipe = serializers.SerializerMethodField('count_recipes')
recipe_filter = None recipe_filter = None
# list of ORM paths to any image
images = None
image = serializers.SerializerMethodField('get_image')
numrecipe = serializers.ReadOnlyField(source='count_recipes_test')
def get_fields(self, *args, **kwargs): def get_fields(self, *args, **kwargs):
fields = super().get_fields(*args, **kwargs) fields = super().get_fields(*args, **kwargs)
@ -37,8 +43,7 @@ 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 bool(int( if str2bool(self.context['request'].query_params.get('extended', False)) and self.__class__ == api_serializer:
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
@ -50,21 +55,8 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
return fields return fields
def get_image(self, obj): def get_image(self, obj):
# TODO add caching if obj.recipe_image:
recipes = Recipe.objects.filter(**{self.recipe_filter: obj}, space=obj.space).exclude( return SCRIPT_NAME + MEDIA_URL + obj.recipe_image
image__isnull=True).exclude(image__exact='')
try:
if recipes.count() == 0 and obj.has_children():
obj__in = self.recipe_filter + '__in'
recipes = Recipe.objects.filter(**{obj__in: obj.get_descendants()}, space=obj.space).exclude(
image__isnull=True).exclude(image__exact='') # if no recipes found - check whole tree
except AttributeError:
# probably not a tree
pass
if recipes.count() != 0:
return random.choice(recipes).image.url
else:
return None
def count_recipes(self, obj): def count_recipes(self, obj):
return Recipe.objects.filter(**{self.recipe_filter: obj}, space=obj.space).count() return Recipe.objects.filter(**{self.recipe_filter: obj}, space=obj.space).count()
@ -98,6 +90,10 @@ class CustomOnHandField(serializers.Field):
return instance return instance
def to_representation(self, obj): def to_representation(self, obj):
shared_users = []
if request := self.context.get('request', None):
shared_users = request._shared_users
else:
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]
return obj.onhand_users.filter(id__in=shared_users).exists() return obj.onhand_users.filter(id__in=shared_users).exists()
@ -379,14 +375,16 @@ 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)
shopping = serializers.SerializerMethodField('get_shopping_status') # shopping = serializers.SerializerMethodField('get_shopping_status')
shopping = serializers.ReadOnlyField(source='shopping_status')
inherit_fields = FoodInheritFieldSerializer(many=True, allow_null=True, required=False) inherit_fields = FoodInheritFieldSerializer(many=True, allow_null=True, required=False)
food_onhand = CustomOnHandField(required=False, allow_null=True) food_onhand = CustomOnHandField(required=False, allow_null=True)
recipe_filter = 'steps__ingredients__food' recipe_filter = 'steps__ingredients__food'
images = ['recipe__image']
def get_shopping_status(self, obj): # def get_shopping_status(self, obj):
return ShoppingListEntry.objects.filter(space=obj.space, food=obj, checked=False).count() > 0 # return ShoppingListEntry.objects.filter(space=obj.space, food=obj, checked=False).count() > 0
def create(self, validated_data): def create(self, validated_data):
validated_data['name'] = validated_data['name'].strip() validated_data['name'] = validated_data['name'].strip()

View File

@ -12,8 +12,9 @@ from django.contrib.auth.models import User
from django.contrib.postgres.search import TrigramSimilarity from django.contrib.postgres.search import TrigramSimilarity
from django.core.exceptions import FieldError, ValidationError from django.core.exceptions import FieldError, ValidationError
from django.core.files import File from django.core.files import File
from django.db.models import Case, ProtectedError, Q, Value, When from django.db.models import Case, Count, Exists, OuterRef, ProtectedError, Q, Subquery, Value, When
from django.db.models.fields.related import ForeignObjectRel from django.db.models.fields.related import ForeignObjectRel
from django.db.models.functions import Coalesce
from django.http import FileResponse, HttpResponse, JsonResponse from django.http import FileResponse, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
@ -30,6 +31,7 @@ from rest_framework.response import Response
from rest_framework.viewsets import ViewSetMixin from rest_framework.viewsets import ViewSetMixin
from treebeard.exceptions import InvalidMoveToDescendant, InvalidPosition, PathOverflow from treebeard.exceptions import InvalidMoveToDescendant, InvalidPosition, PathOverflow
from cookbook.helper.HelperFunctions import str2bool
from cookbook.helper.image_processing import handle_image 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 (CustomIsAdmin, CustomIsGuest, CustomIsOwner, from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest, CustomIsOwner,
@ -100,7 +102,38 @@ class DefaultPagination(PageNumberPagination):
max_page_size = 200 max_page_size = 200
class FuzzyFilterMixin(ViewSetMixin): class ExtendedRecipeMixin():
'''
ExtendedRecipe annotates a queryset with recipe_image and recipe_count values
'''
@classmethod
def annotate_recipe(self, queryset=None, request=None, serializer=None, tree=False):
extended = str2bool(request.query_params.get('extended', None))
if extended:
recipe_filter = serializer.recipe_filter
images = serializer.images
space = request.space
# 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')
queryset = queryset.annotate(recipe_count_test=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]
if tree:
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
if images:
queryset = queryset.annotate(recipe_image=Coalesce(*images, image_subquery, image_children_subquery))
else:
queryset = queryset.annotate(recipe_image=Coalesce(image_subquery, image_children_subquery))
return queryset
class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin):
schema = FilterSchema() schema = FilterSchema()
def get_queryset(self): def get_queryset(self):
@ -141,12 +174,12 @@ class FuzzyFilterMixin(ViewSetMixin):
if random: if random:
self.queryset = self.queryset.order_by("?") self.queryset = self.queryset.order_by("?")
self.queryset = self.queryset[:int(limit)] self.queryset = self.queryset[:int(limit)]
return self.queryset return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class)
class MergeMixin(ViewSetMixin): class MergeMixin(ViewSetMixin):
@decorators.action(detail=True, url_path='merge/(?P<target>[^/.]+)', methods=['PUT'], ) @ decorators.action(detail=True, url_path='merge/(?P<target>[^/.]+)', methods=['PUT'], )
@decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer)) @ decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer))
def merge(self, request, pk, target): def merge(self, request, pk, target):
self.description = f"Merge {self.basename} onto target {self.basename} with ID of [int]." self.description = f"Merge {self.basename} onto target {self.basename} with ID of [int]."
@ -211,7 +244,7 @@ class MergeMixin(ViewSetMixin):
return Response(content, status=status.HTTP_400_BAD_REQUEST) return Response(content, status=status.HTTP_400_BAD_REQUEST)
class TreeMixin(MergeMixin, FuzzyFilterMixin): class TreeMixin(MergeMixin, FuzzyFilterMixin, ExtendedRecipeMixin):
schema = TreeSchema() schema = TreeSchema()
model = None model = None
@ -237,11 +270,13 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin):
except self.model.DoesNotExist: except self.model.DoesNotExist:
self.queryset = self.model.objects.none() self.queryset = self.model.objects.none()
else: else:
return super().get_queryset() self.queryset = super().get_queryset()
return self.queryset.filter(space=self.request.space).order_by('name') self.queryset = self.queryset.filter(space=self.request.space).order_by('name')
@decorators.action(detail=True, url_path='move/(?P<parent>[^/.]+)', methods=['PUT'], ) return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class, tree=True)
@decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer))
@ decorators.action(detail=True, url_path='move/(?P<parent>[^/.]+)', methods=['PUT'], )
@ decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer))
def move(self, request, pk, parent): def move(self, request, pk, parent):
self.description = f"Move {self.basename} to be a child of {self.basename} with ID of [int]. Use ID: 0 to move {self.basename} to the root." self.description = f"Move {self.basename} to be a child of {self.basename} with ID of [int]. Use ID: 0 to move {self.basename} to the root."
if self.model.node_order_by: if self.model.node_order_by:
@ -413,7 +448,15 @@ 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 get_queryset(self):
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')
# 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')
@ 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
def shopping(self, request, pk): def shopping(self, request, pk):
if self.request.space.demo: if self.request.space.demo:

View File

@ -18,14 +18,15 @@ Updated statements to make it Python 3 friendly.
""" """
def terminal_width(): def terminal_width():
""" """
Function to compute the terminal width. Function to compute the terminal width.
""" """
width = 0 width = 0
try: try:
import struct, fcntl, termios import fcntl
import struct
import termios
s = struct.pack('HHHH', 0, 0, 0, 0) s = struct.pack('HHHH', 0, 0, 0, 0)
x = fcntl.ioctl(1, termios.TIOCGWINSZ, s) x = fcntl.ioctl(1, termios.TIOCGWINSZ, s)
width = struct.unpack('HHHH', x)[1] width = struct.unpack('HHHH', x)[1]

View File

@ -371,10 +371,10 @@ LANGUAGES = [
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.0/howto/static-files/ # https://docs.djangoproject.com/en/2.0/howto/static-files/
SCRIPT_NAME = os.getenv('SCRIPT_NAME', '')
# path for django_js_reverse to generate the javascript file containing all urls. Only done because the default command (collectstatic_js_reverse) fails to update the manifest # path for django_js_reverse to generate the javascript file containing all urls. Only done because the default command (collectstatic_js_reverse) fails to update the manifest
JS_REVERSE_OUTPUT_PATH = os.path.join(BASE_DIR, "cookbook/static/django_js_reverse") JS_REVERSE_OUTPUT_PATH = os.path.join(BASE_DIR, "cookbook/static/django_js_reverse")
JS_REVERSE_SCRIPT_PREFIX = os.getenv('JS_REVERSE_SCRIPT_PREFIX', SCRIPT_NAME)
JS_REVERSE_SCRIPT_PREFIX = os.getenv('JS_REVERSE_SCRIPT_PREFIX', os.getenv('SCRIPT_NAME', ''))
STATIC_URL = os.getenv('STATIC_URL', '/static/') STATIC_URL = os.getenv('STATIC_URL', '/static/')
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")