ExtendedRecipeMixin

This commit is contained in:
smilerz 2021-10-05 06:28:25 -05:00
parent 2fc27d7c36
commit 613b618533
20 changed files with 249 additions and 205 deletions

View File

@ -876,7 +876,7 @@ class SearchPreference(models.Model, PermissionModelMixin):
unaccent = models.ManyToManyField(SearchFields, related_name="unaccent_fields", blank=True, default=allSearchFields) unaccent = models.ManyToManyField(SearchFields, related_name="unaccent_fields", blank=True, default=allSearchFields)
icontains = models.ManyToManyField(SearchFields, related_name="icontains_fields", blank=True, default=nameSearchField) icontains = models.ManyToManyField(SearchFields, related_name="icontains_fields", blank=True, default=nameSearchField)
istartswith = models.ManyToManyField(SearchFields, related_name="istartswith_fields", blank=True) istartswith = models.ManyToManyField(SearchFields, related_name="istartswith_fields", blank=True)
trigram = models.ManyToManyField(SearchFields, related_name="trigram_fields", blank=True,default=nameSearchField) trigram = models.ManyToManyField(SearchFields, related_name="trigram_fields", blank=True, default=nameSearchField)
fulltext = models.ManyToManyField(SearchFields, related_name="fulltext_fields", blank=True) fulltext = models.ManyToManyField(SearchFields, related_name="fulltext_fields", blank=True)
trigram_threshold = models.DecimalField(default=0.1, decimal_places=2, max_digits=3) trigram_threshold = models.DecimalField(default=0.1, decimal_places=2, max_digits=3)

View File

@ -21,6 +21,45 @@ from cookbook.models import (Comment, CookLog, Food, Ingredient, Keyword,
from cookbook.templatetags.custom_tags import markdown from cookbook.templatetags.custom_tags import markdown
class ExtendedRecipeMixin(serializers.ModelSerializer):
# adds image and recipe count to serializer when query param extended=1
image = serializers.SerializerMethodField('get_image')
numrecipe = serializers.SerializerMethodField('count_recipes')
recipe_filter = None
def get_fields(self, *args, **kwargs):
fields = super().get_fields(*args, **kwargs)
try:
api_serializer = self.context['view'].serializer_class
except KeyError:
api_serializer = None
# extended values are computationally expensive and not needed in normal circumstances
if bool(int(self.context['request'].query_params.get('extended', False))) and self.__class__ == api_serializer:
return fields
else:
del fields['image']
del fields['numrecipe']
return fields
def get_image(self, obj):
# TODO add caching
recipes = Recipe.objects.filter(**{self.recipe_filter: obj}, space=obj.space).exclude(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):
return Recipe.objects.filter(**{self.recipe_filter: obj}, space=obj.space).count()
class CustomDecimalField(serializers.Field): class CustomDecimalField(serializers.Field):
""" """
Custom decimal field to normalize useless decimal places Custom decimal field to normalize useless decimal places
@ -28,10 +67,9 @@ class CustomDecimalField(serializers.Field):
""" """
def to_representation(self, value): def to_representation(self, value):
if isinstance(value, Decimal): if not isinstance(value, Decimal):
return value.normalize() value = Decimal(value)
else: return round(value, 2).normalize()
return Decimal(value).normalize()
def to_internal_value(self, data): def to_internal_value(self, data):
if type(data) == int or type(data) == float: if type(data) == int or type(data) == float:
@ -206,25 +244,26 @@ class KeywordLabelSerializer(serializers.ModelSerializer):
read_only_fields = ('id', 'label') read_only_fields = ('id', 'label')
class KeywordSerializer(UniqueFieldsMixin, serializers.ModelSerializer): class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
label = serializers.SerializerMethodField('get_label') label = serializers.SerializerMethodField('get_label')
image = serializers.SerializerMethodField('get_image') # image = serializers.SerializerMethodField('get_image')
numrecipe = serializers.SerializerMethodField('count_recipes') # numrecipe = serializers.SerializerMethodField('count_recipes')
recipe_filter = 'keywords'
def get_label(self, obj): def get_label(self, obj):
return str(obj) return str(obj)
def get_image(self, obj): # def get_image(self, obj):
recipes = obj.recipe_set.all().filter(space=obj.space).exclude(image__isnull=True).exclude(image__exact='') # recipes = obj.recipe_set.all().filter(space=obj.space).exclude(image__isnull=True).exclude(image__exact='')
if recipes.count() == 0 and obj.has_children(): # if recipes.count() == 0 and obj.has_children():
recipes = Recipe.objects.filter(keywords__in=obj.get_descendants(), space=obj.space).exclude(image__isnull=True).exclude(image__exact='') # if no recipes found - check whole tree # recipes = Recipe.objects.filter(keywords__in=obj.get_descendants(), space=obj.space).exclude(image__isnull=True).exclude(image__exact='') # if no recipes found - check whole tree
if recipes.count() != 0: # if recipes.count() != 0:
return random.choice(recipes).image.url # return random.choice(recipes).image.url
else: # else:
return None # return None
def count_recipes(self, obj): # def count_recipes(self, obj):
return obj.recipe_set.filter(space=self.context['request'].space).all().count() # return obj.recipe_set.filter(space=self.context['request'].space).all().count()
def create(self, validated_data): def create(self, validated_data):
# since multi select tags dont have id's # since multi select tags dont have id's
@ -242,20 +281,21 @@ class KeywordSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
read_only_fields = ('id', 'numchild', 'parent', 'image') read_only_fields = ('id', 'numchild', 'parent', 'image')
class UnitSerializer(UniqueFieldsMixin, serializers.ModelSerializer): class UnitSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
image = serializers.SerializerMethodField('get_image') # image = serializers.SerializerMethodField('get_image')
numrecipe = serializers.SerializerMethodField('count_recipes') # numrecipe = serializers.SerializerMethodField('count_recipes')
recipe_filter = 'steps__ingredients__unit'
def get_image(self, obj): # def get_image(self, obj):
recipes = Recipe.objects.filter(steps__ingredients__unit=obj, space=obj.space).exclude(image__isnull=True).exclude(image__exact='') # recipes = Recipe.objects.filter(steps__ingredients__unit=obj, space=obj.space).exclude(image__isnull=True).exclude(image__exact='')
if recipes.count() != 0: # if recipes.count() != 0:
return random.choice(recipes).image.url # return random.choice(recipes).image.url
else: # else:
return None # return None
def count_recipes(self, obj): # def count_recipes(self, obj):
return Recipe.objects.filter(steps__ingredients__unit=obj, space=obj.space).count() # return Recipe.objects.filter(steps__ingredients__unit=obj, space=obj.space).count()
def create(self, validated_data): def create(self, validated_data):
validated_data['name'] = validated_data['name'].strip() validated_data['name'] = validated_data['name'].strip()
@ -317,29 +357,30 @@ class RecipeSimpleSerializer(serializers.ModelSerializer):
read_only_fields = ['id', 'name', 'url'] read_only_fields = ['id', 'name', 'url']
class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer): class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedRecipeMixin):
supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False) supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False)
recipe = RecipeSimpleSerializer(allow_null=True, required=False) recipe = RecipeSimpleSerializer(allow_null=True, required=False)
image = serializers.SerializerMethodField('get_image') # image = serializers.SerializerMethodField('get_image')
numrecipe = serializers.SerializerMethodField('count_recipes') # numrecipe = serializers.SerializerMethodField('count_recipes')
recipe_filter = 'steps__ingredients__food'
def get_image(self, obj): # def get_image(self, obj):
if obj.recipe and obj.space == obj.recipe.space: # if obj.recipe and obj.space == obj.recipe.space:
if obj.recipe.image and obj.recipe.image != '': # if obj.recipe.image and obj.recipe.image != '':
return obj.recipe.image.url # return obj.recipe.image.url
# if food is not also a recipe, look for recipe images that use the food # # if food is not also a recipe, look for recipe images that use the food
recipes = Recipe.objects.filter(steps__ingredients__food=obj, space=obj.space).exclude(image__isnull=True).exclude(image__exact='') # recipes = Recipe.objects.filter(steps__ingredients__food=obj, space=obj.space).exclude(image__isnull=True).exclude(image__exact='')
# if no recipes found - check whole tree # # if no recipes found - check whole tree
if recipes.count() == 0 and obj.has_children(): # if recipes.count() == 0 and obj.has_children():
recipes = Recipe.objects.filter(steps__ingredients__food__in=obj.get_descendants(), space=obj.space).exclude(image__isnull=True).exclude(image__exact='') # recipes = Recipe.objects.filter(steps__ingredients__food__in=obj.get_descendants(), space=obj.space).exclude(image__isnull=True).exclude(image__exact='')
if recipes.count() != 0: # if recipes.count() != 0:
return random.choice(recipes).image.url # return random.choice(recipes).image.url
else: # else:
return None # return None
def count_recipes(self, obj): # def count_recipes(self, obj):
return Recipe.objects.filter(steps__ingredients__food=obj, space=obj.space).count() # return Recipe.objects.filter(steps__ingredients__food=obj, space=obj.space).count()
def create(self, validated_data): def create(self, validated_data):
validated_data['name'] = validated_data['name'].strip() validated_data['name'] = validated_data['name'].strip()
@ -561,7 +602,7 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
recipe = RecipeOverviewSerializer(required=False, allow_null=True) recipe = RecipeOverviewSerializer(required=False, allow_null=True)
recipe_name = serializers.ReadOnlyField(source='recipe.name') recipe_name = serializers.ReadOnlyField(source='recipe.name')
meal_type = MealTypeSerializer() meal_type = MealTypeSerializer()
meal_type_name = serializers.ReadOnlyField(source='meal_type.name') # TODO deprecate once old meal plan was removed meal_type_name = serializers.ReadOnlyField(source='meal_type.name') # TODO deprecate once old meal plan was removed
note_markdown = serializers.SerializerMethodField('get_note_markdown') note_markdown = serializers.SerializerMethodField('get_note_markdown')
servings = CustomDecimalField() servings = CustomDecimalField()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -101,7 +101,7 @@ class FuzzyFilterMixin(ViewSetMixin):
schema = FilterSchema() schema = FilterSchema()
def get_queryset(self): def get_queryset(self):
self.queryset = self.queryset.filter(space=self.request.space) self.queryset = self.queryset.filter(space=self.request.space).order_by('name')
query = self.request.query_params.get('query', None) query = self.request.query_params.get('query', None)
fuzzy = self.request.user.searchpreference.lookup fuzzy = self.request.user.searchpreference.lookup
@ -111,14 +111,14 @@ class FuzzyFilterMixin(ViewSetMixin):
self.queryset self.queryset
.annotate(exact=Case(When(name__iexact=query, then=(Value(100))), default=Value(0))) # put exact matches at the top of the result set .annotate(exact=Case(When(name__iexact=query, then=(Value(100))), default=Value(0))) # put exact matches at the top of the result set
.annotate(trigram=TrigramSimilarity('name', query)).filter(trigram__gt=0.2) .annotate(trigram=TrigramSimilarity('name', query)).filter(trigram__gt=0.2)
.order_by('-exact').order_by("-trigram") .order_by('-exact', '-trigram')
) )
else: else:
# TODO have this check unaccent search settings or other search preferences? # TODO have this check unaccent search settings or other search preferences?
self.queryset = ( self.queryset = (
self.queryset self.queryset
.annotate(exact=Case(When(name__iexact=query, then=(Value(100))), default=Value(0))) # put exact matches at the top of the result set .annotate(exact=Case(When(name__iexact=query, then=(Value(100))), default=Value(0))) # put exact matches at the top of the result set
.filter(name__icontains=query).order_by('-exact') .filter(name__icontains=query).order_by('-exact', 'name')
) )
updated_at = self.request.query_params.get('updated_at', None) updated_at = self.request.query_params.get('updated_at', None)
@ -139,7 +139,7 @@ class FuzzyFilterMixin(ViewSetMixin):
return self.queryset return self.queryset
class MergeMixin(ViewSetMixin): # TODO update Units to use merge API 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):

View File

@ -252,6 +252,7 @@ export default {
}, },
getItems: function (params, col) { getItems: function (params, col) {
let column = col || 'left' let column = col || 'left'
params.options = {'query':{'extended': 1}} // returns extended values in API response
this.genericAPI(this.this_model, this.Actions.LIST, params).then((result) => { this.genericAPI(this.this_model, this.Actions.LIST, params).then((result) => {
let results = result.data?.results ?? result.data let results = result.data?.results ?? result.data
@ -399,11 +400,13 @@ export default {
}, },
getChildren: function (col, item) { getChildren: function (col, item) {
let parent = {} let parent = {}
let options = { let params = {
'root': item.id, 'root': item.id,
'pageSize': 200 'pageSize': 200,
'query': {'extended': 1},
'options': {'query':{'extended': 1}}
} }
this.genericAPI(this.this_model, this.Actions.LIST, options).then((result) => { this.genericAPI(this.this_model, this.Actions.LIST, params).then((result) => {
parent = this.findCard(item.id, this['items_' + col]) parent = this.findCard(item.id, this['items_' + col])
if (parent) { if (parent) {
Vue.set(parent, 'children', result.data.results) Vue.set(parent, 'children', result.data.results)
@ -418,10 +421,10 @@ export default {
getRecipes: function (col, item) { getRecipes: function (col, item) {
let parent = {} let parent = {}
// TODO: make this generic // TODO: make this generic
let options = {'pageSize': 200} let params = {'pageSize': 50}
options[this.this_recipe_param] = item.id params[this.this_recipe_param] = item.id
this.genericAPI(this.Models.RECIPE, this.Actions.LIST, options).then((result) => { this.genericAPI(this.Models.RECIPE, this.Actions.LIST, params).then((result) => {
parent = this.findCard(item.id, this['items_' + col]) parent = this.findCard(item.id, this['items_' + col])
if (parent) { if (parent) {
Vue.set(parent, 'recipes', result.data.results) Vue.set(parent, 'recipes', result.data.results)

View File

@ -13,7 +13,7 @@ export class Models {
// MODEL_TYPES - inherited by MODELS, inherits and takes precedence over ACTIONS // MODEL_TYPES - inherited by MODELS, inherits and takes precedence over ACTIONS
static TREE = { static TREE = {
'list': { 'list': {
'params': ['query', 'root', 'tree', 'page', 'pageSize'], 'params': ['query', 'root', 'tree', 'page', 'pageSize', 'options'],
'config': { 'config': {
'root': { 'root': {
'default': { 'default': {
@ -471,7 +471,7 @@ export class Actions {
static LIST = { static LIST = {
"function": "list", "function": "list",
"suffix": "s", "suffix": "s",
"params": ['query', 'page', 'pageSize'], "params": ['query', 'page', 'pageSize', 'options'],
"config": { "config": {
'query': {'default': undefined}, 'query': {'default': undefined},
'page': {'default': 1}, 'page': {'default': 1},

View File

@ -159,7 +159,7 @@ export function roundDecimals(num) {
/* /*
* Utility functions to use OpenAPIs generically * Utility functions to use OpenAPIs generically
* */ * */
import {ApiApiFactory} from "@/utils/openapi/api.ts"; // TODO: is it possible to only import inside the Mixin? import {ApiApiFactory} from "@/utils/openapi/api.ts";
import axios from "axios"; import axios from "axios";
axios.defaults.xsrfCookieName = 'csrftoken' axios.defaults.xsrfCookieName = 'csrftoken'

View File

@ -3,87 +3,87 @@
"assets": { "assets": {
"../../templates/sw.js": { "../../templates/sw.js": {
"name": "../../templates/sw.js", "name": "../../templates/sw.js",
"path": "..\\..\\templates\\sw.js" "path": "../../templates/sw.js"
}, },
"js/checklist_view.js": { "js/checklist_view.js": {
"name": "js/checklist_view.js", "name": "js/checklist_view.js",
"path": "js\\checklist_view.js" "path": "js/checklist_view.js"
}, },
"js/chunk-2d0da313.js": { "js/chunk-2d0da313.js": {
"name": "js/chunk-2d0da313.js", "name": "js/chunk-2d0da313.js",
"path": "js\\chunk-2d0da313.js" "path": "js/chunk-2d0da313.js"
}, },
"css/chunk-vendors.css": { "css/chunk-vendors.css": {
"name": "css/chunk-vendors.css", "name": "css/chunk-vendors.css",
"path": "css\\chunk-vendors.css" "path": "css/chunk-vendors.css"
}, },
"js/chunk-vendors.js": { "js/chunk-vendors.js": {
"name": "js/chunk-vendors.js", "name": "js/chunk-vendors.js",
"path": "js\\chunk-vendors.js" "path": "js/chunk-vendors.js"
}, },
"css/cookbook_view.css": { "css/cookbook_view.css": {
"name": "css/cookbook_view.css", "name": "css/cookbook_view.css",
"path": "css\\cookbook_view.css" "path": "css/cookbook_view.css"
}, },
"js/cookbook_view.js": { "js/cookbook_view.js": {
"name": "js/cookbook_view.js", "name": "js/cookbook_view.js",
"path": "js\\cookbook_view.js" "path": "js/cookbook_view.js"
}, },
"css/edit_internal_recipe.css": { "css/edit_internal_recipe.css": {
"name": "css/edit_internal_recipe.css", "name": "css/edit_internal_recipe.css",
"path": "css\\edit_internal_recipe.css" "path": "css/edit_internal_recipe.css"
}, },
"js/edit_internal_recipe.js": { "js/edit_internal_recipe.js": {
"name": "js/edit_internal_recipe.js", "name": "js/edit_internal_recipe.js",
"path": "js\\edit_internal_recipe.js" "path": "js/edit_internal_recipe.js"
}, },
"js/import_response_view.js": { "js/import_response_view.js": {
"name": "js/import_response_view.js", "name": "js/import_response_view.js",
"path": "js\\import_response_view.js" "path": "js/import_response_view.js"
}, },
"css/meal_plan_view.css": { "css/meal_plan_view.css": {
"name": "css/meal_plan_view.css", "name": "css/meal_plan_view.css",
"path": "css\\meal_plan_view.css" "path": "css/meal_plan_view.css"
}, },
"js/meal_plan_view.js": { "js/meal_plan_view.js": {
"name": "js/meal_plan_view.js", "name": "js/meal_plan_view.js",
"path": "js\\meal_plan_view.js" "path": "js/meal_plan_view.js"
}, },
"css/model_list_view.css": { "css/model_list_view.css": {
"name": "css/model_list_view.css", "name": "css/model_list_view.css",
"path": "css\\model_list_view.css" "path": "css/model_list_view.css"
}, },
"js/model_list_view.js": { "js/model_list_view.js": {
"name": "js/model_list_view.js", "name": "js/model_list_view.js",
"path": "js\\model_list_view.js" "path": "js/model_list_view.js"
}, },
"js/offline_view.js": { "js/offline_view.js": {
"name": "js/offline_view.js", "name": "js/offline_view.js",
"path": "js\\offline_view.js" "path": "js/offline_view.js"
}, },
"css/recipe_search_view.css": { "css/recipe_search_view.css": {
"name": "css/recipe_search_view.css", "name": "css/recipe_search_view.css",
"path": "css\\recipe_search_view.css" "path": "css/recipe_search_view.css"
}, },
"js/recipe_search_view.js": { "js/recipe_search_view.js": {
"name": "js/recipe_search_view.js", "name": "js/recipe_search_view.js",
"path": "js\\recipe_search_view.js" "path": "js/recipe_search_view.js"
}, },
"css/recipe_view.css": { "css/recipe_view.css": {
"name": "css/recipe_view.css", "name": "css/recipe_view.css",
"path": "css\\recipe_view.css" "path": "css/recipe_view.css"
}, },
"js/recipe_view.js": { "js/recipe_view.js": {
"name": "js/recipe_view.js", "name": "js/recipe_view.js",
"path": "js\\recipe_view.js" "path": "js/recipe_view.js"
}, },
"js/supermarket_view.js": { "js/supermarket_view.js": {
"name": "js/supermarket_view.js", "name": "js/supermarket_view.js",
"path": "js\\supermarket_view.js" "path": "js/supermarket_view.js"
}, },
"js/user_file_view.js": { "js/user_file_view.js": {
"name": "js/user_file_view.js", "name": "js/user_file_view.js",
"path": "js\\user_file_view.js" "path": "js/user_file_view.js"
}, },
"recipe_search_view.html": { "recipe_search_view.html": {
"name": "recipe_search_view.html", "name": "recipe_search_view.html",