Merge branch 'feature/unit-conversion' into develop
This commit is contained in:
commit
2214540a51
@ -15,7 +15,7 @@ from .models import (BookmarkletImport, Comment, CookLog, Food, FoodInheritField
|
|||||||
Recipe, RecipeBook, RecipeBookEntry, RecipeImport, SearchPreference, ShareLink,
|
Recipe, RecipeBook, RecipeBookEntry, RecipeImport, SearchPreference, ShareLink,
|
||||||
ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
|
ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
|
||||||
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog,
|
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog,
|
||||||
TelegramBot, Unit, UserFile, UserPreference, ViewLog, Automation, UserSpace)
|
TelegramBot, Unit, UserFile, UserPreference, ViewLog, Automation, UserSpace, UnitConversion, PropertyType, Property)
|
||||||
|
|
||||||
|
|
||||||
class CustomUserAdmin(UserAdmin):
|
class CustomUserAdmin(UserAdmin):
|
||||||
@ -201,6 +201,14 @@ class FoodAdmin(TreeAdmin):
|
|||||||
admin.site.register(Food, FoodAdmin)
|
admin.site.register(Food, FoodAdmin)
|
||||||
|
|
||||||
|
|
||||||
|
class UnitConversionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('base_amount', 'base_unit', 'food', 'converted_amount', 'converted_unit')
|
||||||
|
search_fields = ('food__name', 'unit__name')
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(UnitConversion, UnitConversionAdmin)
|
||||||
|
|
||||||
|
|
||||||
class IngredientAdmin(admin.ModelAdmin):
|
class IngredientAdmin(admin.ModelAdmin):
|
||||||
list_display = ('food', 'amount', 'unit')
|
list_display = ('food', 'amount', 'unit')
|
||||||
search_fields = ('food__name', 'unit__name')
|
search_fields = ('food__name', 'unit__name')
|
||||||
@ -319,6 +327,20 @@ class ShareLinkAdmin(admin.ModelAdmin):
|
|||||||
admin.site.register(ShareLink, ShareLinkAdmin)
|
admin.site.register(ShareLink, ShareLinkAdmin)
|
||||||
|
|
||||||
|
|
||||||
|
class PropertyTypeAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('id', 'name')
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(PropertyType, PropertyTypeAdmin)
|
||||||
|
|
||||||
|
|
||||||
|
class PropertyAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('property_amount', 'property_type')
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(Property, PropertyAdmin)
|
||||||
|
|
||||||
|
|
||||||
class NutritionInformationAdmin(admin.ModelAdmin):
|
class NutritionInformationAdmin(admin.ModelAdmin):
|
||||||
list_display = ('id',)
|
list_display = ('id',)
|
||||||
|
|
||||||
|
11
cookbook/helper/cache_helper.py
Normal file
11
cookbook/helper/cache_helper.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
class CacheHelper:
|
||||||
|
space = None
|
||||||
|
|
||||||
|
BASE_UNITS_CACHE_KEY = None
|
||||||
|
PROPERTY_TYPE_CACHE_KEY = None
|
||||||
|
|
||||||
|
def __init__(self, space):
|
||||||
|
self.space = space
|
||||||
|
|
||||||
|
self.BASE_UNITS_CACHE_KEY = f'SPACE_{space.id}_BASE_UNITS'
|
||||||
|
self.PROPERTY_TYPE_CACHE_KEY = f'SPACE_{space.id}_PROPERTY_TYPES'
|
206
cookbook/helper/open_data_importer.py
Normal file
206
cookbook/helper/open_data_importer.py
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
from cookbook.models import Unit, SupermarketCategory, Property, PropertyType, Supermarket, SupermarketCategoryRelation, Food, Automation, UnitConversion
|
||||||
|
|
||||||
|
|
||||||
|
class OpenDataImporter:
|
||||||
|
request = None
|
||||||
|
data = {}
|
||||||
|
slug_id_cache = {}
|
||||||
|
update_existing = False
|
||||||
|
use_metric = True
|
||||||
|
|
||||||
|
def __init__(self, request, data, update_existing=False, use_metric=True):
|
||||||
|
self.request = request
|
||||||
|
self.data = data
|
||||||
|
self.update_existing = update_existing
|
||||||
|
self.use_metric = use_metric
|
||||||
|
|
||||||
|
def _update_slug_cache(self, object_class, datatype):
|
||||||
|
self.slug_id_cache[datatype] = dict(object_class.objects.filter(space=self.request.space, open_data_slug__isnull=False).values_list('open_data_slug', 'id', ))
|
||||||
|
|
||||||
|
def import_units(self):
|
||||||
|
datatype = 'category'
|
||||||
|
|
||||||
|
insert_list = []
|
||||||
|
for u in list(self.data['unit'].keys()):
|
||||||
|
insert_list.append(Unit(
|
||||||
|
name=self.data['unit'][u]['name'],
|
||||||
|
plural_name=self.data['unit'][u]['plural_name'],
|
||||||
|
base_unit=self.data['unit'][u]['base_unit'] if self.data['unit'][u]['base_unit'] != '' else None,
|
||||||
|
open_data_slug=u,
|
||||||
|
space=self.request.space
|
||||||
|
))
|
||||||
|
|
||||||
|
if self.update_existing:
|
||||||
|
return Unit.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('name', 'plural_name', 'base_unit', 'open_data_slug'), unique_fields=('space', 'name',))
|
||||||
|
else:
|
||||||
|
return Unit.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('open_data_slug',), unique_fields=('space', 'name',))
|
||||||
|
|
||||||
|
def import_category(self):
|
||||||
|
datatype = 'category'
|
||||||
|
|
||||||
|
insert_list = []
|
||||||
|
for k in list(self.data[datatype].keys()):
|
||||||
|
insert_list.append(SupermarketCategory(
|
||||||
|
name=self.data[datatype][k]['name'],
|
||||||
|
open_data_slug=k,
|
||||||
|
space=self.request.space
|
||||||
|
))
|
||||||
|
|
||||||
|
return SupermarketCategory.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('open_data_slug',), unique_fields=('space', 'name',))
|
||||||
|
|
||||||
|
def import_property(self):
|
||||||
|
datatype = 'property'
|
||||||
|
|
||||||
|
insert_list = []
|
||||||
|
for k in list(self.data[datatype].keys()):
|
||||||
|
insert_list.append(PropertyType(
|
||||||
|
name=self.data[datatype][k]['name'],
|
||||||
|
unit=self.data[datatype][k]['unit'],
|
||||||
|
open_data_slug=k,
|
||||||
|
space=self.request.space
|
||||||
|
))
|
||||||
|
|
||||||
|
return PropertyType.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('open_data_slug',), unique_fields=('space', 'name',))
|
||||||
|
|
||||||
|
def import_supermarket(self):
|
||||||
|
datatype = 'supermarket'
|
||||||
|
|
||||||
|
self._update_slug_cache(SupermarketCategory, 'category')
|
||||||
|
insert_list = []
|
||||||
|
for k in list(self.data[datatype].keys()):
|
||||||
|
insert_list.append(Supermarket(
|
||||||
|
name=self.data[datatype][k]['name'],
|
||||||
|
open_data_slug=k,
|
||||||
|
space=self.request.space
|
||||||
|
))
|
||||||
|
|
||||||
|
# always add open data slug if matching supermarket is found, otherwise relation might fail
|
||||||
|
supermarkets = Supermarket.objects.bulk_create(insert_list, unique_fields=('space', 'name',), update_conflicts=True, update_fields=('open_data_slug',))
|
||||||
|
self._update_slug_cache(Supermarket, 'supermarket')
|
||||||
|
|
||||||
|
insert_list = []
|
||||||
|
for k in list(self.data[datatype].keys()):
|
||||||
|
relations = []
|
||||||
|
order = 0
|
||||||
|
for c in self.data[datatype][k]['categories']:
|
||||||
|
relations.append(
|
||||||
|
SupermarketCategoryRelation(
|
||||||
|
supermarket_id=self.slug_id_cache[datatype][k],
|
||||||
|
category_id=self.slug_id_cache['category'][c],
|
||||||
|
order=order,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
order += 1
|
||||||
|
|
||||||
|
SupermarketCategoryRelation.objects.bulk_create(relations, ignore_conflicts=True, unique_fields=('supermarket', 'category',))
|
||||||
|
|
||||||
|
return supermarkets
|
||||||
|
|
||||||
|
def import_food(self):
|
||||||
|
identifier_list = []
|
||||||
|
datatype = 'food'
|
||||||
|
for k in list(self.data[datatype].keys()):
|
||||||
|
identifier_list.append(self.data[datatype][k]['name'])
|
||||||
|
identifier_list.append(self.data[datatype][k]['plural_name'])
|
||||||
|
|
||||||
|
existing_objects_flat = []
|
||||||
|
existing_objects = {}
|
||||||
|
for f in Food.objects.filter(space=self.request.space).filter(name__in=identifier_list).values_list('id', 'name', 'plural_name'):
|
||||||
|
existing_objects_flat.append(f[1])
|
||||||
|
existing_objects_flat.append(f[2])
|
||||||
|
existing_objects[f[1]] = f
|
||||||
|
existing_objects[f[2]] = f
|
||||||
|
|
||||||
|
self._update_slug_cache(Unit, 'unit')
|
||||||
|
self._update_slug_cache(PropertyType, 'property')
|
||||||
|
|
||||||
|
pref_unit_key = 'preferred_unit_metric'
|
||||||
|
pref_shopping_unit_key = 'preferred_packaging_unit_metric'
|
||||||
|
if not self.use_metric:
|
||||||
|
pref_unit_key = 'preferred_unit_imperial'
|
||||||
|
pref_shopping_unit_key = 'preferred_packaging_unit_imperial'
|
||||||
|
|
||||||
|
insert_list = []
|
||||||
|
update_list = []
|
||||||
|
update_field_list = []
|
||||||
|
for k in list(self.data[datatype].keys()):
|
||||||
|
if not (self.data[datatype][k]['name'] in existing_objects_flat or self.data[datatype][k]['plural_name'] in existing_objects_flat):
|
||||||
|
insert_list.append({'data': {
|
||||||
|
'name': self.data[datatype][k]['name'],
|
||||||
|
'plural_name': self.data[datatype][k]['plural_name'] if self.data[datatype][k]['plural_name'] != '' else None,
|
||||||
|
'preferred_unit_id': self.slug_id_cache['unit'][self.data[datatype][k][pref_unit_key]],
|
||||||
|
'preferred_shopping_unit_id': self.slug_id_cache['unit'][self.data[datatype][k][pref_shopping_unit_key]],
|
||||||
|
'supermarket_category_id': self.slug_id_cache['category'][self.data[datatype][k]['supermarket_category']],
|
||||||
|
'fdc_id': self.data[datatype][k]['fdc_id'] if self.data[datatype][k]['fdc_id'] != '' else None,
|
||||||
|
'open_data_slug': k,
|
||||||
|
'space': self.request.space.id,
|
||||||
|
}})
|
||||||
|
else:
|
||||||
|
if self.data[datatype][k]['name'] in existing_objects:
|
||||||
|
existing_food_id = existing_objects[self.data[datatype][k]['name']][0]
|
||||||
|
else:
|
||||||
|
existing_food_id = existing_objects[self.data[datatype][k]['plural_name']][0]
|
||||||
|
|
||||||
|
if self.update_existing:
|
||||||
|
update_field_list = ['name', 'plural_name', 'preferred_unit_id', 'preferred_shopping_unit_id', 'supermarket_category_id', 'fdc_id', 'open_data_slug', ]
|
||||||
|
update_list.append(Food(
|
||||||
|
id=existing_food_id,
|
||||||
|
name=self.data[datatype][k]['name'],
|
||||||
|
plural_name=self.data[datatype][k]['plural_name'] if self.data[datatype][k]['plural_name'] != '' else None,
|
||||||
|
preferred_unit_id=self.slug_id_cache['unit'][self.data[datatype][k][pref_unit_key]],
|
||||||
|
preferred_shopping_unit_id=self.slug_id_cache['unit'][self.data[datatype][k][pref_shopping_unit_key]],
|
||||||
|
supermarket_category_id=self.slug_id_cache['category'][self.data[datatype][k]['supermarket_category']],
|
||||||
|
fdc_id=self.data[datatype][k]['fdc_id'] if self.data[datatype][k]['fdc_id'] != '' else None,
|
||||||
|
open_data_slug=k,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
update_field_list = ['open_data_slug', ]
|
||||||
|
update_list.append(Food(id=existing_food_id, open_data_slug=k, ))
|
||||||
|
|
||||||
|
Food.load_bulk(insert_list, None)
|
||||||
|
if len(update_list) > 0:
|
||||||
|
Food.objects.bulk_update(update_list, update_field_list)
|
||||||
|
|
||||||
|
self._update_slug_cache(Food, 'food')
|
||||||
|
|
||||||
|
food_property_list = []
|
||||||
|
alias_list = []
|
||||||
|
for k in list(self.data[datatype].keys()):
|
||||||
|
for fp in self.data[datatype][k]['properties']['type_values']:
|
||||||
|
food_property_list.append(Property(
|
||||||
|
property_type_id=self.slug_id_cache['property'][fp['property_type']],
|
||||||
|
property_amount=fp['property_value'],
|
||||||
|
space=self.request.space,
|
||||||
|
))
|
||||||
|
|
||||||
|
for a in self.data[datatype][k]['alias']:
|
||||||
|
alias_list.append(Automation(
|
||||||
|
param_1=a,
|
||||||
|
param_2=self.data[datatype][k]['name'],
|
||||||
|
space=self.request.space,
|
||||||
|
created_by=self.request.user,
|
||||||
|
))
|
||||||
|
|
||||||
|
Property.objects.bulk_create(food_property_list, ignore_conflicts=True, unique_fields=('space', 'food', 'property_type',))
|
||||||
|
Automation.objects.bulk_create(alias_list, ignore_conflicts=True, unique_fields=('space', 'param_1', 'param_2',))
|
||||||
|
return insert_list + update_list
|
||||||
|
|
||||||
|
def import_conversion(self):
|
||||||
|
datatype = 'conversion'
|
||||||
|
|
||||||
|
insert_list = []
|
||||||
|
for k in list(self.data[datatype].keys()):
|
||||||
|
insert_list.append(UnitConversion(
|
||||||
|
base_amount=self.data[datatype][k]['base_amount'],
|
||||||
|
base_unit_id=self.slug_id_cache['unit'][self.data[datatype][k]['base_unit']],
|
||||||
|
converted_amount=self.data[datatype][k]['converted_amount'],
|
||||||
|
converted_unit_id=self.slug_id_cache['unit'][self.data[datatype][k]['converted_unit']],
|
||||||
|
food_id=self.slug_id_cache['food'][self.data[datatype][k]['food']],
|
||||||
|
open_data_slug=k,
|
||||||
|
space=self.request.space,
|
||||||
|
created_by=self.request.user,
|
||||||
|
))
|
||||||
|
|
||||||
|
return UnitConversion.objects.bulk_create(insert_list, ignore_conflicts=True, unique_fields=('space', 'base_unit', 'converted_unit', 'food', 'open_data_slug'))
|
70
cookbook/helper/property_helper.py
Normal file
70
cookbook/helper/property_helper.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
from django.core.cache import caches
|
||||||
|
|
||||||
|
from cookbook.helper.cache_helper import CacheHelper
|
||||||
|
from cookbook.helper.unit_conversion_helper import UnitConversionHelper
|
||||||
|
from cookbook.models import PropertyType, Unit, Food, Property, Recipe, Step
|
||||||
|
|
||||||
|
|
||||||
|
class FoodPropertyHelper:
|
||||||
|
space = None
|
||||||
|
|
||||||
|
def __init__(self, space):
|
||||||
|
"""
|
||||||
|
Helper to perform food property calculations
|
||||||
|
:param space: space to limit scope to
|
||||||
|
"""
|
||||||
|
self.space = space
|
||||||
|
|
||||||
|
def calculate_recipe_properties(self, recipe):
|
||||||
|
"""
|
||||||
|
Calculate all food properties for a given recipe.
|
||||||
|
:param recipe: recipe to calculate properties for
|
||||||
|
:return: dict of with property keys and total/food values for each property available
|
||||||
|
"""
|
||||||
|
ingredients = []
|
||||||
|
computed_properties = {}
|
||||||
|
|
||||||
|
for s in recipe.steps.all():
|
||||||
|
ingredients += s.ingredients.all()
|
||||||
|
|
||||||
|
property_types = caches['default'].get(CacheHelper(self.space).PROPERTY_TYPE_CACHE_KEY, None)
|
||||||
|
|
||||||
|
if not property_types:
|
||||||
|
property_types = PropertyType.objects.filter(space=self.space).all()
|
||||||
|
caches['default'].set(CacheHelper(self.space).PROPERTY_TYPE_CACHE_KEY, property_types, 60 * 60) # cache is cleared on property type save signal so long duration is fine
|
||||||
|
|
||||||
|
for fpt in property_types:
|
||||||
|
computed_properties[fpt.id] = {'id': fpt.id, 'name': fpt.name, 'icon': fpt.icon, 'description': fpt.description, 'unit': fpt.unit, 'food_values': {}, 'total_value': 0, 'missing_value': False}
|
||||||
|
|
||||||
|
uch = UnitConversionHelper(self.space)
|
||||||
|
|
||||||
|
for i in ingredients:
|
||||||
|
conversions = uch.get_conversions(i)
|
||||||
|
for pt in property_types:
|
||||||
|
found_property = False
|
||||||
|
if i.food.properties_food_amount == 0 or i.food.properties_food_unit is None:
|
||||||
|
computed_properties[pt.id]['missing_value'] = True
|
||||||
|
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': i.food.name, 'value': 0}
|
||||||
|
else:
|
||||||
|
for p in i.food.properties.all():
|
||||||
|
if p.property_type == pt:
|
||||||
|
for c in conversions:
|
||||||
|
if c.unit == i.food.properties_food_unit:
|
||||||
|
found_property = True
|
||||||
|
computed_properties[pt.id]['total_value'] += (c.amount / i.food.properties_food_amount) * p.property_amount
|
||||||
|
computed_properties[pt.id]['food_values'] = self.add_or_create(computed_properties[p.property_type.id]['food_values'], c.food.id, (c.amount / i.food.properties_food_amount) * p.property_amount, c.food)
|
||||||
|
if not found_property:
|
||||||
|
computed_properties[pt.id]['missing_value'] = True
|
||||||
|
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': i.food.name, 'value': 0}
|
||||||
|
|
||||||
|
return computed_properties
|
||||||
|
|
||||||
|
# small dict helper to add to existing key or create new, probably a better way of doing this
|
||||||
|
# TODO move to central helper ?
|
||||||
|
@staticmethod
|
||||||
|
def add_or_create(d, key, value, food):
|
||||||
|
if key in d:
|
||||||
|
d[key]['value'] += value
|
||||||
|
else:
|
||||||
|
d[key] = {'id': food.id, 'food': food.name, 'value': value}
|
||||||
|
return d
|
@ -1,5 +1,6 @@
|
|||||||
# import random
|
# import random
|
||||||
import re
|
import re
|
||||||
|
import traceback
|
||||||
from html import unescape
|
from html import unescape
|
||||||
|
|
||||||
from django.core.cache import caches
|
from django.core.cache import caches
|
||||||
@ -12,7 +13,8 @@ from recipe_scrapers._utils import get_host_name, get_minutes
|
|||||||
|
|
||||||
# from cookbook.helper import recipe_url_import as helper
|
# from cookbook.helper import recipe_url_import as helper
|
||||||
from cookbook.helper.ingredient_parser import IngredientParser
|
from cookbook.helper.ingredient_parser import IngredientParser
|
||||||
from cookbook.models import Automation, Keyword
|
from cookbook.models import Automation, Keyword, PropertyType
|
||||||
|
|
||||||
|
|
||||||
# from unicodedata import decomposition
|
# from unicodedata import decomposition
|
||||||
|
|
||||||
@ -193,6 +195,13 @@ def get_from_scraper(scrape, request):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
recipe_json['properties'] = get_recipe_properties(request.space, scrape.schema.nutrients())
|
||||||
|
print(recipe_json['properties'])
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
|
pass
|
||||||
|
|
||||||
if 'source_url' in recipe_json and recipe_json['source_url']:
|
if 'source_url' in recipe_json and recipe_json['source_url']:
|
||||||
automations = Automation.objects.filter(type=Automation.INSTRUCTION_REPLACE, space=request.space, disabled=False).only('param_1', 'param_2', 'param_3').order_by('order').all()[:512]
|
automations = Automation.objects.filter(type=Automation.INSTRUCTION_REPLACE, space=request.space, disabled=False).only('param_1', 'param_2', 'param_3').order_by('order').all()[:512]
|
||||||
for a in automations:
|
for a in automations:
|
||||||
@ -203,6 +212,30 @@ def get_from_scraper(scrape, request):
|
|||||||
return recipe_json
|
return recipe_json
|
||||||
|
|
||||||
|
|
||||||
|
def get_recipe_properties(space, property_data):
|
||||||
|
# {'servingSize': '1', 'calories': '302 kcal', 'proteinContent': '7,66g', 'fatContent': '11,56g', 'carbohydrateContent': '41,33g'}
|
||||||
|
properties = {
|
||||||
|
"property-calories": "calories",
|
||||||
|
"property-carbohydrates": "carbohydrateContent",
|
||||||
|
"property-proteins": "proteinContent",
|
||||||
|
"property-fats": "fatContent",
|
||||||
|
}
|
||||||
|
recipe_properties = []
|
||||||
|
for pt in PropertyType.objects.filter(space=space, open_data_slug__in=list(properties.keys())).all():
|
||||||
|
for p in list(properties.keys()):
|
||||||
|
if pt.open_data_slug == p:
|
||||||
|
if properties[p] in property_data:
|
||||||
|
recipe_properties.append({
|
||||||
|
'property_type': {
|
||||||
|
'id': pt.id,
|
||||||
|
'name': pt.name,
|
||||||
|
},
|
||||||
|
'property_amount': parse_servings(property_data[properties[p]]) / float(property_data['servingSize']),
|
||||||
|
})
|
||||||
|
|
||||||
|
return recipe_properties
|
||||||
|
|
||||||
|
|
||||||
def get_from_youtube_scraper(url, request):
|
def get_from_youtube_scraper(url, request):
|
||||||
"""A YouTube Information Scraper."""
|
"""A YouTube Information Scraper."""
|
||||||
kw, created = Keyword.objects.get_or_create(name='YouTube', space=request.space)
|
kw, created = Keyword.objects.get_or_create(name='YouTube', space=request.space)
|
||||||
|
141
cookbook/helper/unit_conversion_helper.py
Normal file
141
cookbook/helper/unit_conversion_helper.py
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
from django.core.cache import caches
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from cookbook.helper.cache_helper import CacheHelper
|
||||||
|
from cookbook.models import Ingredient, Unit
|
||||||
|
|
||||||
|
CONVERSION_TABLE = {
|
||||||
|
'weight': {
|
||||||
|
'g': 1000,
|
||||||
|
'kg': 1,
|
||||||
|
'ounce': 35.274,
|
||||||
|
'pound': 2.20462
|
||||||
|
},
|
||||||
|
'volume': {
|
||||||
|
'ml': 1000,
|
||||||
|
'l': 1,
|
||||||
|
'fluid_ounce': 33.814,
|
||||||
|
'pint': 2.11338,
|
||||||
|
'quart': 1.05669,
|
||||||
|
'gallon': 0.264172,
|
||||||
|
'tbsp': 67.628,
|
||||||
|
'tsp': 202.884,
|
||||||
|
'imperial_fluid_ounce': 35.1951,
|
||||||
|
'imperial_pint': 1.75975,
|
||||||
|
'imperial_quart': 0.879877,
|
||||||
|
'imperial_gallon': 0.219969,
|
||||||
|
'imperial_tbsp': 56.3121,
|
||||||
|
'imperial_tsp': 168.936,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
BASE_UNITS_WEIGHT = list(CONVERSION_TABLE['weight'].keys())
|
||||||
|
BASE_UNITS_VOLUME = list(CONVERSION_TABLE['volume'].keys())
|
||||||
|
|
||||||
|
|
||||||
|
class ConversionException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UnitConversionHelper:
|
||||||
|
space = None
|
||||||
|
|
||||||
|
def __init__(self, space):
|
||||||
|
"""
|
||||||
|
Initializes unit conversion helper
|
||||||
|
:param space: space to perform conversions on
|
||||||
|
"""
|
||||||
|
self.space = space
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def convert_from_to(from_unit, to_unit, amount):
|
||||||
|
"""
|
||||||
|
Convert from one base unit to another. Throws ConversionException if trying to convert between different systems (weight/volume) or if units are not supported.
|
||||||
|
:param from_unit: str unit to convert from
|
||||||
|
:param to_unit: str unit to convert to
|
||||||
|
:param amount: amount to convert
|
||||||
|
:return: Decimal converted amount
|
||||||
|
"""
|
||||||
|
system = None
|
||||||
|
if from_unit in BASE_UNITS_WEIGHT and to_unit in BASE_UNITS_WEIGHT:
|
||||||
|
system = 'weight'
|
||||||
|
if from_unit in BASE_UNITS_VOLUME and to_unit in BASE_UNITS_VOLUME:
|
||||||
|
system = 'volume'
|
||||||
|
|
||||||
|
if not system:
|
||||||
|
raise ConversionException('Trying to convert units not existing or not in one unit system (weight/volume)')
|
||||||
|
|
||||||
|
return Decimal(amount / Decimal(CONVERSION_TABLE[system][from_unit] / CONVERSION_TABLE[system][to_unit]))
|
||||||
|
|
||||||
|
def base_conversions(self, ingredient_list):
|
||||||
|
"""
|
||||||
|
Calculates all possible base unit conversions for each ingredient give.
|
||||||
|
Converts to all common base units IF they exist in the unit database of the space.
|
||||||
|
For useful results all ingredients passed should be of the same food, otherwise filtering afterwards might be required.
|
||||||
|
:param ingredient_list: list of ingredients to convert
|
||||||
|
:return: ingredient list with appended conversions
|
||||||
|
"""
|
||||||
|
base_conversion_ingredient_list = ingredient_list.copy()
|
||||||
|
for i in ingredient_list:
|
||||||
|
try:
|
||||||
|
conversion_unit = i.unit.name
|
||||||
|
if i.unit.base_unit:
|
||||||
|
conversion_unit = i.unit.base_unit
|
||||||
|
|
||||||
|
# TODO allow setting which units to convert to? possibly only once conversions become visible
|
||||||
|
units = caches['default'].get(CacheHelper(self.space).BASE_UNITS_CACHE_KEY, None)
|
||||||
|
if not units:
|
||||||
|
units = Unit.objects.filter(space=self.space, base_unit__in=(BASE_UNITS_VOLUME + BASE_UNITS_WEIGHT)).all()
|
||||||
|
caches['default'].set(CacheHelper(self.space).BASE_UNITS_CACHE_KEY, units, 60 * 60) # cache is cleared on unit save signal so long duration is fine
|
||||||
|
|
||||||
|
for u in units:
|
||||||
|
try:
|
||||||
|
ingredient = Ingredient(amount=self.convert_from_to(conversion_unit, u.base_unit, i.amount), unit=u, food=ingredient_list[0].food, )
|
||||||
|
if not any((x.unit.name == ingredient.unit.name or x.unit.base_unit == ingredient.unit.name) for x in base_conversion_ingredient_list):
|
||||||
|
base_conversion_ingredient_list.append(ingredient)
|
||||||
|
except ConversionException:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return base_conversion_ingredient_list
|
||||||
|
|
||||||
|
def get_conversions(self, ingredient):
|
||||||
|
"""
|
||||||
|
Converts an ingredient to all possible conversions based on the custom unit conversion database.
|
||||||
|
After that passes conversion to UnitConversionHelper.base_conversions() to get all base conversions possible.
|
||||||
|
:param ingredient: Ingredient object
|
||||||
|
:return: list of ingredients with all possible custom and base conversions
|
||||||
|
"""
|
||||||
|
conversions = [ingredient]
|
||||||
|
if ingredient.unit:
|
||||||
|
for c in ingredient.unit.unit_conversion_base_relation.all():
|
||||||
|
if c.space == self.space:
|
||||||
|
r = self._uc_convert(c, ingredient.amount, ingredient.unit, ingredient.food)
|
||||||
|
if r and r not in conversions:
|
||||||
|
conversions.append(r)
|
||||||
|
for c in ingredient.unit.unit_conversion_converted_relation.all():
|
||||||
|
if c.space == self.space:
|
||||||
|
r = self._uc_convert(c, ingredient.amount, ingredient.unit, ingredient.food)
|
||||||
|
if r and r not in conversions:
|
||||||
|
conversions.append(r)
|
||||||
|
|
||||||
|
conversions = self.base_conversions(conversions)
|
||||||
|
|
||||||
|
return conversions
|
||||||
|
|
||||||
|
def _uc_convert(self, uc, amount, unit, food):
|
||||||
|
"""
|
||||||
|
Helper to calculate values for custom unit conversions.
|
||||||
|
Converts given base values using the passed UnitConversion object into a converted Ingredient
|
||||||
|
:param uc: UnitConversion object
|
||||||
|
:param amount: base amount
|
||||||
|
:param unit: base unit
|
||||||
|
:param food: base food
|
||||||
|
:return: converted ingredient object from base amount/unit/food
|
||||||
|
"""
|
||||||
|
if uc.food is None or uc.food == food:
|
||||||
|
if unit == uc.base_unit:
|
||||||
|
return Ingredient(amount=amount * (uc.converted_amount / uc.base_amount), unit=uc.converted_unit, food=food, space=self.space)
|
||||||
|
else:
|
||||||
|
return Ingredient(amount=amount * (uc.base_amount / uc.converted_amount), unit=uc.base_unit, food=food, space=self.space)
|
@ -0,0 +1,163 @@
|
|||||||
|
# Generated by Django 4.1.9 on 2023-05-25 13:05
|
||||||
|
|
||||||
|
import cookbook.models
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django_prometheus.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('cookbook', '0188_space_no_sharing_limit'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Property',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('property_amount', models.DecimalField(decimal_places=4, default=0, max_digits=32)),
|
||||||
|
],
|
||||||
|
bases=(models.Model, cookbook.models.PermissionModelMixin),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PropertyType',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=128)),
|
||||||
|
('unit', models.CharField(blank=True, max_length=64, null=True)),
|
||||||
|
('icon', models.CharField(blank=True, max_length=16, null=True)),
|
||||||
|
('description', models.CharField(blank=True, max_length=512, null=True)),
|
||||||
|
('category', models.CharField(blank=True, choices=[('NUTRITION', 'Nutrition'), ('ALLERGEN', 'Allergen'), ('PRICE', 'Price'), ('GOAL', 'Goal'), ('OTHER', 'Other')], max_length=64, null=True)),
|
||||||
|
('open_data_slug', models.CharField(blank=True, default=None, max_length=128, null=True)),
|
||||||
|
],
|
||||||
|
bases=(models.Model, cookbook.models.PermissionModelMixin),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UnitConversion',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('base_amount', models.DecimalField(decimal_places=16, default=0, max_digits=32)),
|
||||||
|
('converted_amount', models.DecimalField(decimal_places=16, default=0, max_digits=32)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('open_data_slug', models.CharField(blank=True, default=None, max_length=128, null=True)),
|
||||||
|
],
|
||||||
|
bases=(django_prometheus.models.ExportModelOperationsMixin('unit_conversion'), models.Model, cookbook.models.PermissionModelMixin),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='food',
|
||||||
|
name='fdc_id',
|
||||||
|
field=models.CharField(blank=True, default=None, max_length=128, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='food',
|
||||||
|
name='open_data_slug',
|
||||||
|
field=models.CharField(blank=True, default=None, max_length=128, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='food',
|
||||||
|
name='preferred_shopping_unit',
|
||||||
|
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='preferred_shopping_unit', to='cookbook.unit'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='food',
|
||||||
|
name='preferred_unit',
|
||||||
|
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='preferred_unit', to='cookbook.unit'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='food',
|
||||||
|
name='properties_food_amount',
|
||||||
|
field=models.IntegerField(blank=True, default=100),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='food',
|
||||||
|
name='properties_food_unit',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='cookbook.unit'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='supermarket',
|
||||||
|
name='open_data_slug',
|
||||||
|
field=models.CharField(blank=True, default=None, max_length=128, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='supermarketcategory',
|
||||||
|
name='open_data_slug',
|
||||||
|
field=models.CharField(blank=True, default=None, max_length=128, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='unit',
|
||||||
|
name='base_unit',
|
||||||
|
field=models.TextField(blank=True, default=None, max_length=256, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='unit',
|
||||||
|
name='open_data_slug',
|
||||||
|
field=models.CharField(blank=True, default=None, max_length=128, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='supermarketcategoryrelation',
|
||||||
|
constraint=models.UniqueConstraint(fields=('supermarket', 'category'), name='unique_sm_category_relation'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='unitconversion',
|
||||||
|
name='base_unit',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='unit_conversion_base_relation', to='cookbook.unit'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='unitconversion',
|
||||||
|
name='converted_unit',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='unit_conversion_converted_relation', to='cookbook.unit'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='unitconversion',
|
||||||
|
name='created_by',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='unitconversion',
|
||||||
|
name='food',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.food'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='unitconversion',
|
||||||
|
name='space',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='propertytype',
|
||||||
|
name='space',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='property',
|
||||||
|
name='property_type',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='cookbook.propertytype'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='property',
|
||||||
|
name='space',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='food',
|
||||||
|
name='properties',
|
||||||
|
field=models.ManyToManyField(blank=True, to='cookbook.property'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='recipe',
|
||||||
|
name='properties',
|
||||||
|
field=models.ManyToManyField(blank=True, to='cookbook.property'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='unitconversion',
|
||||||
|
constraint=models.UniqueConstraint(fields=('space', 'base_unit', 'converted_unit', 'food'), name='f_unique_conversion_per_space'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='propertytype',
|
||||||
|
constraint=models.UniqueConstraint(fields=('space', 'name'), name='property_type_unique_name_per_space'),
|
||||||
|
),
|
||||||
|
]
|
38
cookbook/migrations/0190_auto_20230525_1506.py
Normal file
38
cookbook/migrations/0190_auto_20230525_1506.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# Generated by Django 4.1.9 on 2023-05-25 13:06
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from django_scopes import scopes_disabled
|
||||||
|
from gettext import gettext as _
|
||||||
|
|
||||||
|
def migrate_old_nutrition_data(apps, schema_editor):
|
||||||
|
print('Transforming nutrition information, this might take a while on large databases')
|
||||||
|
with scopes_disabled():
|
||||||
|
PropertyType = apps.get_model('cookbook', 'PropertyType')
|
||||||
|
RecipeProperty = apps.get_model('cookbook', 'Property')
|
||||||
|
Recipe = apps.get_model('cookbook', 'Recipe')
|
||||||
|
Space = apps.get_model('cookbook', 'Space')
|
||||||
|
|
||||||
|
# TODO respect space
|
||||||
|
for s in Space.objects.all():
|
||||||
|
property_fat = PropertyType.objects.get_or_create(name=_('Fat'), unit=_('g'), space=s, )[0]
|
||||||
|
property_carbohydrates = PropertyType.objects.get_or_create(name=_('Carbohydrates'), unit=_('g'), space=s, )[0]
|
||||||
|
property_proteins = PropertyType.objects.get_or_create(name=_('Proteins'), unit=_('g'), space=s, )[0]
|
||||||
|
property_calories = PropertyType.objects.get_or_create(name=_('Calories'), unit=_('kcal'), space=s, )[0]
|
||||||
|
|
||||||
|
for r in Recipe.objects.filter(nutrition__isnull=False, space=s).all():
|
||||||
|
rp_fat = RecipeProperty.objects.create(property_type=property_fat, property_amount=r.nutrition.fats, space=s)
|
||||||
|
rp_carbohydrates = RecipeProperty.objects.create(property_type=property_carbohydrates, property_amount=r.nutrition.carbohydrates, space=s)
|
||||||
|
rp_proteins = RecipeProperty.objects.create(property_type=property_proteins, property_amount=r.nutrition.proteins, space=s)
|
||||||
|
rp_calories = RecipeProperty.objects.create(property_type=property_calories, property_amount=r.nutrition.calories, space=s)
|
||||||
|
r.properties.add(rp_fat, rp_carbohydrates, rp_proteins, rp_calories)
|
||||||
|
r.nutrition = None
|
||||||
|
r.save()
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('cookbook', '0189_property_propertytype_unitconversion_food_fdc_id_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(migrate_old_nutrition_data)
|
||||||
|
]
|
@ -82,10 +82,13 @@ class TreeManager(MP_NodeManager):
|
|||||||
# model.Manager get_or_create() is not compatible with MP_Tree
|
# model.Manager get_or_create() is not compatible with MP_Tree
|
||||||
def get_or_create(self, *args, **kwargs):
|
def get_or_create(self, *args, **kwargs):
|
||||||
kwargs['name'] = kwargs['name'].strip()
|
kwargs['name'] = kwargs['name'].strip()
|
||||||
|
if hasattr(self, 'space'):
|
||||||
if obj := self.filter(name__iexact=kwargs['name'], space=kwargs['space']).first():
|
if obj := self.filter(name__iexact=kwargs['name'], space=kwargs['space']).first():
|
||||||
return obj, False
|
return obj, False
|
||||||
else:
|
else:
|
||||||
|
if obj := self.filter(name__iexact=kwargs['name']).first():
|
||||||
|
return obj, False
|
||||||
|
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
try:
|
try:
|
||||||
defaults = kwargs.pop('defaults', None)
|
defaults = kwargs.pop('defaults', None)
|
||||||
@ -454,6 +457,7 @@ class Sync(models.Model, PermissionModelMixin):
|
|||||||
class SupermarketCategory(models.Model, PermissionModelMixin):
|
class SupermarketCategory(models.Model, PermissionModelMixin):
|
||||||
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
||||||
description = models.TextField(blank=True, null=True)
|
description = models.TextField(blank=True, null=True)
|
||||||
|
open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||||
|
|
||||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||||
objects = ScopedManager(space='space')
|
objects = ScopedManager(space='space')
|
||||||
@ -471,6 +475,7 @@ class Supermarket(models.Model, PermissionModelMixin):
|
|||||||
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
||||||
description = models.TextField(blank=True, null=True)
|
description = models.TextField(blank=True, null=True)
|
||||||
categories = models.ManyToManyField(SupermarketCategory, through='SupermarketCategoryRelation')
|
categories = models.ManyToManyField(SupermarketCategory, through='SupermarketCategoryRelation')
|
||||||
|
open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||||
|
|
||||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||||
objects = ScopedManager(space='space')
|
objects = ScopedManager(space='space')
|
||||||
@ -496,6 +501,9 @@ class SupermarketCategoryRelation(models.Model, PermissionModelMixin):
|
|||||||
return 'supermarket', 'space'
|
return 'supermarket', 'space'
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(fields=['supermarket', 'category'], name='unique_sm_category_relation')
|
||||||
|
]
|
||||||
ordering = ('order',)
|
ordering = ('order',)
|
||||||
|
|
||||||
|
|
||||||
@ -534,6 +542,8 @@ class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixi
|
|||||||
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
||||||
plural_name = models.CharField(max_length=128, null=True, blank=True, default=None)
|
plural_name = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||||
description = models.TextField(blank=True, null=True)
|
description = models.TextField(blank=True, null=True)
|
||||||
|
base_unit = models.TextField(max_length=256, null=True, blank=True, default=None)
|
||||||
|
open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||||
|
|
||||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||||
objects = ScopedManager(space='space')
|
objects = ScopedManager(space='space')
|
||||||
@ -569,6 +579,15 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
|||||||
substitute_children = models.BooleanField(default=False)
|
substitute_children = models.BooleanField(default=False)
|
||||||
child_inherit_fields = models.ManyToManyField(FoodInheritField, blank=True, related_name='child_inherit')
|
child_inherit_fields = models.ManyToManyField(FoodInheritField, blank=True, related_name='child_inherit')
|
||||||
|
|
||||||
|
properties = models.ManyToManyField("Property", blank=True)
|
||||||
|
properties_food_amount = models.IntegerField(default=100, blank=True)
|
||||||
|
properties_food_unit = models.ForeignKey(Unit, on_delete=models.PROTECT, blank=True, null=True)
|
||||||
|
|
||||||
|
preferred_unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True, default=None, related_name='preferred_unit')
|
||||||
|
preferred_shopping_unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True, default=None, related_name='preferred_shopping_unit')
|
||||||
|
fdc_id = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||||
|
|
||||||
|
open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||||
objects = ScopedManager(space='space', _manager_class=TreeManager)
|
objects = ScopedManager(space='space', _manager_class=TreeManager)
|
||||||
|
|
||||||
@ -650,6 +669,31 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UnitConversion(ExportModelOperationsMixin('unit_conversion'), models.Model, PermissionModelMixin):
|
||||||
|
base_amount = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
||||||
|
base_unit = models.ForeignKey('Unit', on_delete=models.CASCADE, related_name='unit_conversion_base_relation')
|
||||||
|
converted_amount = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
||||||
|
converted_unit = models.ForeignKey('Unit', on_delete=models.CASCADE, related_name='unit_conversion_converted_relation')
|
||||||
|
|
||||||
|
food = models.ForeignKey('Food', on_delete=models.CASCADE, null=True, blank=True)
|
||||||
|
|
||||||
|
created_by = models.ForeignKey(User, on_delete=models.PROTECT)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||||
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||||
|
objects = ScopedManager(space='space')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.base_amount} {self.base_unit} -> {self.converted_amount} {self.converted_unit} {self.food}'
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(fields=['space', 'base_unit', 'converted_unit', 'food'], name='f_unique_conversion_per_space')
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, PermissionModelMixin):
|
class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, PermissionModelMixin):
|
||||||
# delete method on Food and Unit checks if they are part of a Recipe, if it is raises a ProtectedError instead of cascading the delete
|
# delete method on Food and Unit checks if they are part of a Recipe, if it is raises a ProtectedError instead of cascading the delete
|
||||||
food = models.ForeignKey(Food, on_delete=models.CASCADE, null=True, blank=True)
|
food = models.ForeignKey(Food, on_delete=models.CASCADE, null=True, blank=True)
|
||||||
@ -663,8 +707,6 @@ class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, Permiss
|
|||||||
order = models.IntegerField(default=0)
|
order = models.IntegerField(default=0)
|
||||||
original_text = models.CharField(max_length=512, null=True, blank=True, default=None)
|
original_text = models.CharField(max_length=512, null=True, blank=True, default=None)
|
||||||
|
|
||||||
original_text = models.CharField(max_length=512, null=True, blank=True, default=None)
|
|
||||||
|
|
||||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||||
objects = ScopedManager(space='space')
|
objects = ScopedManager(space='space')
|
||||||
|
|
||||||
@ -720,6 +762,46 @@ class Step(ExportModelOperationsMixin('step'), models.Model, PermissionModelMixi
|
|||||||
indexes = (GinIndex(fields=["search_vector"]),)
|
indexes = (GinIndex(fields=["search_vector"]),)
|
||||||
|
|
||||||
|
|
||||||
|
class PropertyType(models.Model, PermissionModelMixin):
|
||||||
|
NUTRITION = 'NUTRITION'
|
||||||
|
ALLERGEN = 'ALLERGEN'
|
||||||
|
PRICE = 'PRICE'
|
||||||
|
GOAL = 'GOAL'
|
||||||
|
OTHER = 'OTHER'
|
||||||
|
|
||||||
|
name = models.CharField(max_length=128)
|
||||||
|
unit = models.CharField(max_length=64, blank=True, null=True)
|
||||||
|
icon = models.CharField(max_length=16, blank=True, null=True)
|
||||||
|
description = models.CharField(max_length=512, blank=True, null=True)
|
||||||
|
category = models.CharField(max_length=64, choices=((NUTRITION, _('Nutrition')), (ALLERGEN, _('Allergen')), (PRICE, _('Price')), (GOAL, _('Goal')), (OTHER, _('Other'))), null=True, blank=True)
|
||||||
|
open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||||
|
|
||||||
|
# TODO show if empty property?
|
||||||
|
# TODO formatting property?
|
||||||
|
|
||||||
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||||
|
objects = ScopedManager(space='space')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.name}'
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(fields=['space', 'name'], name='property_type_unique_name_per_space')
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Property(models.Model, PermissionModelMixin):
|
||||||
|
property_amount = models.DecimalField(default=0, decimal_places=4, max_digits=32)
|
||||||
|
property_type = models.ForeignKey(PropertyType, on_delete=models.PROTECT)
|
||||||
|
|
||||||
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||||
|
objects = ScopedManager(space='space')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.property_amount} {self.property_type.unit} {self.property_type.name}'
|
||||||
|
|
||||||
|
|
||||||
class NutritionInformation(models.Model, PermissionModelMixin):
|
class NutritionInformation(models.Model, PermissionModelMixin):
|
||||||
fats = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
fats = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
||||||
carbohydrates = models.DecimalField(
|
carbohydrates = models.DecimalField(
|
||||||
@ -736,14 +818,6 @@ class NutritionInformation(models.Model, PermissionModelMixin):
|
|||||||
return f'Nutrition {self.pk}'
|
return f'Nutrition {self.pk}'
|
||||||
|
|
||||||
|
|
||||||
# class NutritionType(models.Model, PermissionModelMixin):
|
|
||||||
# name = models.CharField(max_length=128)
|
|
||||||
# icon = models.CharField(max_length=16, blank=True, null=True)
|
|
||||||
# description = models.CharField(max_length=512, blank=True, null=True)
|
|
||||||
#
|
|
||||||
# space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
|
||||||
# objects = ScopedManager(space='space')
|
|
||||||
|
|
||||||
class RecipeManager(models.Manager.from_queryset(models.QuerySet)):
|
class RecipeManager(models.Manager.from_queryset(models.QuerySet)):
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return super(RecipeManager, self).get_queryset().annotate(rating=Avg('cooklog__rating')).annotate(last_cooked=Max('cooklog__created_at'))
|
return super(RecipeManager, self).get_queryset().annotate(rating=Avg('cooklog__rating')).annotate(last_cooked=Max('cooklog__created_at'))
|
||||||
@ -766,6 +840,7 @@ class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModel
|
|||||||
waiting_time = models.IntegerField(default=0)
|
waiting_time = models.IntegerField(default=0)
|
||||||
internal = models.BooleanField(default=False)
|
internal = models.BooleanField(default=False)
|
||||||
nutrition = models.ForeignKey(NutritionInformation, blank=True, null=True, on_delete=models.CASCADE)
|
nutrition = models.ForeignKey(NutritionInformation, blank=True, null=True, on_delete=models.CASCADE)
|
||||||
|
properties = models.ManyToManyField(Property, blank=True)
|
||||||
show_ingredient_overview = models.BooleanField(default=True)
|
show_ingredient_overview = models.BooleanField(default=True)
|
||||||
private = models.BooleanField(default=False)
|
private = models.BooleanField(default=False)
|
||||||
shared = models.ManyToManyField(User, blank=True, related_name='recipe_shared_with')
|
shared = models.ManyToManyField(User, blank=True, related_name='recipe_shared_with')
|
||||||
|
@ -7,6 +7,7 @@ from html import escape
|
|||||||
from smtplib import SMTPException
|
from smtplib import SMTPException
|
||||||
|
|
||||||
from django.contrib.auth.models import Group, User, AnonymousUser
|
from django.contrib.auth.models import Group, User, AnonymousUser
|
||||||
|
from django.core.cache import caches
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
from django.db.models import Avg, Q, QuerySet, Sum
|
from django.db.models import Avg, Q, QuerySet, Sum
|
||||||
from django.http import BadHeaderError
|
from django.http import BadHeaderError
|
||||||
@ -21,15 +22,18 @@ from rest_framework.exceptions import NotFound, ValidationError
|
|||||||
|
|
||||||
from cookbook.helper.CustomStorageClass import CachedS3Boto3Storage
|
from cookbook.helper.CustomStorageClass import CachedS3Boto3Storage
|
||||||
from cookbook.helper.HelperFunctions import str2bool
|
from cookbook.helper.HelperFunctions import str2bool
|
||||||
|
from cookbook.helper.property_helper import FoodPropertyHelper
|
||||||
from cookbook.helper.permission_helper import above_space_limit
|
from cookbook.helper.permission_helper import above_space_limit
|
||||||
from cookbook.helper.shopping_helper import RecipeShoppingEditor
|
from cookbook.helper.shopping_helper import RecipeShoppingEditor
|
||||||
|
from cookbook.helper.unit_conversion_helper import UnitConversionHelper
|
||||||
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, CustomFilter,
|
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, CustomFilter,
|
||||||
ExportLog, Food, FoodInheritField, ImportLog, Ingredient, InviteLink,
|
ExportLog, Food, FoodInheritField, ImportLog, Ingredient, InviteLink,
|
||||||
Keyword, MealPlan, MealType, NutritionInformation, Recipe, RecipeBook,
|
Keyword, MealPlan, MealType, NutritionInformation, Recipe, RecipeBook,
|
||||||
RecipeBookEntry, RecipeImport, ShareLink, ShoppingList,
|
RecipeBookEntry, RecipeImport, ShareLink, ShoppingList,
|
||||||
ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
|
ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
|
||||||
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync,
|
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync,
|
||||||
SyncLog, Unit, UserFile, UserPreference, UserSpace, ViewLog)
|
SyncLog, Unit, UserFile, UserPreference, UserSpace, ViewLog, UnitConversion, Property,
|
||||||
|
PropertyType, Property)
|
||||||
from cookbook.templatetags.custom_tags import markdown
|
from cookbook.templatetags.custom_tags import markdown
|
||||||
from recipes.settings import AWS_ENABLED, MEDIA_URL
|
from recipes.settings import AWS_ENABLED, MEDIA_URL
|
||||||
|
|
||||||
@ -102,15 +106,21 @@ class CustomOnHandField(serializers.Field):
|
|||||||
return instance
|
return instance
|
||||||
|
|
||||||
def to_representation(self, obj):
|
def to_representation(self, obj):
|
||||||
shared_users = None
|
if not self.context["request"].user.is_authenticated:
|
||||||
if request := self.context.get('request', None):
|
return []
|
||||||
shared_users = getattr(request, '_shared_users', None)
|
shared_users = []
|
||||||
if shared_users is None:
|
if c := caches['default'].get(f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None):
|
||||||
|
shared_users = c
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [
|
shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [
|
||||||
self.context['request'].user.id]
|
self.context['request'].user.id]
|
||||||
|
caches['default'].set(
|
||||||
|
f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}',
|
||||||
|
shared_users, timeout=5 * 60)
|
||||||
|
# TODO ugly hack that improves API performance significantly, should be done properly
|
||||||
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 = []
|
pass
|
||||||
return obj.onhand_users.filter(id__in=shared_users).exists()
|
return obj.onhand_users.filter(id__in=shared_users).exists()
|
||||||
|
|
||||||
def to_internal_value(self, data):
|
def to_internal_value(self, data):
|
||||||
@ -276,10 +286,13 @@ class SpaceSerializer(WritableNestedModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Space
|
model = Space
|
||||||
fields = ('id', 'name', 'created_by', 'created_at', 'message', 'max_recipes', 'max_file_storage_mb', 'max_users',
|
fields = (
|
||||||
|
'id', 'name', 'created_by', 'created_at', 'message', 'max_recipes', 'max_file_storage_mb', 'max_users',
|
||||||
'allow_sharing', 'demo', 'food_inherit', 'show_facet_count', 'user_count', 'recipe_count', 'file_size_mb',
|
'allow_sharing', 'demo', 'food_inherit', 'show_facet_count', 'user_count', 'recipe_count', 'file_size_mb',
|
||||||
'image', 'use_plural',)
|
'image', 'use_plural',)
|
||||||
read_only_fields = ('id', 'created_by', 'created_at', 'max_recipes', 'max_file_storage_mb', 'max_users', 'allow_sharing', 'demo',)
|
read_only_fields = (
|
||||||
|
'id', 'created_by', 'created_at', 'max_recipes', 'max_file_storage_mb', 'max_users', 'allow_sharing',
|
||||||
|
'demo',)
|
||||||
|
|
||||||
|
|
||||||
class UserSpaceSerializer(WritableNestedModelSerializer):
|
class UserSpaceSerializer(WritableNestedModelSerializer):
|
||||||
@ -440,7 +453,8 @@ class UnitSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
|
|||||||
return unit
|
return unit
|
||||||
|
|
||||||
space = validated_data.pop('space', self.context['request'].space)
|
space = validated_data.pop('space', self.context['request'].space)
|
||||||
obj, created = Unit.objects.get_or_create(name=name, plural_name=plural_name, space=space, defaults=validated_data)
|
obj, created = Unit.objects.get_or_create(name=name, plural_name=plural_name, space=space,
|
||||||
|
defaults=validated_data)
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
@ -451,7 +465,7 @@ class UnitSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Unit
|
model = Unit
|
||||||
fields = ('id', 'name', 'plural_name', 'description', 'numrecipe', 'image')
|
fields = ('id', 'name', 'plural_name', 'description', 'numrecipe', 'image', 'open_data_slug')
|
||||||
read_only_fields = ('id', 'numrecipe', 'image')
|
read_only_fields = ('id', 'numrecipe', 'image')
|
||||||
|
|
||||||
|
|
||||||
@ -484,7 +498,37 @@ class SupermarketSerializer(UniqueFieldsMixin, SpacedModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Supermarket
|
model = Supermarket
|
||||||
fields = ('id', 'name', 'description', 'category_to_supermarket')
|
fields = ('id', 'name', 'description', 'category_to_supermarket', 'open_data_slug')
|
||||||
|
|
||||||
|
|
||||||
|
class PropertyTypeSerializer(serializers.ModelSerializer):
|
||||||
|
def create(self, validated_data):
|
||||||
|
validated_data['space'] = self.context['request'].space
|
||||||
|
|
||||||
|
if property_type := PropertyType.objects.filter(Q(name=validated_data['name'])).first():
|
||||||
|
return property_type
|
||||||
|
|
||||||
|
return super().create(validated_data)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = PropertyType
|
||||||
|
fields = ('id', 'name', 'icon', 'unit', 'description', 'open_data_slug')
|
||||||
|
|
||||||
|
|
||||||
|
class PropertySerializer(UniqueFieldsMixin, WritableNestedModelSerializer):
|
||||||
|
property_type = PropertyTypeSerializer()
|
||||||
|
property_amount = CustomDecimalField()
|
||||||
|
|
||||||
|
# TODO prevent updates
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
validated_data['space'] = self.context['request'].space
|
||||||
|
return super().create(validated_data)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Property
|
||||||
|
fields = ('id', 'property_amount', 'property_type')
|
||||||
|
read_only_fields = ('id',)
|
||||||
|
|
||||||
|
|
||||||
class RecipeSimpleSerializer(WritableNestedModelSerializer):
|
class RecipeSimpleSerializer(WritableNestedModelSerializer):
|
||||||
@ -523,19 +567,29 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
|
|||||||
substitute_onhand = serializers.SerializerMethodField('get_substitute_onhand')
|
substitute_onhand = serializers.SerializerMethodField('get_substitute_onhand')
|
||||||
substitute = FoodSimpleSerializer(many=True, allow_null=True, required=False)
|
substitute = FoodSimpleSerializer(many=True, allow_null=True, required=False)
|
||||||
|
|
||||||
|
properties = PropertySerializer(many=True, allow_null=True, required=False)
|
||||||
|
properties_food_unit = UnitSerializer(allow_null=True, required=False)
|
||||||
|
|
||||||
recipe_filter = 'steps__ingredients__food'
|
recipe_filter = 'steps__ingredients__food'
|
||||||
images = ['recipe__image']
|
images = ['recipe__image']
|
||||||
|
|
||||||
def get_substitute_onhand(self, obj):
|
def get_substitute_onhand(self, obj):
|
||||||
shared_users = None
|
if not self.context["request"].user.is_authenticated:
|
||||||
if request := self.context.get('request', None):
|
return []
|
||||||
shared_users = getattr(request, '_shared_users', None)
|
shared_users = []
|
||||||
if shared_users is None:
|
if c := caches['default'].get(
|
||||||
|
f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None):
|
||||||
|
shared_users = c
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [
|
shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [
|
||||||
self.context['request'].user.id]
|
self.context['request'].user.id]
|
||||||
except AttributeError:
|
caches['default'].set(
|
||||||
shared_users = []
|
f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}',
|
||||||
|
shared_users, timeout=5 * 60)
|
||||||
|
# TODO ugly hack that improves API performance significantly, should be done properly
|
||||||
|
except AttributeError: # Anonymous users (using share links) don't have shared users
|
||||||
|
pass
|
||||||
filter = Q(id__in=obj.substitute.all())
|
filter = Q(id__in=obj.substitute.all())
|
||||||
if obj.substitute_siblings:
|
if obj.substitute_siblings:
|
||||||
filter |= Q(path__startswith=obj.path[:Food.steplen * (obj.depth - 1)], depth=obj.depth)
|
filter |= Q(path__startswith=obj.path[:Food.steplen * (obj.depth - 1)], depth=obj.depth)
|
||||||
@ -547,7 +601,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
|
|||||||
# 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):
|
||||||
name = validated_data.pop('name').strip()
|
name = validated_data['name'].strip()
|
||||||
|
|
||||||
if plural_name := validated_data.pop('plural_name', None):
|
if plural_name := validated_data.pop('plural_name', None):
|
||||||
plural_name = plural_name.strip()
|
plural_name = plural_name.strip()
|
||||||
@ -579,7 +633,8 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
|
|||||||
else:
|
else:
|
||||||
validated_data['onhand_users'] = list(set(onhand_users) - set(shared_users))
|
validated_data['onhand_users'] = list(set(onhand_users) - set(shared_users))
|
||||||
|
|
||||||
obj, created = Food.objects.get_or_create(name=name, plural_name=plural_name, space=space, defaults=validated_data)
|
obj, created = Food.objects.get_or_create(name=name, plural_name=plural_name, space=space,
|
||||||
|
defaults=validated_data)
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
@ -606,9 +661,11 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Food
|
model = Food
|
||||||
fields = (
|
fields = (
|
||||||
'id', 'name', 'plural_name', 'description', 'shopping', 'recipe', 'food_onhand', 'supermarket_category',
|
'id', 'name', 'plural_name', 'description', 'shopping', 'recipe',
|
||||||
|
'properties', 'properties_food_amount', 'properties_food_unit',
|
||||||
|
'food_onhand', 'supermarket_category',
|
||||||
'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name', 'ignore_shopping',
|
'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name', 'ignore_shopping',
|
||||||
'substitute', 'substitute_siblings', 'substitute_children', 'substitute_onhand', 'child_inherit_fields'
|
'substitute', 'substitute_siblings', 'substitute_children', 'substitute_onhand', 'child_inherit_fields', 'open_data_slug',
|
||||||
)
|
)
|
||||||
read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe')
|
read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe')
|
||||||
|
|
||||||
@ -618,9 +675,24 @@ class IngredientSimpleSerializer(WritableNestedModelSerializer):
|
|||||||
unit = UnitSerializer(allow_null=True)
|
unit = UnitSerializer(allow_null=True)
|
||||||
used_in_recipes = serializers.SerializerMethodField('get_used_in_recipes')
|
used_in_recipes = serializers.SerializerMethodField('get_used_in_recipes')
|
||||||
amount = CustomDecimalField()
|
amount = CustomDecimalField()
|
||||||
|
conversions = serializers.SerializerMethodField('get_conversions')
|
||||||
|
|
||||||
def get_used_in_recipes(self, obj):
|
def get_used_in_recipes(self, obj):
|
||||||
return list(Recipe.objects.filter(steps__ingredients=obj.id).values('id', 'name'))
|
used_in = []
|
||||||
|
for s in obj.step_set.all():
|
||||||
|
for r in s.recipe_set.all():
|
||||||
|
used_in.append({'id': r.id, 'name': r.name})
|
||||||
|
return used_in
|
||||||
|
|
||||||
|
def get_conversions(self, obj):
|
||||||
|
if obj.unit and obj.food:
|
||||||
|
uch = UnitConversionHelper(self.context['request'].space)
|
||||||
|
conversions = []
|
||||||
|
for c in uch.get_conversions(obj):
|
||||||
|
conversions.append({'food': c.food.name, 'unit': c.unit.name, 'amount': c.amount}) # TODO do formatting in helper
|
||||||
|
return conversions
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
validated_data['space'] = self.context['request'].space
|
validated_data['space'] = self.context['request'].space
|
||||||
@ -633,10 +705,11 @@ class IngredientSimpleSerializer(WritableNestedModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Ingredient
|
model = Ingredient
|
||||||
fields = (
|
fields = (
|
||||||
'id', 'food', 'unit', 'amount', 'note', 'order',
|
'id', 'food', 'unit', 'amount', 'conversions', 'note', 'order',
|
||||||
'is_header', 'no_amount', 'original_text', 'used_in_recipes',
|
'is_header', 'no_amount', 'original_text', 'used_in_recipes',
|
||||||
'always_use_plural_unit', 'always_use_plural_food',
|
'always_use_plural_unit', 'always_use_plural_food',
|
||||||
)
|
)
|
||||||
|
read_only_fields = ['conversions', ]
|
||||||
|
|
||||||
|
|
||||||
class IngredientSerializer(IngredientSimpleSerializer):
|
class IngredientSerializer(IngredientSimpleSerializer):
|
||||||
@ -688,6 +761,30 @@ class StepRecipeSerializer(WritableNestedModelSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UnitConversionSerializer(WritableNestedModelSerializer):
|
||||||
|
name = serializers.SerializerMethodField('get_conversion_name')
|
||||||
|
base_unit = UnitSerializer()
|
||||||
|
converted_unit = UnitSerializer()
|
||||||
|
food = FoodSerializer(allow_null=True, required=False)
|
||||||
|
base_amount = CustomDecimalField()
|
||||||
|
converted_amount = CustomDecimalField()
|
||||||
|
|
||||||
|
def get_conversion_name(self, obj):
|
||||||
|
text = f'{round(obj.base_amount)} {obj.base_unit} '
|
||||||
|
if obj.food:
|
||||||
|
text += f' {obj.food}'
|
||||||
|
return text + f' = {round(obj.converted_amount)} {obj.converted_unit}'
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
validated_data['space'] = self.context['request'].space
|
||||||
|
validated_data['created_by'] = self.context['request'].user
|
||||||
|
return super().create(validated_data)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = UnitConversion
|
||||||
|
fields = ('id', 'name', 'base_amount', 'base_unit', 'converted_amount', 'converted_unit', 'food', 'open_data_slug')
|
||||||
|
|
||||||
|
|
||||||
class NutritionInformationSerializer(serializers.ModelSerializer):
|
class NutritionInformationSerializer(serializers.ModelSerializer):
|
||||||
carbohydrates = CustomDecimalField()
|
carbohydrates = CustomDecimalField()
|
||||||
fats = CustomDecimalField()
|
fats = CustomDecimalField()
|
||||||
@ -738,21 +835,28 @@ class RecipeOverviewSerializer(RecipeBaseSerializer):
|
|||||||
|
|
||||||
class RecipeSerializer(RecipeBaseSerializer):
|
class RecipeSerializer(RecipeBaseSerializer):
|
||||||
nutrition = NutritionInformationSerializer(allow_null=True, required=False)
|
nutrition = NutritionInformationSerializer(allow_null=True, required=False)
|
||||||
|
properties = PropertySerializer(many=True, required=False)
|
||||||
steps = StepSerializer(many=True)
|
steps = StepSerializer(many=True)
|
||||||
keywords = KeywordSerializer(many=True)
|
keywords = KeywordSerializer(many=True)
|
||||||
shared = UserSerializer(many=True, required=False)
|
shared = UserSerializer(many=True, required=False)
|
||||||
rating = CustomDecimalField(required=False, allow_null=True, read_only=True)
|
rating = CustomDecimalField(required=False, allow_null=True, read_only=True)
|
||||||
last_cooked = serializers.DateTimeField(required=False, allow_null=True, read_only=True)
|
last_cooked = serializers.DateTimeField(required=False, allow_null=True, read_only=True)
|
||||||
|
food_properties = serializers.SerializerMethodField('get_food_properties')
|
||||||
|
|
||||||
|
def get_food_properties(self, obj):
|
||||||
|
fph = FoodPropertyHelper(obj.space) # initialize with object space since recipes might be viewed anonymously
|
||||||
|
return fph.calculate_recipe_properties(obj)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Recipe
|
model = Recipe
|
||||||
fields = (
|
fields = (
|
||||||
'id', 'name', 'description', 'image', 'keywords', 'steps', 'working_time',
|
'id', 'name', 'description', 'image', 'keywords', 'steps', 'working_time',
|
||||||
'waiting_time', 'created_by', 'created_at', 'updated_at', 'source_url',
|
'waiting_time', 'created_by', 'created_at', 'updated_at', 'source_url',
|
||||||
'internal', 'show_ingredient_overview', 'nutrition', 'servings', 'file_path', 'servings_text', 'rating', 'last_cooked',
|
'internal', 'show_ingredient_overview', 'nutrition', 'properties', 'food_properties', 'servings', 'file_path', 'servings_text', 'rating',
|
||||||
|
'last_cooked',
|
||||||
'private', 'shared',
|
'private', 'shared',
|
||||||
)
|
)
|
||||||
read_only_fields = ['image', 'created_by', 'created_at']
|
read_only_fields = ['image', 'created_by', 'created_at', 'food_properties']
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
above_limit, msg = above_space_limit(self.context['request'].space)
|
above_limit, msg = above_space_limit(self.context['request'].space)
|
||||||
@ -1089,13 +1193,19 @@ class InviteLinkSerializer(WritableNestedModelSerializer):
|
|||||||
|
|
||||||
if obj.email:
|
if obj.email:
|
||||||
try:
|
try:
|
||||||
if InviteLink.objects.filter(space=self.context['request'].space, created_at__gte=datetime.now() - timedelta(hours=4)).count() < 20:
|
if InviteLink.objects.filter(space=self.context['request'].space,
|
||||||
message = _('Hello') + '!\n\n' + _('You have been invited by ') + escape(self.context['request'].user.get_user_display_name())
|
created_at__gte=datetime.now() - timedelta(hours=4)).count() < 20:
|
||||||
message += _(' to join their Tandoor Recipes space ') + escape(self.context['request'].space.name) + '.\n\n'
|
message = _('Hello') + '!\n\n' + _('You have been invited by ') + escape(
|
||||||
message += _('Click the following link to activate your account: ') + self.context['request'].build_absolute_uri(reverse('view_invite', args=[str(obj.uuid)])) + '\n\n'
|
self.context['request'].user.get_user_display_name())
|
||||||
message += _('If the link does not work use the following code to manually join the space: ') + str(obj.uuid) + '\n\n'
|
message += _(' to join their Tandoor Recipes space ') + escape(
|
||||||
|
self.context['request'].space.name) + '.\n\n'
|
||||||
|
message += _('Click the following link to activate your account: ') + self.context[
|
||||||
|
'request'].build_absolute_uri(reverse('view_invite', args=[str(obj.uuid)])) + '\n\n'
|
||||||
|
message += _('If the link does not work use the following code to manually join the space: ') + str(
|
||||||
|
obj.uuid) + '\n\n'
|
||||||
message += _('The invitation is valid until ') + str(obj.valid_until) + '\n\n'
|
message += _('The invitation is valid until ') + str(obj.valid_until) + '\n\n'
|
||||||
message += _('Tandoor Recipes is an Open Source recipe manager. Check it out on GitHub ') + 'https://github.com/vabene1111/recipes/'
|
message += _(
|
||||||
|
'Tandoor Recipes is an Open Source recipe manager. Check it out on GitHub ') + 'https://github.com/vabene1111/recipes/'
|
||||||
|
|
||||||
send_mail(
|
send_mail(
|
||||||
_('Tandoor Recipes Invite'),
|
_('Tandoor Recipes Invite'),
|
||||||
@ -1204,7 +1314,8 @@ class IngredientExportSerializer(WritableNestedModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Ingredient
|
model = Ingredient
|
||||||
fields = ('food', 'unit', 'amount', 'note', 'order', 'is_header', 'no_amount', 'always_use_plural_unit', 'always_use_plural_food')
|
fields = ('food', 'unit', 'amount', 'note', 'order', 'is_header', 'no_amount', 'always_use_plural_unit',
|
||||||
|
'always_use_plural_food')
|
||||||
|
|
||||||
|
|
||||||
class StepExportSerializer(WritableNestedModelSerializer):
|
class StepExportSerializer(WritableNestedModelSerializer):
|
||||||
|
@ -4,15 +4,17 @@ from functools import wraps
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.postgres.search import SearchVector
|
from django.contrib.postgres.search import SearchVector
|
||||||
|
from django.core.cache import caches
|
||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils import translation
|
from django.utils import translation
|
||||||
from django_scopes import scope, scopes_disabled
|
from django_scopes import scope, scopes_disabled
|
||||||
|
|
||||||
|
from cookbook.helper.cache_helper import CacheHelper
|
||||||
from cookbook.helper.shopping_helper import RecipeShoppingEditor
|
from cookbook.helper.shopping_helper import RecipeShoppingEditor
|
||||||
from cookbook.managers import DICTIONARY
|
from cookbook.managers import DICTIONARY
|
||||||
from cookbook.models import (Food, FoodInheritField, Ingredient, MealPlan, Recipe,
|
from cookbook.models import (Food, FoodInheritField, Ingredient, MealPlan, Recipe,
|
||||||
ShoppingListEntry, Step, UserPreference, SearchPreference, SearchFields)
|
ShoppingListEntry, Step, UserPreference, SearchPreference, SearchFields, Unit, PropertyType)
|
||||||
|
|
||||||
SQLITE = True
|
SQLITE = True
|
||||||
if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
|
if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
|
||||||
@ -149,3 +151,15 @@ def auto_add_shopping(sender, instance=None, created=False, weak=False, **kwargs
|
|||||||
print("MEAL_AUTO_ADD Created SLR")
|
print("MEAL_AUTO_ADD Created SLR")
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=Unit)
|
||||||
|
def clear_unit_cache(sender, instance=None, created=False, **kwargs):
|
||||||
|
if instance:
|
||||||
|
caches['default'].delete(CacheHelper(instance.space).BASE_UNITS_CACHE_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=PropertyType)
|
||||||
|
def clear_property_type_cache(sender, instance=None, created=False, **kwargs):
|
||||||
|
if instance:
|
||||||
|
caches['default'].delete(CacheHelper(instance.space).PROPERTY_TYPE_CACHE_KEY)
|
||||||
|
@ -270,6 +270,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<a href="{% url 'list_property_type' %}" class="p-0 p-md-1">
|
||||||
|
<div class="card p-0 no-gutters border-0">
|
||||||
|
<div class="card-body text-center p-0 no-gutters">
|
||||||
|
<i class="fas fa-database fa-2x"></i>
|
||||||
|
</div>
|
||||||
|
<div class="card-body text-break text-center p-0 no-gutters text-muted menu-dropdown-text">
|
||||||
|
{% trans 'Food Properties' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row m-0 mt-2 mt-md-0">
|
||||||
|
<div class="col-4">
|
||||||
|
<a href="{% url 'list_unit_conversion' %}" class="p-0 p-md-1">
|
||||||
|
<div class="card p-0 no-gutters border-0">
|
||||||
|
<div class="card-body text-center p-0 no-gutters">
|
||||||
|
<i class="fas fa-exchange-alt fa-2x"></i>
|
||||||
|
</div>
|
||||||
|
<div class="card-body text-break text-center p-0 no-gutters text-muted menu-dropdown-text">
|
||||||
|
{% trans 'Unit Conversions' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
@ -2,6 +2,7 @@ import json
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.contrib import auth
|
from django.contrib import auth
|
||||||
|
from django.core.cache import caches
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django_scopes import scope, scopes_disabled
|
from django_scopes import scope, scopes_disabled
|
||||||
from pytest_factoryboy import LazyFixture, register
|
from pytest_factoryboy import LazyFixture, register
|
||||||
@ -28,7 +29,6 @@ if (Food.node_order_by):
|
|||||||
else:
|
else:
|
||||||
node_location = 'last-child'
|
node_location = 'last-child'
|
||||||
|
|
||||||
|
|
||||||
register(FoodFactory, 'obj_1', space=LazyFixture('space_1'))
|
register(FoodFactory, 'obj_1', space=LazyFixture('space_1'))
|
||||||
register(FoodFactory, 'obj_2', space=LazyFixture('space_1'))
|
register(FoodFactory, 'obj_2', space=LazyFixture('space_1'))
|
||||||
register(FoodFactory, 'obj_3', space=LazyFixture('space_2'))
|
register(FoodFactory, 'obj_3', space=LazyFixture('space_2'))
|
||||||
@ -554,6 +554,7 @@ def test_inherit(request, obj_tree_1, field, inherit, new_val, u1_s1):
|
|||||||
assert (getattr(obj_tree_1, field) == new_val) == inherit
|
assert (getattr(obj_tree_1, field) == new_val) == inherit
|
||||||
assert (getattr(child, field) == new_val) == inherit
|
assert (getattr(child, field) == new_val) == inherit
|
||||||
|
|
||||||
|
|
||||||
# TODO add test_inherit with child_inherit
|
# TODO add test_inherit with child_inherit
|
||||||
|
|
||||||
|
|
||||||
@ -613,11 +614,9 @@ def test_reset_inherit_no_food_instances(obj_tree_1, space_1, field):
|
|||||||
parent.reset_inheritance(space=space_1)
|
parent.reset_inheritance(space=space_1)
|
||||||
|
|
||||||
|
|
||||||
def test_onhand(obj_1, u1_s1, u2_s1):
|
def test_onhand(obj_1, u1_s1, u2_s1, space_1):
|
||||||
assert json.loads(u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)[
|
assert json.loads(u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] is False
|
||||||
'food_onhand'] == False
|
assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] is False
|
||||||
assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)[
|
|
||||||
'food_onhand'] == False
|
|
||||||
|
|
||||||
u1_s1.patch(
|
u1_s1.patch(
|
||||||
reverse(
|
reverse(
|
||||||
@ -627,13 +626,12 @@ def test_onhand(obj_1, u1_s1, u2_s1):
|
|||||||
{'food_onhand': True},
|
{'food_onhand': True},
|
||||||
content_type='application/json'
|
content_type='application/json'
|
||||||
)
|
)
|
||||||
assert json.loads(u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)[
|
assert json.loads(u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] is True
|
||||||
'food_onhand'] == True
|
assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] is False
|
||||||
assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)[
|
|
||||||
'food_onhand'] == False
|
|
||||||
|
|
||||||
user1 = auth.get_user(u1_s1)
|
user1 = auth.get_user(u1_s1)
|
||||||
user2 = auth.get_user(u2_s1)
|
user2 = auth.get_user(u2_s1)
|
||||||
user1.userpreference.shopping_share.add(user2)
|
user1.userpreference.shopping_share.add(user2)
|
||||||
assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)[
|
caches['default'].set(f'shopping_shared_users_{space_1.id}_{user2.id}', None)
|
||||||
'food_onhand'] == True
|
|
||||||
|
assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] is True
|
||||||
|
116
cookbook/tests/api/test_api_property.py
Normal file
116
cookbook/tests/api/test_api_property.py
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.contrib import auth
|
||||||
|
from django.urls import reverse
|
||||||
|
from django_scopes import scopes_disabled
|
||||||
|
|
||||||
|
from cookbook.models import Food, MealType, PropertyType, Property
|
||||||
|
|
||||||
|
LIST_URL = 'api:property-list'
|
||||||
|
DETAIL_URL = 'api:property-detail'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def obj_1(space_1, u1_s1):
|
||||||
|
pt = PropertyType.objects.get_or_create(name='test_1', space=space_1)[0]
|
||||||
|
return Property.objects.get_or_create(property_amount=100, property_type=pt, space=space_1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def obj_2(space_1, u1_s1):
|
||||||
|
pt = PropertyType.objects.get_or_create(name='test_2', space=space_1)[0]
|
||||||
|
return Property.objects.get_or_create(property_amount=100, property_type=pt, space=space_1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("arg", [
|
||||||
|
['a_u', 403],
|
||||||
|
['g1_s1', 403],
|
||||||
|
['u1_s1', 200],
|
||||||
|
['a1_s1', 200],
|
||||||
|
])
|
||||||
|
def test_list_permission(arg, request):
|
||||||
|
c = request.getfixturevalue(arg[0])
|
||||||
|
assert c.get(reverse(LIST_URL)).status_code == arg[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_space(obj_1, obj_2, u1_s1, u1_s2, space_2):
|
||||||
|
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 2
|
||||||
|
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
|
||||||
|
|
||||||
|
obj_1.space = space_2
|
||||||
|
obj_1.save()
|
||||||
|
|
||||||
|
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 1
|
||||||
|
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("arg", [
|
||||||
|
['a_u', 403],
|
||||||
|
['g1_s1', 403],
|
||||||
|
['u1_s1', 200],
|
||||||
|
['a1_s1', 200],
|
||||||
|
['g1_s2', 403],
|
||||||
|
['u1_s2', 404],
|
||||||
|
['a1_s2', 404],
|
||||||
|
])
|
||||||
|
def test_update(arg, request, obj_1):
|
||||||
|
c = request.getfixturevalue(arg[0])
|
||||||
|
r = c.patch(
|
||||||
|
reverse(
|
||||||
|
DETAIL_URL,
|
||||||
|
args={obj_1.id}
|
||||||
|
),
|
||||||
|
{'property_amount': 200},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
response = json.loads(r.content)
|
||||||
|
assert r.status_code == arg[1]
|
||||||
|
if r.status_code == 200:
|
||||||
|
assert response['property_amount'] == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("arg", [
|
||||||
|
['a_u', 403],
|
||||||
|
['g1_s1', 403],
|
||||||
|
['u1_s1', 201],
|
||||||
|
['a1_s1', 201],
|
||||||
|
])
|
||||||
|
def test_add(arg, request, u1_s2, space_1):
|
||||||
|
with scopes_disabled():
|
||||||
|
pt = PropertyType.objects.get_or_create(name='test_1', space=space_1)[0]
|
||||||
|
c = request.getfixturevalue(arg[0])
|
||||||
|
r = c.post(
|
||||||
|
reverse(LIST_URL),
|
||||||
|
{'property_amount': 100, 'property_type': {'id': pt.id, 'name': pt.name}},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
response = json.loads(r.content)
|
||||||
|
assert r.status_code == arg[1]
|
||||||
|
if r.status_code == 201:
|
||||||
|
assert response['property_amount'] == 100
|
||||||
|
r = c.get(reverse(DETAIL_URL, args={response['id']}))
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = u1_s2.get(reverse(DETAIL_URL, args={response['id']}))
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete(u1_s1, u1_s2, obj_1):
|
||||||
|
r = u1_s2.delete(
|
||||||
|
reverse(
|
||||||
|
DETAIL_URL,
|
||||||
|
args={obj_1.id}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
r = u1_s1.delete(
|
||||||
|
reverse(
|
||||||
|
DETAIL_URL,
|
||||||
|
args={obj_1.id}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 204
|
||||||
|
with scopes_disabled():
|
||||||
|
assert MealType.objects.count() == 0
|
132
cookbook/tests/api/test_api_property_type.py
Normal file
132
cookbook/tests/api/test_api_property_type.py
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.contrib import auth
|
||||||
|
from django.urls import reverse
|
||||||
|
from django_scopes import scopes_disabled
|
||||||
|
|
||||||
|
from cookbook.models import Food, MealType, PropertyType
|
||||||
|
|
||||||
|
LIST_URL = 'api:propertytype-list'
|
||||||
|
DETAIL_URL = 'api:propertytype-detail'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def obj_1(space_1, u1_s1):
|
||||||
|
return PropertyType.objects.get_or_create(name='test_1', space=space_1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def obj_2(space_1, u1_s1):
|
||||||
|
return PropertyType.objects.get_or_create(name='test_2', space=space_1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("arg", [
|
||||||
|
['a_u', 403],
|
||||||
|
['g1_s1', 403],
|
||||||
|
['u1_s1', 200],
|
||||||
|
['a1_s1', 200],
|
||||||
|
])
|
||||||
|
def test_list_permission(arg, request):
|
||||||
|
c = request.getfixturevalue(arg[0])
|
||||||
|
assert c.get(reverse(LIST_URL)).status_code == arg[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_space(obj_1, obj_2, u1_s1, u1_s2, space_2):
|
||||||
|
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 2
|
||||||
|
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
|
||||||
|
|
||||||
|
obj_1.space = space_2
|
||||||
|
obj_1.save()
|
||||||
|
|
||||||
|
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 1
|
||||||
|
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("arg", [
|
||||||
|
['a_u', 403],
|
||||||
|
['g1_s1', 403],
|
||||||
|
['u1_s1', 200],
|
||||||
|
['a1_s1', 200],
|
||||||
|
['g1_s2', 403],
|
||||||
|
['u1_s2', 404],
|
||||||
|
['a1_s2', 404],
|
||||||
|
])
|
||||||
|
def test_update(arg, request, obj_1):
|
||||||
|
c = request.getfixturevalue(arg[0])
|
||||||
|
r = c.patch(
|
||||||
|
reverse(
|
||||||
|
DETAIL_URL,
|
||||||
|
args={obj_1.id}
|
||||||
|
),
|
||||||
|
{'name': 'new'},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
response = json.loads(r.content)
|
||||||
|
assert r.status_code == arg[1]
|
||||||
|
if r.status_code == 200:
|
||||||
|
assert response['name'] == 'new'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("arg", [
|
||||||
|
['a_u', 403],
|
||||||
|
['g1_s1', 403],
|
||||||
|
['u1_s1', 201],
|
||||||
|
['a1_s1', 201],
|
||||||
|
])
|
||||||
|
def test_add(arg, request, u1_s2):
|
||||||
|
c = request.getfixturevalue(arg[0])
|
||||||
|
r = c.post(
|
||||||
|
reverse(LIST_URL),
|
||||||
|
{'name': 'test'},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
response = json.loads(r.content)
|
||||||
|
assert r.status_code == arg[1]
|
||||||
|
if r.status_code == 201:
|
||||||
|
assert response['name'] == 'test'
|
||||||
|
r = c.get(reverse(DETAIL_URL, args={response['id']}))
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = u1_s2.get(reverse(DETAIL_URL, args={response['id']}))
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_duplicate(u1_s1, u1_s2, obj_1):
|
||||||
|
r = u1_s1.post(
|
||||||
|
reverse(LIST_URL),
|
||||||
|
{'name': obj_1.name},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
response = json.loads(r.content)
|
||||||
|
assert r.status_code == 201
|
||||||
|
assert response['id'] == obj_1.id
|
||||||
|
|
||||||
|
r = u1_s2.post(
|
||||||
|
reverse(LIST_URL),
|
||||||
|
{'name': obj_1.name},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
response = json.loads(r.content)
|
||||||
|
assert r.status_code == 201
|
||||||
|
assert response['id'] != obj_1.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete(u1_s1, u1_s2, obj_1):
|
||||||
|
r = u1_s2.delete(
|
||||||
|
reverse(
|
||||||
|
DETAIL_URL,
|
||||||
|
args={obj_1.id}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
r = u1_s1.delete(
|
||||||
|
reverse(
|
||||||
|
DETAIL_URL,
|
||||||
|
args={obj_1.id}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 204
|
||||||
|
with scopes_disabled():
|
||||||
|
assert MealType.objects.count() == 0
|
163
cookbook/tests/api/test_api_unit_conversion.py
Normal file
163
cookbook/tests/api/test_api_unit_conversion.py
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.contrib import auth
|
||||||
|
from django.urls import reverse
|
||||||
|
from django_scopes import scopes_disabled
|
||||||
|
|
||||||
|
from cookbook.models import Food, MealType, UnitConversion
|
||||||
|
from cookbook.tests.conftest import get_random_food, get_random_unit
|
||||||
|
|
||||||
|
LIST_URL = 'api:unitconversion-list'
|
||||||
|
DETAIL_URL = 'api:unitconversion-detail'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def obj_1(space_1, u1_s1):
|
||||||
|
return UnitConversion.objects.get_or_create(
|
||||||
|
food=get_random_food(space_1, u1_s1),
|
||||||
|
base_amount=100,
|
||||||
|
base_unit=get_random_unit(space_1, u1_s1),
|
||||||
|
converted_amount=100,
|
||||||
|
converted_unit=get_random_unit(space_1, u1_s1),
|
||||||
|
created_by=auth.get_user(u1_s1),
|
||||||
|
space=space_1
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def obj_2(space_1, u1_s1):
|
||||||
|
return UnitConversion.objects.get_or_create(
|
||||||
|
food=get_random_food(space_1, u1_s1),
|
||||||
|
base_amount=100,
|
||||||
|
base_unit=get_random_unit(space_1, u1_s1),
|
||||||
|
converted_amount=100,
|
||||||
|
converted_unit=get_random_unit(space_1, u1_s1),
|
||||||
|
created_by=auth.get_user(u1_s1),
|
||||||
|
space=space_1
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("arg", [
|
||||||
|
['a_u', 403],
|
||||||
|
['g1_s1', 403],
|
||||||
|
['u1_s1', 200],
|
||||||
|
['a1_s1', 200],
|
||||||
|
])
|
||||||
|
def test_list_permission(arg, request):
|
||||||
|
c = request.getfixturevalue(arg[0])
|
||||||
|
assert c.get(reverse(LIST_URL)).status_code == arg[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_space(obj_1, obj_2, u1_s1, u1_s2, space_2):
|
||||||
|
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 2
|
||||||
|
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
|
||||||
|
|
||||||
|
obj_1.space = space_2
|
||||||
|
obj_1.save()
|
||||||
|
|
||||||
|
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 1
|
||||||
|
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("arg", [
|
||||||
|
['a_u', 403],
|
||||||
|
['g1_s1', 403],
|
||||||
|
['u1_s1', 200],
|
||||||
|
['a1_s1', 200],
|
||||||
|
['g1_s2', 403],
|
||||||
|
['u1_s2', 404],
|
||||||
|
['a1_s2', 404],
|
||||||
|
])
|
||||||
|
def test_update(arg, request, obj_1):
|
||||||
|
c = request.getfixturevalue(arg[0])
|
||||||
|
r = c.patch(
|
||||||
|
reverse(
|
||||||
|
DETAIL_URL,
|
||||||
|
args={obj_1.id}
|
||||||
|
),
|
||||||
|
{'base_amount': 1000},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
response = json.loads(r.content)
|
||||||
|
assert r.status_code == arg[1]
|
||||||
|
if r.status_code == 200:
|
||||||
|
assert response['base_amount'] == 1000
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("arg", [
|
||||||
|
['a_u', 403],
|
||||||
|
['g1_s1', 403],
|
||||||
|
['u1_s1', 201],
|
||||||
|
['a1_s1', 201],
|
||||||
|
])
|
||||||
|
def test_add(arg, request, u1_s2, space_1, u1_s1):
|
||||||
|
with scopes_disabled():
|
||||||
|
c = request.getfixturevalue(arg[0])
|
||||||
|
random_unit_1 = get_random_unit(space_1, u1_s1)
|
||||||
|
random_unit_2 = get_random_unit(space_1, u1_s1)
|
||||||
|
random_food_1 = get_random_unit(space_1, u1_s1)
|
||||||
|
r = c.post(
|
||||||
|
reverse(LIST_URL),
|
||||||
|
{
|
||||||
|
'food': {'id': random_food_1.id, 'name': random_food_1.name},
|
||||||
|
'base_amount': 100,
|
||||||
|
'base_unit': {'id': random_unit_1.id, 'name': random_unit_1.name},
|
||||||
|
'converted_amount': 100,
|
||||||
|
'converted_unit': {'id': random_unit_2.id, 'name': random_unit_2.name}
|
||||||
|
|
||||||
|
},
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
response = json.loads(r.content)
|
||||||
|
print(response)
|
||||||
|
assert r.status_code == arg[1]
|
||||||
|
if r.status_code == 201:
|
||||||
|
assert response['base_amount'] == 100
|
||||||
|
r = c.get(reverse(DETAIL_URL, args={response['id']}))
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = u1_s2.get(reverse(DETAIL_URL, args={response['id']}))
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# TODO make name in space unique
|
||||||
|
# def test_add_duplicate(u1_s1, u1_s2, obj_1):
|
||||||
|
# r = u1_s1.post(
|
||||||
|
# reverse(LIST_URL),
|
||||||
|
# {'name': obj_1.name},
|
||||||
|
# content_type='application/json'
|
||||||
|
# )
|
||||||
|
# response = json.loads(r.content)
|
||||||
|
# assert r.status_code == 201
|
||||||
|
# assert response['id'] == obj_1.id
|
||||||
|
#
|
||||||
|
# r = u1_s2.post(
|
||||||
|
# reverse(LIST_URL),
|
||||||
|
# {'name': obj_1.name},
|
||||||
|
# content_type='application/json'
|
||||||
|
# )
|
||||||
|
# response = json.loads(r.content)
|
||||||
|
# assert r.status_code == 201
|
||||||
|
# assert response['id'] != obj_1.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete(u1_s1, u1_s2, obj_1):
|
||||||
|
r = u1_s2.delete(
|
||||||
|
reverse(
|
||||||
|
DETAIL_URL,
|
||||||
|
args={obj_1.id}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
r = u1_s1.delete(
|
||||||
|
reverse(
|
||||||
|
DETAIL_URL,
|
||||||
|
args={obj_1.id}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 204
|
||||||
|
with scopes_disabled():
|
||||||
|
assert MealType.objects.count() == 0
|
@ -13,6 +13,8 @@ from cookbook.tests.factories import SpaceFactory, UserFactory
|
|||||||
|
|
||||||
register(SpaceFactory, 'space_1')
|
register(SpaceFactory, 'space_1')
|
||||||
register(SpaceFactory, 'space_2')
|
register(SpaceFactory, 'space_2')
|
||||||
|
|
||||||
|
|
||||||
# register(FoodFactory, space=LazyFixture('space_2'))
|
# register(FoodFactory, space=LazyFixture('space_2'))
|
||||||
# TODO refactor clients to be factories
|
# TODO refactor clients to be factories
|
||||||
|
|
||||||
@ -169,7 +171,6 @@ def dict_compare(d1, d2, details=False):
|
|||||||
|
|
||||||
|
|
||||||
def transpose(text, number=2):
|
def transpose(text, number=2):
|
||||||
|
|
||||||
# select random token
|
# select random token
|
||||||
tokens = text.split()
|
tokens = text.split()
|
||||||
positions = list(i for i, e in enumerate(tokens) if len(e) > 1)
|
positions = list(i for i, e in enumerate(tokens) if len(e) > 1)
|
||||||
@ -212,6 +213,14 @@ def ext_recipe_1_s1(space_1, u1_s1):
|
|||||||
return r
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def get_random_food(space_1, u1_s1):
|
||||||
|
return Food.objects.get_or_create(name=str(uuid.uuid4()), space=space_1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def get_random_unit(space_1, u1_s1):
|
||||||
|
return Unit.objects.get_or_create(name=str(uuid.uuid4()), space=space_1)[0]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------- USER FIXTURES -----------------------
|
# ---------------------- USER FIXTURES -----------------------
|
||||||
# maybe better with factories but this is very explict so ...
|
# maybe better with factories but this is very explict so ...
|
||||||
|
|
||||||
|
129
cookbook/tests/other/test_food_property.py
Normal file
129
cookbook/tests/other/test_food_property.py
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
from django.contrib import auth
|
||||||
|
from django.core.cache import caches
|
||||||
|
from django_scopes import scopes_disabled
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from cookbook.helper.cache_helper import CacheHelper
|
||||||
|
from cookbook.helper.property_helper import FoodPropertyHelper
|
||||||
|
from cookbook.models import Unit, Food, PropertyType, Property, Recipe, Step, UnitConversion, Property
|
||||||
|
|
||||||
|
|
||||||
|
def test_food_property(space_1, space_2, u1_s1):
|
||||||
|
with scopes_disabled():
|
||||||
|
unit_gram = Unit.objects.create(name='gram', base_unit='g', space=space_1)
|
||||||
|
unit_kg = Unit.objects.create(name='kg', base_unit='kg', space=space_1)
|
||||||
|
unit_pcs = Unit.objects.create(name='pcs', base_unit='', space=space_1)
|
||||||
|
unit_floz1 = Unit.objects.create(name='fl. oz 1', base_unit='imperial_fluid_ounce', space=space_1) # US and UK use different volume systems (US vs imperial)
|
||||||
|
unit_floz2 = Unit.objects.create(name='fl. oz 2', base_unit='fluid_ounce', space=space_1)
|
||||||
|
unit_fantasy = Unit.objects.create(name='Fantasy Unit', base_unit='', space=space_1)
|
||||||
|
|
||||||
|
food_1 = Food.objects.create(name='food_1', space=space_1, properties_food_unit=unit_gram, properties_food_amount=100)
|
||||||
|
food_2 = Food.objects.create(name='food_2', space=space_1, properties_food_unit=unit_gram, properties_food_amount=100)
|
||||||
|
|
||||||
|
property_fat = PropertyType.objects.create(name='property_fat', space=space_1)
|
||||||
|
property_calories = PropertyType.objects.create(name='property_calories', space=space_1)
|
||||||
|
property_nuts = PropertyType.objects.create(name='property_nuts', space=space_1)
|
||||||
|
property_price = PropertyType.objects.create(name='property_price', space=space_1)
|
||||||
|
|
||||||
|
food_1_property_fat = Property.objects.create(property_amount=50, property_type=property_fat, space=space_1)
|
||||||
|
food_1_property_nuts = Property.objects.create(property_amount=1, property_type=property_nuts, space=space_1)
|
||||||
|
food_1_property_price = Property.objects.create(property_amount=7.50, property_type=property_price, space=space_1)
|
||||||
|
food_1.properties.add(food_1_property_fat, food_1_property_nuts, food_1_property_price)
|
||||||
|
|
||||||
|
food_2_property_fat = Property.objects.create(property_amount=25, property_type=property_fat, space=space_1)
|
||||||
|
food_2_property_nuts = Property.objects.create(property_amount=0, property_type=property_nuts, space=space_1)
|
||||||
|
food_2_property_price = Property.objects.create(property_amount=2.50, property_type=property_price, space=space_1)
|
||||||
|
food_2.properties.add(food_2_property_fat, food_2_property_nuts, food_2_property_price)
|
||||||
|
|
||||||
|
print('\n----------- TEST PROPERTY - PROPERTY CALCULATION MULTI STEP IDENTICAL UNIT ---------------')
|
||||||
|
recipe_1 = Recipe.objects.create(name='recipe_1', waiting_time=0, working_time=0, space=space_1, created_by=auth.get_user(u1_s1))
|
||||||
|
|
||||||
|
step_1 = Step.objects.create(instruction='instruction_step_1', space=space_1)
|
||||||
|
step_1.ingredients.create(amount=500, unit=unit_gram, food=food_1, space=space_1)
|
||||||
|
step_1.ingredients.create(amount=1000, unit=unit_gram, food=food_2, space=space_1)
|
||||||
|
recipe_1.steps.add(step_1)
|
||||||
|
|
||||||
|
step_2 = Step.objects.create(instruction='instruction_step_1', space=space_1)
|
||||||
|
step_2.ingredients.create(amount=50, unit=unit_gram, food=food_1, space=space_1)
|
||||||
|
recipe_1.steps.add(step_2)
|
||||||
|
|
||||||
|
property_values = FoodPropertyHelper(space_1).calculate_recipe_properties(recipe_1)
|
||||||
|
|
||||||
|
assert property_values[property_fat.id]['name'] == property_fat.name
|
||||||
|
assert abs(property_values[property_fat.id]['total_value'] - Decimal(525)) < 0.0001
|
||||||
|
assert abs(property_values[property_fat.id]['food_values'][food_1.id]['value'] - Decimal(275)) < 0.0001
|
||||||
|
assert abs(property_values[property_fat.id]['food_values'][food_2.id]['value'] - Decimal(250)) < 0.0001
|
||||||
|
|
||||||
|
print('\n----------- TEST PROPERTY - PROPERTY CALCULATION NO POSSIBLE CONVERSION ---------------')
|
||||||
|
recipe_2 = Recipe.objects.create(name='recipe_2', waiting_time=0, working_time=0, space=space_1, created_by=auth.get_user(u1_s1))
|
||||||
|
|
||||||
|
step_1 = Step.objects.create(instruction='instruction_step_1', space=space_1)
|
||||||
|
step_1.ingredients.create(amount=5, unit=unit_pcs, food=food_1, space=space_1)
|
||||||
|
step_1.ingredients.create(amount=10, unit=unit_pcs, food=food_2, space=space_1)
|
||||||
|
recipe_2.steps.add(step_1)
|
||||||
|
|
||||||
|
property_values = FoodPropertyHelper(space_1).calculate_recipe_properties(recipe_2)
|
||||||
|
|
||||||
|
assert property_values[property_fat.id]['name'] == property_fat.name
|
||||||
|
assert abs(property_values[property_fat.id]['total_value']) < 0.0001
|
||||||
|
assert abs(property_values[property_fat.id]['food_values'][food_1.id]['value']) < 0.0001
|
||||||
|
|
||||||
|
print('\n----------- TEST PROPERTY - PROPERTY CALCULATION UNIT CONVERSION ---------------')
|
||||||
|
uc1 = UnitConversion.objects.create(
|
||||||
|
base_amount=100,
|
||||||
|
base_unit=unit_gram,
|
||||||
|
converted_amount=1,
|
||||||
|
converted_unit=unit_pcs,
|
||||||
|
space=space_1,
|
||||||
|
created_by=auth.get_user(u1_s1),
|
||||||
|
)
|
||||||
|
|
||||||
|
property_values = FoodPropertyHelper(space_1).calculate_recipe_properties(recipe_2)
|
||||||
|
|
||||||
|
assert property_values[property_fat.id]['name'] == property_fat.name
|
||||||
|
assert abs(property_values[property_fat.id]['total_value'] - Decimal(500)) < 0.0001
|
||||||
|
assert abs(property_values[property_fat.id]['food_values'][food_1.id]['value'] - Decimal(250)) < 0.0001
|
||||||
|
assert abs(property_values[property_fat.id]['food_values'][food_2.id]['value'] - Decimal(250)) < 0.0001
|
||||||
|
|
||||||
|
print('\n----------- TEST PROPERTY - PROPERTY CALCULATION UNIT CONVERSION MULTIPLE ---------------')
|
||||||
|
|
||||||
|
uc1.delete()
|
||||||
|
uc1 = UnitConversion.objects.create(
|
||||||
|
base_amount=0.1,
|
||||||
|
base_unit=unit_kg,
|
||||||
|
converted_amount=1,
|
||||||
|
converted_unit=unit_pcs,
|
||||||
|
space=space_1,
|
||||||
|
created_by=auth.get_user(u1_s1),
|
||||||
|
)
|
||||||
|
|
||||||
|
property_values = FoodPropertyHelper(space_1).calculate_recipe_properties(recipe_2)
|
||||||
|
|
||||||
|
assert property_values[property_fat.id]['name'] == property_fat.name
|
||||||
|
assert abs(property_values[property_fat.id]['total_value'] - Decimal(500)) < 0.0001
|
||||||
|
assert abs(property_values[property_fat.id]['food_values'][food_1.id]['value'] - Decimal(250)) < 0.0001
|
||||||
|
assert abs(property_values[property_fat.id]['food_values'][food_2.id]['value'] - Decimal(250)) < 0.0001
|
||||||
|
|
||||||
|
print('\n----------- TEST PROPERTY - MISSING FOOD REFERENCE AMOUNT ---------------')
|
||||||
|
food_1.properties_food_unit = None
|
||||||
|
food_1.save()
|
||||||
|
food_2.properties_food_amount = 0
|
||||||
|
food_2.save()
|
||||||
|
|
||||||
|
property_values = FoodPropertyHelper(space_1).calculate_recipe_properties(recipe_1)
|
||||||
|
|
||||||
|
assert property_values[property_fat.id]['name'] == property_fat.name
|
||||||
|
assert property_values[property_fat.id]['total_value'] == 0
|
||||||
|
|
||||||
|
print('\n----------- TEST PROPERTY - SPACE SEPARATION ---------------')
|
||||||
|
|
||||||
|
property_fat.space = space_2
|
||||||
|
property_fat.save()
|
||||||
|
|
||||||
|
caches['default'].delete(CacheHelper(space_1).PROPERTY_TYPE_CACHE_KEY) # clear cache as objects won't change space in reality
|
||||||
|
|
||||||
|
property_values = FoodPropertyHelper(space_1).calculate_recipe_properties(recipe_2)
|
||||||
|
|
||||||
|
assert property_fat.id not in property_values
|
||||||
|
|
||||||
|
|
187
cookbook/tests/other/test_unit_conversion.py
Normal file
187
cookbook/tests/other/test_unit_conversion.py
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
from _decimal import Decimal
|
||||||
|
|
||||||
|
from django.contrib import auth
|
||||||
|
from django_scopes import scopes_disabled
|
||||||
|
|
||||||
|
from cookbook.helper.unit_conversion_helper import UnitConversionHelper, ConversionException
|
||||||
|
from cookbook.models import Unit, Food, Ingredient, UnitConversion
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_converter(space_1):
|
||||||
|
uch = UnitConversionHelper(space_1)
|
||||||
|
assert abs(uch.convert_from_to('g', 'kg', 1234) - Decimal(1.234)) < 0.0001
|
||||||
|
assert abs(uch.convert_from_to('kg', 'pound', 2) - Decimal(4.40924)) < 0.00001
|
||||||
|
assert abs(uch.convert_from_to('kg', 'g', 1) - Decimal(1000)) < 0.00001
|
||||||
|
assert abs(uch.convert_from_to('imperial_gallon', 'gallon', 1000) - Decimal(1200.95104)) < 0.00001
|
||||||
|
assert abs(uch.convert_from_to('tbsp', 'ml', 20) - Decimal(295.73549)) < 0.00001
|
||||||
|
|
||||||
|
try:
|
||||||
|
assert uch.convert_from_to('kg', 'tbsp', 2) == 1234
|
||||||
|
assert False
|
||||||
|
except ConversionException:
|
||||||
|
assert True
|
||||||
|
|
||||||
|
try:
|
||||||
|
assert uch.convert_from_to('kg', 'g2', 2) == 1234
|
||||||
|
assert False
|
||||||
|
except ConversionException:
|
||||||
|
assert True
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_conversions(space_1, space_2, u1_s1):
|
||||||
|
with scopes_disabled():
|
||||||
|
uch = UnitConversionHelper(space_1)
|
||||||
|
uch_space_2 = UnitConversionHelper(space_2)
|
||||||
|
|
||||||
|
unit_gram = Unit.objects.create(name='gram', base_unit='g', space=space_1)
|
||||||
|
unit_kg = Unit.objects.create(name='kg', base_unit='kg', space=space_1)
|
||||||
|
unit_pcs = Unit.objects.create(name='pcs', base_unit='', space=space_1)
|
||||||
|
unit_floz1 = Unit.objects.create(name='fl. oz 1', base_unit='imperial_fluid_ounce', space=space_1) # US and UK use different volume systems (US vs imperial)
|
||||||
|
unit_floz2 = Unit.objects.create(name='fl. oz 2', base_unit='fluid_ounce', space=space_1)
|
||||||
|
unit_fantasy = Unit.objects.create(name='Fantasy Unit', base_unit='', space=space_1)
|
||||||
|
|
||||||
|
food_1 = Food.objects.create(name='Test Food 1', space=space_1)
|
||||||
|
food_2 = Food.objects.create(name='Test Food 2', space=space_1)
|
||||||
|
|
||||||
|
print('\n----------- TEST BASE CONVERSIONS - GRAM ---------------')
|
||||||
|
ingredient_food_1_gram = Ingredient.objects.create(
|
||||||
|
food=food_1,
|
||||||
|
unit=unit_gram,
|
||||||
|
amount=100,
|
||||||
|
space=space_1,
|
||||||
|
)
|
||||||
|
|
||||||
|
conversions = uch.get_conversions(ingredient_food_1_gram)
|
||||||
|
print(conversions)
|
||||||
|
assert len(conversions) == 2
|
||||||
|
assert next(x for x in conversions if x.unit == unit_kg) is not None
|
||||||
|
assert abs(next(x for x in conversions if x.unit == unit_kg).amount - Decimal(0.1)) < 0.0001
|
||||||
|
|
||||||
|
print('\n----------- TEST BASE CONVERSIONS - VOLUMES ---------------')
|
||||||
|
|
||||||
|
ingredient_food_1_floz1 = Ingredient.objects.create(
|
||||||
|
food=food_1,
|
||||||
|
unit=unit_floz1,
|
||||||
|
amount=100,
|
||||||
|
space=space_1,
|
||||||
|
)
|
||||||
|
|
||||||
|
conversions = uch.get_conversions(ingredient_food_1_floz1)
|
||||||
|
assert len(conversions) == 2
|
||||||
|
assert next(x for x in conversions if x.unit == unit_floz2) is not None
|
||||||
|
assert abs(next(x for x in conversions if x.unit == unit_floz2).amount - Decimal(96.07599404038842)) < 0.001 # TODO validate value
|
||||||
|
|
||||||
|
print(conversions)
|
||||||
|
|
||||||
|
unit_pint = Unit.objects.create(name='pint', base_unit='pint', space=space_1)
|
||||||
|
conversions = uch.get_conversions(ingredient_food_1_floz1)
|
||||||
|
assert len(conversions) == 3
|
||||||
|
assert next(x for x in conversions if x.unit == unit_pint) is not None
|
||||||
|
assert abs(next(x for x in conversions if x.unit == unit_pint).amount - Decimal(6.004749627524276)) < 0.001 # TODO validate value
|
||||||
|
|
||||||
|
print(conversions)
|
||||||
|
|
||||||
|
print('\n----------- TEST BASE CUSTOM CONVERSION - TO CUSTOM CONVERSION ---------------')
|
||||||
|
UnitConversion.objects.create(
|
||||||
|
base_amount=1000,
|
||||||
|
base_unit=unit_gram,
|
||||||
|
converted_amount=1337,
|
||||||
|
converted_unit=unit_fantasy,
|
||||||
|
space=space_1,
|
||||||
|
created_by=auth.get_user(u1_s1),
|
||||||
|
)
|
||||||
|
conversions = uch.get_conversions(ingredient_food_1_gram)
|
||||||
|
|
||||||
|
assert len(conversions) == 3
|
||||||
|
assert next(x for x in conversions if x.unit == unit_fantasy) is not None
|
||||||
|
assert abs(next(x for x in conversions if x.unit == unit_fantasy).amount - Decimal('133.700')) < 0.001 # TODO validate value
|
||||||
|
|
||||||
|
print(conversions)
|
||||||
|
|
||||||
|
print('\n----------- TEST CUSTOM CONVERSION - NO PCS ---------------')
|
||||||
|
ingredient_food_1_pcs = Ingredient.objects.create(
|
||||||
|
food=food_1,
|
||||||
|
unit=unit_pcs,
|
||||||
|
amount=5,
|
||||||
|
space=space_1,
|
||||||
|
)
|
||||||
|
|
||||||
|
ingredient_food_2_pcs = Ingredient.objects.create(
|
||||||
|
food=food_2,
|
||||||
|
unit=unit_pcs,
|
||||||
|
amount=5,
|
||||||
|
space=space_1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(uch.get_conversions(ingredient_food_1_pcs)) == 1
|
||||||
|
assert len(uch.get_conversions(ingredient_food_2_pcs)) == 1
|
||||||
|
print(uch.get_conversions(ingredient_food_1_pcs))
|
||||||
|
print(uch.get_conversions(ingredient_food_2_pcs))
|
||||||
|
|
||||||
|
print('\n----------- TEST CUSTOM CONVERSION - PCS TO MULTIPLE BASE ---------------')
|
||||||
|
uc1 = UnitConversion.objects.create(
|
||||||
|
base_amount=1,
|
||||||
|
base_unit=unit_pcs,
|
||||||
|
converted_amount=200,
|
||||||
|
converted_unit=unit_gram,
|
||||||
|
food=food_1,
|
||||||
|
space=space_1,
|
||||||
|
created_by=auth.get_user(u1_s1),
|
||||||
|
)
|
||||||
|
|
||||||
|
conversions = uch.get_conversions(ingredient_food_1_pcs)
|
||||||
|
assert len(conversions) == 3
|
||||||
|
assert abs(next(x for x in conversions if x.unit == unit_gram).amount - Decimal(1000)) < 0.0001
|
||||||
|
assert abs(next(x for x in conversions if x.unit == unit_kg).amount - Decimal(1)) < 0.0001
|
||||||
|
print(conversions)
|
||||||
|
|
||||||
|
assert len(uch.get_conversions(ingredient_food_2_pcs)) == 1
|
||||||
|
print(uch.get_conversions(ingredient_food_2_pcs))
|
||||||
|
|
||||||
|
print('\n----------- TEST CUSTOM CONVERSION - CONVERT MULTI STEP ---------------')
|
||||||
|
|
||||||
|
# TODO add test for multi step conversion ... do I even do or want to support this ?
|
||||||
|
|
||||||
|
print('\n----------- TEST CUSTOM CONVERSION - REVERSE CONVERSION ---------------')
|
||||||
|
uc2 = UnitConversion.objects.create(
|
||||||
|
base_amount=200,
|
||||||
|
base_unit=unit_gram,
|
||||||
|
converted_amount=1,
|
||||||
|
converted_unit=unit_pcs,
|
||||||
|
food=food_2,
|
||||||
|
space=space_1,
|
||||||
|
created_by=auth.get_user(u1_s1),
|
||||||
|
)
|
||||||
|
|
||||||
|
conversions = uch.get_conversions(ingredient_food_1_pcs)
|
||||||
|
assert len(conversions) == 3
|
||||||
|
assert abs(next(x for x in conversions if x.unit == unit_gram).amount - Decimal(1000)) < 0.0001
|
||||||
|
assert abs(next(x for x in conversions if x.unit == unit_kg).amount - Decimal(1)) < 0.0001
|
||||||
|
print(conversions)
|
||||||
|
|
||||||
|
conversions = uch.get_conversions(ingredient_food_2_pcs)
|
||||||
|
assert len(conversions) == 3
|
||||||
|
assert abs(next(x for x in conversions if x.unit == unit_gram).amount - Decimal(1000)) < 0.0001
|
||||||
|
assert abs(next(x for x in conversions if x.unit == unit_kg).amount - Decimal(1)) < 0.0001
|
||||||
|
print(conversions)
|
||||||
|
|
||||||
|
print('\n----------- TEST SPACE SEPARATION ---------------')
|
||||||
|
uc2.space = space_2
|
||||||
|
uc2.save()
|
||||||
|
|
||||||
|
conversions = uch.get_conversions(ingredient_food_2_pcs)
|
||||||
|
assert len(conversions) == 1
|
||||||
|
print(conversions)
|
||||||
|
|
||||||
|
conversions = uch_space_2.get_conversions(ingredient_food_1_gram)
|
||||||
|
assert len(conversions) == 1
|
||||||
|
assert not any(x for x in conversions if x.unit == unit_kg)
|
||||||
|
print(conversions)
|
||||||
|
|
||||||
|
unit_kg_space_2 = Unit.objects.create(name='kg', base_unit='kg', space=space_2)
|
||||||
|
conversions = uch_space_2.get_conversions(ingredient_food_1_gram)
|
||||||
|
assert len(conversions) == 2
|
||||||
|
assert not any(x for x in conversions if x.unit == unit_kg)
|
||||||
|
assert next(x for x in conversions if x.unit == unit_kg_space_2) is not None
|
||||||
|
assert abs(next(x for x in conversions if x.unit == unit_kg_space_2).amount - Decimal(0.1)) < 0.0001
|
||||||
|
print(conversions)
|
@ -12,9 +12,9 @@ from recipes.version import VERSION_NUMBER
|
|||||||
from .models import (Automation, Comment, CustomFilter, Food, InviteLink, Keyword, MealPlan, Recipe,
|
from .models import (Automation, Comment, CustomFilter, Food, InviteLink, Keyword, MealPlan, Recipe,
|
||||||
RecipeBook, RecipeBookEntry, RecipeImport, ShoppingList, Step, Storage,
|
RecipeBook, RecipeBookEntry, RecipeImport, ShoppingList, Step, Storage,
|
||||||
Supermarket, SupermarketCategory, Sync, SyncLog, Unit, UserFile,
|
Supermarket, SupermarketCategory, Sync, SyncLog, Unit, UserFile,
|
||||||
get_model_name, UserSpace, Space)
|
get_model_name, UserSpace, Space, PropertyType, UnitConversion)
|
||||||
from .views import api, data, delete, edit, import_export, lists, new, telegram, views
|
from .views import api, data, delete, edit, import_export, lists, new, telegram, views
|
||||||
from .views.api import CustomAuthToken
|
from .views.api import CustomAuthToken, ImportOpenData
|
||||||
|
|
||||||
# extend DRF default router class to allow including additional routers
|
# extend DRF default router class to allow including additional routers
|
||||||
class DefaultRouter(routers.DefaultRouter):
|
class DefaultRouter(routers.DefaultRouter):
|
||||||
@ -40,6 +40,9 @@ router.register(r'meal-type', api.MealTypeViewSet)
|
|||||||
router.register(r'recipe', api.RecipeViewSet)
|
router.register(r'recipe', api.RecipeViewSet)
|
||||||
router.register(r'recipe-book', api.RecipeBookViewSet)
|
router.register(r'recipe-book', api.RecipeBookViewSet)
|
||||||
router.register(r'recipe-book-entry', api.RecipeBookEntryViewSet)
|
router.register(r'recipe-book-entry', api.RecipeBookEntryViewSet)
|
||||||
|
router.register(r'unit-conversion', api.UnitConversionViewSet)
|
||||||
|
router.register(r'food-property-type', api.PropertyTypeViewSet)
|
||||||
|
router.register(r'food-property', api.PropertyViewSet)
|
||||||
router.register(r'shopping-list', api.ShoppingListViewSet)
|
router.register(r'shopping-list', api.ShoppingListViewSet)
|
||||||
router.register(r'shopping-list-entry', api.ShoppingListEntryViewSet)
|
router.register(r'shopping-list-entry', api.ShoppingListEntryViewSet)
|
||||||
router.register(r'shopping-list-recipe', api.ShoppingListRecipeViewSet)
|
router.register(r'shopping-list-recipe', api.ShoppingListRecipeViewSet)
|
||||||
@ -151,6 +154,7 @@ urlpatterns = [
|
|||||||
path('api/', include((router.urls, 'api'))),
|
path('api/', include((router.urls, 'api'))),
|
||||||
path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
|
path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
|
||||||
path('api-token-auth/', CustomAuthToken.as_view()),
|
path('api-token-auth/', CustomAuthToken.as_view()),
|
||||||
|
path('api-import-open-data/', ImportOpenData.as_view(), name='api_import_open_data'),
|
||||||
|
|
||||||
path('offline/', views.offline, name='view_offline'),
|
path('offline/', views.offline, name='view_offline'),
|
||||||
|
|
||||||
@ -201,7 +205,7 @@ for m in generic_models:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
vue_models = [Food, Keyword, Unit, Supermarket, SupermarketCategory, Automation, UserFile, Step, CustomFilter]
|
vue_models = [Food, Keyword, Unit, Supermarket, SupermarketCategory, Automation, UserFile, Step, CustomFilter, UnitConversion, PropertyType]
|
||||||
for m in vue_models:
|
for m in vue_models:
|
||||||
py_name = get_model_name(m)
|
py_name = get_model_name(m)
|
||||||
url_name = py_name.replace('_', '-')
|
url_name = py_name.replace('_', '-')
|
||||||
|
@ -19,6 +19,7 @@ from annoying.functions import get_object_or_None
|
|||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.models import Group, User
|
from django.contrib.auth.models import Group, User
|
||||||
from django.contrib.postgres.search import TrigramSimilarity
|
from django.contrib.postgres.search import TrigramSimilarity
|
||||||
|
from django.core.cache import caches
|
||||||
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, Count, Exists, OuterRef, ProtectedError, Q, Subquery, Value, When, Avg, Max
|
from django.db.models import Case, Count, Exists, OuterRef, ProtectedError, Q, Subquery, Value, When, Avg, Max
|
||||||
@ -44,6 +45,7 @@ from rest_framework.parsers import MultiPartParser
|
|||||||
from rest_framework.renderers import JSONRenderer, TemplateHTMLRenderer
|
from rest_framework.renderers import JSONRenderer, TemplateHTMLRenderer
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.throttling import AnonRateThrottle
|
from rest_framework.throttling import AnonRateThrottle
|
||||||
|
from rest_framework.views import APIView
|
||||||
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
|
||||||
|
|
||||||
@ -52,10 +54,13 @@ from cookbook.helper import recipe_url_import as helper
|
|||||||
from cookbook.helper.HelperFunctions import str2bool
|
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.open_data_importer import OpenDataImporter
|
||||||
from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsOwner,
|
from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsOwner,
|
||||||
CustomIsOwnerReadOnly, CustomIsShared,
|
CustomIsOwnerReadOnly, CustomIsShared,
|
||||||
CustomIsSpaceOwner, CustomIsUser, group_required,
|
CustomIsSpaceOwner, CustomIsUser, group_required,
|
||||||
is_space_owner, switch_user_active_space, above_space_limit, CustomRecipePermission, CustomUserPermission, CustomTokenHasReadWriteScope, CustomTokenHasScope, has_group_permission)
|
is_space_owner, switch_user_active_space, above_space_limit,
|
||||||
|
CustomRecipePermission, CustomUserPermission,
|
||||||
|
CustomTokenHasReadWriteScope, CustomTokenHasScope, has_group_permission)
|
||||||
from cookbook.helper.recipe_search import RecipeFacet, RecipeSearch
|
from cookbook.helper.recipe_search import RecipeFacet, RecipeSearch
|
||||||
from cookbook.helper.recipe_url_import import get_from_youtube_scraper, get_images_from_soup, clean_dict
|
from cookbook.helper.recipe_url_import import get_from_youtube_scraper, get_images_from_soup, clean_dict
|
||||||
from cookbook.helper.scrapers.scrapers import text_scraper
|
from cookbook.helper.scrapers.scrapers import text_scraper
|
||||||
@ -65,7 +70,7 @@ from cookbook.models import (Automation, BookmarkletImport, CookLog, CustomFilte
|
|||||||
MealType, Recipe, RecipeBook, RecipeBookEntry, ShareLink, ShoppingList,
|
MealType, Recipe, RecipeBook, RecipeBookEntry, ShareLink, ShoppingList,
|
||||||
ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
|
ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
|
||||||
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync,
|
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync,
|
||||||
SyncLog, Unit, UserFile, UserPreference, UserSpace, ViewLog)
|
SyncLog, Unit, UserFile, UserPreference, UserSpace, ViewLog, UnitConversion, PropertyType, Property)
|
||||||
from cookbook.provider.dropbox import Dropbox
|
from cookbook.provider.dropbox import Dropbox
|
||||||
from cookbook.provider.local import Local
|
from cookbook.provider.local import Local
|
||||||
from cookbook.provider.nextcloud import Nextcloud
|
from cookbook.provider.nextcloud import Nextcloud
|
||||||
@ -88,7 +93,8 @@ from cookbook.serializer import (AutomationSerializer, BookmarkletImportListSeri
|
|||||||
SupermarketCategorySerializer, SupermarketSerializer,
|
SupermarketCategorySerializer, SupermarketSerializer,
|
||||||
SyncLogSerializer, SyncSerializer, UnitSerializer,
|
SyncLogSerializer, SyncSerializer, UnitSerializer,
|
||||||
UserFileSerializer, UserSerializer, UserPreferenceSerializer,
|
UserFileSerializer, UserSerializer, UserPreferenceSerializer,
|
||||||
UserSpaceSerializer, ViewLogSerializer, AccessTokenSerializer, FoodSimpleSerializer, RecipeExportSerializer)
|
UserSpaceSerializer, ViewLogSerializer, AccessTokenSerializer, FoodSimpleSerializer,
|
||||||
|
RecipeExportSerializer, UnitConversionSerializer, PropertyTypeSerializer, PropertySerializer)
|
||||||
from cookbook.views.import_export import get_integration
|
from cookbook.views.import_export import get_integration
|
||||||
from recipes import settings
|
from recipes import settings
|
||||||
|
|
||||||
@ -171,7 +177,8 @@ class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin):
|
|||||||
'field', flat=True)])
|
'field', flat=True)])
|
||||||
|
|
||||||
if query is not None and query not in ["''", '']:
|
if query is not None and query not in ["''", '']:
|
||||||
if fuzzy and (settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']):
|
if fuzzy and (settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
|
||||||
|
'django.db.backends.postgresql']):
|
||||||
if any([self.model.__name__.lower() in x for x in
|
if any([self.model.__name__.lower() in x for x in
|
||||||
self.request.user.searchpreference.unaccent.values_list('field', flat=True)]):
|
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))
|
||||||
@ -243,6 +250,9 @@ class MergeMixin(ViewSetMixin):
|
|||||||
isTree = False
|
isTree = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if isinstance(source, Food):
|
||||||
|
source.properties.through.objects.all().delete()
|
||||||
|
|
||||||
for link in [field for field in source._meta.get_fields() if issubclass(type(field), ForeignObjectRel)]:
|
for link in [field for field in source._meta.get_fields() if issubclass(type(field), ForeignObjectRel)]:
|
||||||
linkManager = getattr(source, link.get_accessor_name())
|
linkManager = getattr(source, link.get_accessor_name())
|
||||||
related = linkManager.all()
|
related = linkManager.all()
|
||||||
@ -272,6 +282,7 @@ class MergeMixin(ViewSetMixin):
|
|||||||
source.delete()
|
source.delete()
|
||||||
return Response(content, status=status.HTTP_200_OK)
|
return Response(content, status=status.HTTP_200_OK)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
content = {'error': True,
|
content = {'error': True,
|
||||||
'msg': _(f'An error occurred attempting to merge {source.name} with {target.name}')}
|
'msg': _(f'An error occurred attempting to merge {source.name} with {target.name}')}
|
||||||
return Response(content, status=status.HTTP_400_BAD_REQUEST)
|
return Response(content, status=status.HTTP_400_BAD_REQUEST)
|
||||||
@ -522,8 +533,20 @@ 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())] + [
|
shared_users = []
|
||||||
|
if c := caches['default'].get(
|
||||||
|
f'shopping_shared_users_{self.request.space.id}_{self.request.user.id}', None):
|
||||||
|
shared_users = c
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
shared_users = [x.id for x in list(self.request.user.get_shopping_share())] + [
|
||||||
self.request.user.id]
|
self.request.user.id]
|
||||||
|
caches['default'].set(
|
||||||
|
f'shopping_shared_users_{self.request.space.id}_{self.request.user.id}',
|
||||||
|
shared_users, timeout=5 * 60)
|
||||||
|
# TODO ugly hack that improves API performance significantly, should be done properly
|
||||||
|
except AttributeError: # Anonymous users (using share links) don't have shared users
|
||||||
|
pass
|
||||||
|
|
||||||
self.queryset = super().get_queryset()
|
self.queryset = super().get_queryset()
|
||||||
shopping_status = ShoppingListEntry.objects.filter(space=self.request.space, food=OuterRef('id'),
|
shopping_status = ShoppingListEntry.objects.filter(space=self.request.space, food=OuterRef('id'),
|
||||||
@ -792,7 +815,32 @@ class RecipeViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
if self.detail: # if detail request and not list, private condition is verified by permission class
|
if self.detail: # if detail request and not list, private condition is verified by permission class
|
||||||
if not share: # filter for space only if not shared
|
if not share: # filter for space only if not shared
|
||||||
self.queryset = self.queryset.filter(space=self.request.space)
|
self.queryset = self.queryset.filter(space=self.request.space).prefetch_related(
|
||||||
|
'keywords',
|
||||||
|
'shared',
|
||||||
|
'properties',
|
||||||
|
'properties__property_type',
|
||||||
|
'steps',
|
||||||
|
'steps__ingredients',
|
||||||
|
'steps__ingredients__step_set',
|
||||||
|
'steps__ingredients__step_set__recipe_set',
|
||||||
|
'steps__ingredients__food',
|
||||||
|
'steps__ingredients__food__properties',
|
||||||
|
'steps__ingredients__food__properties__property_type',
|
||||||
|
'steps__ingredients__food__inherit_fields',
|
||||||
|
'steps__ingredients__food__supermarket_category',
|
||||||
|
'steps__ingredients__food__onhand_users',
|
||||||
|
'steps__ingredients__food__substitute',
|
||||||
|
'steps__ingredients__food__child_inherit_fields',
|
||||||
|
|
||||||
|
'steps__ingredients__unit',
|
||||||
|
'steps__ingredients__unit__unit_conversion_base_relation',
|
||||||
|
'steps__ingredients__unit__unit_conversion_base_relation__base_unit',
|
||||||
|
'steps__ingredients__unit__unit_conversion_converted_relation',
|
||||||
|
'steps__ingredients__unit__unit_conversion_converted_relation__converted_unit',
|
||||||
|
'cooklog_set',
|
||||||
|
).select_related('nutrition')
|
||||||
|
|
||||||
return super().get_queryset()
|
return super().get_queryset()
|
||||||
|
|
||||||
self.queryset = self.queryset.filter(space=self.request.space).filter(
|
self.queryset = self.queryset.filter(space=self.request.space).filter(
|
||||||
@ -802,7 +850,7 @@ class RecipeViewSet(viewsets.ModelViewSet):
|
|||||||
params = {x: self.request.GET.get(x) if len({**self.request.GET}[x]) == 1 else self.request.GET.getlist(x) for x
|
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)}
|
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('keywords', 'cooklog_set')
|
||||||
return self.queryset
|
return self.queryset
|
||||||
|
|
||||||
def list(self, request, *args, **kwargs):
|
def list(self, request, *args, **kwargs):
|
||||||
@ -921,6 +969,33 @@ class RecipeViewSet(viewsets.ModelViewSet):
|
|||||||
return Response(self.serializer_class(qs, many=True).data)
|
return Response(self.serializer_class(qs, many=True).data)
|
||||||
|
|
||||||
|
|
||||||
|
class UnitConversionViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = UnitConversion.objects
|
||||||
|
serializer_class = UnitConversionSerializer
|
||||||
|
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return self.queryset.filter(space=self.request.space)
|
||||||
|
|
||||||
|
|
||||||
|
class PropertyTypeViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = PropertyType.objects
|
||||||
|
serializer_class = PropertyTypeSerializer
|
||||||
|
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return self.queryset.filter(space=self.request.space)
|
||||||
|
|
||||||
|
|
||||||
|
class PropertyViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Property.objects
|
||||||
|
serializer_class = PropertySerializer
|
||||||
|
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return self.queryset.filter(space=self.request.space)
|
||||||
|
|
||||||
|
|
||||||
class ShoppingListRecipeViewSet(viewsets.ModelViewSet):
|
class ShoppingListRecipeViewSet(viewsets.ModelViewSet):
|
||||||
queryset = ShoppingListRecipe.objects
|
queryset = ShoppingListRecipe.objects
|
||||||
serializer_class = ShoppingListRecipeSerializer
|
serializer_class = ShoppingListRecipeSerializer
|
||||||
@ -1122,10 +1197,13 @@ class CustomAuthToken(ObtainAuthToken):
|
|||||||
context={'request': request})
|
context={'request': request})
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
user = serializer.validated_data['user']
|
user = serializer.validated_data['user']
|
||||||
if token := AccessToken.objects.filter(user=user, expires__gt=timezone.now(), scope__contains='read').filter(scope__contains='write').first():
|
if token := AccessToken.objects.filter(user=user, expires__gt=timezone.now(), scope__contains='read').filter(
|
||||||
|
scope__contains='write').first():
|
||||||
access_token = token
|
access_token = token
|
||||||
else:
|
else:
|
||||||
access_token = AccessToken.objects.create(user=user, token=f'tda_{str(uuid.uuid4()).replace("-", "_")}', expires=(timezone.now() + timezone.timedelta(days=365 * 5)), scope='read write app')
|
access_token = AccessToken.objects.create(user=user, token=f'tda_{str(uuid.uuid4()).replace("-", "_")}',
|
||||||
|
expires=(timezone.now() + timezone.timedelta(days=365 * 5)),
|
||||||
|
scope='read write app')
|
||||||
return Response({
|
return Response({
|
||||||
'id': access_token.id,
|
'id': access_token.id,
|
||||||
'token': access_token.token,
|
'token': access_token.token,
|
||||||
@ -1153,7 +1231,8 @@ def recipe_from_source(request):
|
|||||||
serializer = RecipeFromSourceSerializer(data=request.data)
|
serializer = RecipeFromSourceSerializer(data=request.data)
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
|
|
||||||
if (b_pk := serializer.validated_data.get('bookmarklet', None)) and (bookmarklet := BookmarkletImport.objects.filter(pk=b_pk).first()):
|
if (b_pk := serializer.validated_data.get('bookmarklet', None)) and (
|
||||||
|
bookmarklet := BookmarkletImport.objects.filter(pk=b_pk).first()):
|
||||||
serializer.validated_data['url'] = bookmarklet.url
|
serializer.validated_data['url'] = bookmarklet.url
|
||||||
serializer.validated_data['data'] = bookmarklet.html
|
serializer.validated_data['data'] = bookmarklet.html
|
||||||
bookmarklet.delete()
|
bookmarklet.delete()
|
||||||
@ -1175,13 +1254,21 @@ def recipe_from_source(request):
|
|||||||
# 'recipe_html': '',
|
# 'recipe_html': '',
|
||||||
'recipe_images': [],
|
'recipe_images': [],
|
||||||
}, status=status.HTTP_200_OK)
|
}, status=status.HTTP_200_OK)
|
||||||
if re.match('^(.)*/view/recipe/[0-9]+/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', url):
|
if re.match(
|
||||||
recipe_json = requests.get(url.replace('/view/recipe/', '/api/recipe/').replace(re.split('/view/recipe/[0-9]+', url)[1], '') + '?share=' + re.split('/view/recipe/[0-9]+', url)[1].replace('/', '')).json()
|
'^(.)*/view/recipe/[0-9]+/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
|
||||||
|
url):
|
||||||
|
recipe_json = requests.get(
|
||||||
|
url.replace('/view/recipe/', '/api/recipe/').replace(re.split('/view/recipe/[0-9]+', url)[1],
|
||||||
|
'') + '?share=' +
|
||||||
|
re.split('/view/recipe/[0-9]+', url)[1].replace('/', '')).json()
|
||||||
recipe_json = clean_dict(recipe_json, 'id')
|
recipe_json = clean_dict(recipe_json, 'id')
|
||||||
serialized_recipe = RecipeExportSerializer(data=recipe_json, context={'request': request})
|
serialized_recipe = RecipeExportSerializer(data=recipe_json, context={'request': request})
|
||||||
if serialized_recipe.is_valid():
|
if serialized_recipe.is_valid():
|
||||||
recipe = serialized_recipe.save()
|
recipe = serialized_recipe.save()
|
||||||
recipe.image = File(handle_image(request, File(io.BytesIO(requests.get(recipe_json['image']).content), name='image'), filetype=pathlib.Path(recipe_json['image']).suffix),
|
recipe.image = File(handle_image(request,
|
||||||
|
File(io.BytesIO(requests.get(recipe_json['image']).content),
|
||||||
|
name='image'),
|
||||||
|
filetype=pathlib.Path(recipe_json['image']).suffix),
|
||||||
name=f'{uuid.uuid4()}_{recipe.pk}{pathlib.Path(recipe_json["image"]).suffix}')
|
name=f'{uuid.uuid4()}_{recipe.pk}{pathlib.Path(recipe_json["image"]).suffix}')
|
||||||
recipe.save()
|
recipe.save()
|
||||||
return Response({
|
return Response({
|
||||||
@ -1323,11 +1410,44 @@ def import_files(request):
|
|||||||
|
|
||||||
return Response({'import_id': il.pk}, status=status.HTTP_200_OK)
|
return Response({'import_id': il.pk}, status=status.HTTP_200_OK)
|
||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
return Response({'error': True, 'msg': _('Importing is not implemented for this provider')}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'error': True, 'msg': _('Importing is not implemented for this provider')},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST)
|
||||||
else:
|
else:
|
||||||
return Response({'error': True, 'msg': form.errors}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'error': True, 'msg': form.errors}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
class ImportOpenData(APIView):
|
||||||
|
permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope]
|
||||||
|
|
||||||
|
def get(self, request, format=None):
|
||||||
|
response = requests.get('https://raw.githubusercontent.com/TandoorRecipes/open-tandoor-data/main/build/meta.json')
|
||||||
|
metadata = json.loads(response.content)
|
||||||
|
return Response(metadata)
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
# TODO validate data
|
||||||
|
print(request.data)
|
||||||
|
selected_version = request.data['selected_version']
|
||||||
|
selected_datatypes = request.data['selected_datatypes']
|
||||||
|
update_existing = str2bool(request.data['update_existing'])
|
||||||
|
use_metric = str2bool(request.data['use_metric'])
|
||||||
|
|
||||||
|
response = requests.get(f'https://raw.githubusercontent.com/TandoorRecipes/open-tandoor-data/main/build/{selected_version}.json') # TODO catch 404, timeout, ...
|
||||||
|
data = json.loads(response.content)
|
||||||
|
|
||||||
|
response_obj = {}
|
||||||
|
|
||||||
|
data_importer = OpenDataImporter(request, data, update_existing=update_existing, use_metric=use_metric)
|
||||||
|
response_obj['unit'] = len(data_importer.import_units())
|
||||||
|
response_obj['category'] = len(data_importer.import_category())
|
||||||
|
response_obj['property'] = len(data_importer.import_property())
|
||||||
|
response_obj['supermarket'] = len(data_importer.import_supermarket())
|
||||||
|
response_obj['food'] = len(data_importer.import_food())
|
||||||
|
response_obj['conversion'] = len(data_importer.import_conversion())
|
||||||
|
|
||||||
|
return Response(response_obj)
|
||||||
|
|
||||||
|
|
||||||
def get_recipe_provider(recipe):
|
def get_recipe_provider(recipe):
|
||||||
if recipe.storage.method == Storage.DROPBOX:
|
if recipe.storage.method == Storage.DROPBOX:
|
||||||
return Dropbox
|
return Dropbox
|
||||||
|
@ -228,3 +228,33 @@ def step(request):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@group_required('user')
|
||||||
|
def unit_conversion(request):
|
||||||
|
# model-name is the models.js name of the model, probably ALL-CAPS
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
'generic/model_template.html',
|
||||||
|
{
|
||||||
|
"title": _("Unit Conversions"),
|
||||||
|
"config": {
|
||||||
|
'model': "UNIT_CONVERSION", # *REQUIRED* name of the model in models.js
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@group_required('user')
|
||||||
|
def property_type(request):
|
||||||
|
# model-name is the models.js name of the model, probably ALL-CAPS
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
'generic/model_template.html',
|
||||||
|
{
|
||||||
|
"title": _("Property Types"),
|
||||||
|
"config": {
|
||||||
|
'model': "PROPERTY_TYPE", # *REQUIRED* name of the model in models.js
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@ -1,14 +1,11 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth import update_session_auth_hash
|
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.contrib.auth.forms import PasswordChangeForm
|
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.password_validation import validate_password
|
from django.contrib.auth.password_validation import validate_password
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
@ -18,11 +15,9 @@ from django.urls import reverse, reverse_lazy
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
from oauth2_provider.models import AccessToken
|
|
||||||
|
|
||||||
from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, ShoppingPreferenceForm,
|
from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, SpaceCreateForm, SpaceJoinForm, User,
|
||||||
SpaceCreateForm, SpaceJoinForm, User,
|
UserCreateForm, UserPreference)
|
||||||
UserCreateForm, UserNameForm, UserPreference, UserPreferenceForm)
|
|
||||||
from cookbook.helper.permission_helper import group_required, has_group_permission, share_link_valid, switch_user_active_space
|
from cookbook.helper.permission_helper import group_required, has_group_permission, share_link_valid, switch_user_active_space
|
||||||
from cookbook.models import (Comment, CookLog, InviteLink, SearchFields, SearchPreference, ShareLink,
|
from cookbook.models import (Comment, CookLog, InviteLink, SearchFields, SearchPreference, ShareLink,
|
||||||
Space, ViewLog, UserSpace)
|
Space, ViewLog, UserSpace)
|
||||||
|
@ -40,6 +40,7 @@ nav:
|
|||||||
- Templating: features/templating.md
|
- Templating: features/templating.md
|
||||||
- Shopping: features/shopping.md
|
- Shopping: features/shopping.md
|
||||||
- Authentication: features/authentication.md
|
- Authentication: features/authentication.md
|
||||||
|
- Automation: features/automation.md
|
||||||
- Storages and Sync: features/external_recipes.md
|
- Storages and Sync: features/external_recipes.md
|
||||||
- Import/Export: features/import_export.md
|
- Import/Export: features/import_export.md
|
||||||
- System:
|
- System:
|
||||||
|
@ -11,6 +11,7 @@ https://docs.djangoproject.com/en/2.0/ref/settings/
|
|||||||
"""
|
"""
|
||||||
import ast
|
import ast
|
||||||
import json
|
import json
|
||||||
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
@ -519,3 +520,5 @@ EMAIL_USE_SSL = bool(int(os.getenv('EMAIL_USE_SSL', False)))
|
|||||||
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'webmaster@localhost')
|
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'webmaster@localhost')
|
||||||
ACCOUNT_EMAIL_SUBJECT_PREFIX = os.getenv(
|
ACCOUNT_EMAIL_SUBJECT_PREFIX = os.getenv(
|
||||||
'ACCOUNT_EMAIL_SUBJECT_PREFIX', '[Tandoor Recipes] ') # allauth sender prefix
|
'ACCOUNT_EMAIL_SUBJECT_PREFIX', '[Tandoor Recipes] ') # allauth sender prefix
|
||||||
|
|
||||||
|
mimetypes.add_type("text/javascript", ".js", True)
|
@ -40,7 +40,7 @@
|
|||||||
"vue-multiselect": "^2.1.6",
|
"vue-multiselect": "^2.1.6",
|
||||||
"vue-property-decorator": "^9.1.2",
|
"vue-property-decorator": "^9.1.2",
|
||||||
"vue-sanitize": "^0.2.2",
|
"vue-sanitize": "^0.2.2",
|
||||||
"vue-simple-calendar": "TandoorRecipes/vue-simple-calendar#lastvue2",
|
"vue-simple-calendar": "5.0.1",
|
||||||
"vue-template-compiler": "2.7.14",
|
"vue-template-compiler": "2.7.14",
|
||||||
"vue2-touch-events": "^3.2.2",
|
"vue2-touch-events": "^3.2.2",
|
||||||
"vuedraggable": "^2.24.3",
|
"vuedraggable": "^2.24.3",
|
||||||
|
@ -31,7 +31,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Image and misc properties -->
|
<!-- Image and misc -->
|
||||||
<div class="row pt-2">
|
<div class="row pt-2">
|
||||||
<div class="col-md-6" style="max-height: 50vh; min-height: 30vh">
|
<div class="col-md-6" style="max-height: 50vh; min-height: 30vh">
|
||||||
<input id="id_file_upload" ref="file_upload" type="file" hidden
|
<input id="id_file_upload" ref="file_upload" type="file" hidden
|
||||||
@ -99,65 +99,53 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Nutrition -->
|
|
||||||
<div class="row pt-2">
|
<div class="row pt-2">
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<div class="card border-grey">
|
<div class="card mt-2 mb-2">
|
||||||
<div class="card-header" style="display: table">
|
<div class="card-body pr-2 pl-2 pr-md-5 pl-md-5 pt-3 pb-3">
|
||||||
<div class="row">
|
<h6>{{ $t('Properties') }} <small class="text-muted"> {{$t('per_serving')}}</small></h6>
|
||||||
<div class="col-md-9 d-table">
|
|
||||||
<h5 class="d-table-cell align-middle">{{ $t("Nutrition") }}</h5>
|
<div class="alert alert-info" role="alert">
|
||||||
|
{{ $t('recipe_property_info')}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex mt-2" v-for="p in recipe.properties" v-bind:key="p.id">
|
||||||
|
<div class="flex-fill w-50">
|
||||||
|
<generic-multiselect
|
||||||
|
@change="p.property_type = $event.val"
|
||||||
|
:initial_single_selection="p.property_type"
|
||||||
|
:label="'name'"
|
||||||
|
:model="Models.PROPERTY_TYPE"
|
||||||
|
:limit="25"
|
||||||
|
:multiple="false"
|
||||||
|
></generic-multiselect>
|
||||||
|
</div>
|
||||||
|
<div class="flex-fill w-50">
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="number" class="form-control" v-model="p.property_amount">
|
||||||
|
<div class="input-group-append">
|
||||||
|
<span class="input-group-text" v-if="p.property_type !== null && p.property_type.unit !== ''">{{ p.property_type.unit }}</span>
|
||||||
|
<button class="btn btn-danger" @click="deleteProperty(p)"><i class="fa fa-trash fa-fw"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-row mt-2">
|
||||||
|
<div class="flex-column w-25 offset-4">
|
||||||
|
<button class="btn btn-success btn-block" @click="addProperty()"><i class="fa fa-plus"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click="addNutrition()"
|
|
||||||
v-if="recipe.nutrition === null"
|
|
||||||
v-b-tooltip.hover
|
|
||||||
v-bind:title="$t('Add_nutrition_recipe')"
|
|
||||||
class="btn btn-sm btn-success shadow-none float-right"
|
|
||||||
>
|
|
||||||
<i class="fas fa-plus-circle"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click="removeNutrition()"
|
|
||||||
v-if="recipe.nutrition !== null"
|
|
||||||
v-b-tooltip.hover
|
|
||||||
v-bind:title="$t('Remove_nutrition_recipe')"
|
|
||||||
class="btn btn-sm btn-danger shadow-none float-right"
|
|
||||||
>
|
|
||||||
<i class="fas fa-trash-alt"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<b-collapse id="id_nutrition_collapse" class="mt-2" v-model="nutrition_visible">
|
<div class="row pt-2">
|
||||||
<div class="card-body" v-if="recipe.nutrition !== null">
|
<div class="col-md-12">
|
||||||
<b-alert show>
|
|
||||||
There is currently only very basic support for tracking nutritional information. A
|
|
||||||
<a href="https://github.com/vabene1111/recipes/issues/896" target="_blank"
|
|
||||||
rel="noreferrer nofollow">big update</a> is planned to improve on this in many
|
|
||||||
different areas.
|
|
||||||
</b-alert>
|
|
||||||
|
|
||||||
<label for="id_name"> {{ $t(energy()) }}</label>
|
|
||||||
|
|
||||||
<input class="form-control" id="id_calories" v-model="recipe.nutrition.calories"/>
|
|
||||||
|
|
||||||
<label for="id_name"> {{ $t("Carbohydrates") }}</label>
|
|
||||||
<input class="form-control" id="id_carbohydrates"
|
|
||||||
v-model="recipe.nutrition.carbohydrates"/>
|
|
||||||
|
|
||||||
<label for="id_name"> {{ $t("Fats") }}</label>
|
|
||||||
<input class="form-control" id="id_fats" v-model="recipe.nutrition.fats"/>
|
|
||||||
|
|
||||||
<label for="id_name"> {{ $t("Proteins") }}</label>
|
|
||||||
<input class="form-control" id="id_proteins" v-model="recipe.nutrition.proteins"/>
|
|
||||||
</div>
|
|
||||||
</b-collapse>
|
|
||||||
</div>
|
|
||||||
<b-card-header header-tag="header" class="p-1" role="tab">
|
<b-card-header header-tag="header" class="p-1" role="tab">
|
||||||
<b-button squared block v-b-toggle.additional_collapse class="text-left"
|
<b-button squared block v-b-toggle.additional_collapse class="text-left"
|
||||||
variant="outline-primary">{{ $t("additional_options") }}
|
variant="outline-primary">{{ $t("additional_options") }}
|
||||||
@ -1121,6 +1109,14 @@ export default {
|
|||||||
let new_keyword = {label: tag, name: tag}
|
let new_keyword = {label: tag, name: tag}
|
||||||
this.recipe.keywords.push(new_keyword)
|
this.recipe.keywords.push(new_keyword)
|
||||||
},
|
},
|
||||||
|
addProperty: function () {
|
||||||
|
this.recipe.properties.push(
|
||||||
|
{'property_amount': 0, 'property_type': null}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
deleteProperty: function (recipe_property) {
|
||||||
|
this.recipe.properties = this.recipe.properties.filter(p => p.id !== recipe_property.id)
|
||||||
|
},
|
||||||
searchKeywords: function (query) {
|
searchKeywords: function (query) {
|
||||||
let apiFactory = new ApiApiFactory()
|
let apiFactory = new ApiApiFactory()
|
||||||
|
|
||||||
|
@ -137,8 +137,7 @@
|
|||||||
|
|
||||||
<div class="row" style="margin-top: 2vh; ">
|
<div class="row" style="margin-top: 2vh; ">
|
||||||
<div class="col-lg-6 offset-lg-3 col-12">
|
<div class="col-lg-6 offset-lg-3 col-12">
|
||||||
<Nutrition-component :recipe="recipe" id="nutrition_container"
|
<property-view-component :recipe="recipe" :servings="servings" @foodUpdated="loadRecipe(recipe.id)"></property-view-component>
|
||||||
:ingredient_factor="ingredient_factor"></Nutrition-component>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -185,6 +184,7 @@ import CustomInputSpinButton from "@/components/CustomInputSpinButton"
|
|||||||
import {ApiApiFactory} from "@/utils/openapi/api";
|
import {ApiApiFactory} from "@/utils/openapi/api";
|
||||||
import ImportTandoor from "@/components/Modals/ImportTandoor.vue";
|
import ImportTandoor from "@/components/Modals/ImportTandoor.vue";
|
||||||
import BottomNavigationBar from "@/components/BottomNavigationBar.vue";
|
import BottomNavigationBar from "@/components/BottomNavigationBar.vue";
|
||||||
|
import PropertyViewComponent from "@/components/PropertyViewComponent.vue";
|
||||||
|
|
||||||
Vue.prototype.moment = moment
|
Vue.prototype.moment = moment
|
||||||
|
|
||||||
@ -202,13 +202,14 @@ export default {
|
|||||||
IngredientsCard,
|
IngredientsCard,
|
||||||
StepComponent,
|
StepComponent,
|
||||||
RecipeContextMenu,
|
RecipeContextMenu,
|
||||||
NutritionComponent,
|
// NutritionComponent,
|
||||||
KeywordsComponent,
|
KeywordsComponent,
|
||||||
LoadingSpinner,
|
LoadingSpinner,
|
||||||
AddRecipeToBook,
|
AddRecipeToBook,
|
||||||
RecipeSwitcher,
|
RecipeSwitcher,
|
||||||
CustomInputSpinButton,
|
CustomInputSpinButton,
|
||||||
BottomNavigationBar,
|
BottomNavigationBar,
|
||||||
|
PropertyViewComponent,
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
ingredient_factor: function () {
|
ingredient_factor: function () {
|
||||||
|
@ -164,6 +164,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<h4>{{ $t('Open_Data_Import') }}</h4>
|
||||||
|
<open-data-import-component></open-data-import-component>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="row mt-4">
|
<div class="row mt-4">
|
||||||
<div class="col col-12">
|
<div class="col col-12">
|
||||||
<h4 class="mt-2"><i class="fas fa-trash"></i> {{ $t('Delete') }}</h4>
|
<h4 class="mt-2"><i class="fas fa-trash"></i> {{ $t('Delete') }}</h4>
|
||||||
@ -198,6 +207,7 @@ import GenericMultiselect from "@/components/GenericMultiselect";
|
|||||||
import GenericModalForm from "@/components/Modals/GenericModalForm";
|
import GenericModalForm from "@/components/Modals/GenericModalForm";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import VueClipboard from 'vue-clipboard2'
|
import VueClipboard from 'vue-clipboard2'
|
||||||
|
import OpenDataImportComponent from "@/components/OpenDataImportComponent.vue";
|
||||||
|
|
||||||
Vue.use(VueClipboard)
|
Vue.use(VueClipboard)
|
||||||
|
|
||||||
@ -206,7 +216,7 @@ Vue.use(BootstrapVue)
|
|||||||
export default {
|
export default {
|
||||||
name: "SpaceManageView",
|
name: "SpaceManageView",
|
||||||
mixins: [ResolveUrlMixin, ToastMixin, ApiMixin],
|
mixins: [ResolveUrlMixin, ToastMixin, ApiMixin],
|
||||||
components: {GenericMultiselect, GenericModalForm},
|
components: {GenericMultiselect, GenericModalForm, OpenDataImportComponent},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
ACTIVE_SPACE_ID: window.ACTIVE_SPACE_ID,
|
ACTIVE_SPACE_ID: window.ACTIVE_SPACE_ID,
|
||||||
|
@ -1,104 +1,42 @@
|
|||||||
<template>
|
<template>
|
||||||
|
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<div class="row" v-if="food">
|
|
||||||
<div class="col-12">
|
<beta-warning></beta-warning>
|
||||||
<h2>{{ food.name }}</h2>
|
|
||||||
</div>
|
<div v-if="metadata !== undefined">
|
||||||
|
{{ $t('Data_Import_Info') }}
|
||||||
|
|
||||||
|
|
||||||
|
<select class="form-control" v-model="selected_version">
|
||||||
|
<option v-for="v in metadata.versions" v-bind:key="v">{{ v }}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<b-checkbox v-model="update_existing" class="mt-1">{{ $t('Update_Existing_Data') }}</b-checkbox>
|
||||||
|
<b-checkbox v-model="use_metric" class="mt-1">{{ $t('Use_Metric') }}</b-checkbox>
|
||||||
|
|
||||||
|
|
||||||
|
<div v-if="selected_version !== undefined" class="mt-3">
|
||||||
|
<table class="table">
|
||||||
|
<tr>
|
||||||
|
<th>{{ $t('Datatype') }}</th>
|
||||||
|
<th>{{ $t('Number of Objects') }}</th>
|
||||||
|
<th>{{ $t('Imported') }}</th>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="d in metadata.datatypes" v-bind:key="d">
|
||||||
|
<td>{{ $t(d.charAt(0).toUpperCase() + d.slice(1)) }}</td>
|
||||||
|
<td>{{ metadata[selected_version][d] }}</td>
|
||||||
|
<td>
|
||||||
|
<template v-if="import_count !== undefined">{{ import_count[d] }}</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<button class="btn btn-success" @click="doImport">{{ $t('Import') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
<b-form v-if="food">
|
|
||||||
<b-form-group :label="$t('Name')" description="">
|
|
||||||
<b-form-input v-model="food.name"></b-form-input>
|
|
||||||
</b-form-group>
|
|
||||||
<b-form-group :label="$t('Plural')" description="">
|
|
||||||
<b-form-input v-model="food.plural_name"></b-form-input>
|
|
||||||
</b-form-group>
|
|
||||||
|
|
||||||
|
|
||||||
<b-form-group :label="$t('Recipe')" :description="$t('food_recipe_help')">
|
|
||||||
<generic-multiselect
|
|
||||||
@change="food.recipe = $event.val;"
|
|
||||||
:model="Models.RECIPE"
|
|
||||||
:initial_selection="food.recipe"
|
|
||||||
label="name"
|
|
||||||
:multiple="false"
|
|
||||||
:placeholder="$t('Recipe')"
|
|
||||||
></generic-multiselect>
|
|
||||||
</b-form-group>
|
|
||||||
|
|
||||||
<b-form-group :description="$t('OnHand_help')">
|
|
||||||
<b-form-checkbox v-model="food.food_onhand">{{ $t('OnHand') }}</b-form-checkbox>
|
|
||||||
</b-form-group>
|
|
||||||
|
|
||||||
<b-form-group :description="$t('ignore_shopping_help')">
|
|
||||||
<b-form-checkbox v-model="food.ignore_shopping">{{ $t('Ignore_Shopping') }}</b-form-checkbox>
|
|
||||||
</b-form-group>
|
|
||||||
|
|
||||||
<b-form-group :label="$t('Shopping_Category')" :description="$t('shopping_category_help')">
|
|
||||||
<generic-multiselect
|
|
||||||
@change="food.supermarket_category = $event.val;"
|
|
||||||
:model="Models.SHOPPING_CATEGORY"
|
|
||||||
:initial_selection="food.supermarket_category"
|
|
||||||
label="name"
|
|
||||||
:multiple="false"
|
|
||||||
:placeholder="$t('Shopping_Category')"
|
|
||||||
></generic-multiselect>
|
|
||||||
</b-form-group>
|
|
||||||
|
|
||||||
<hr/>
|
|
||||||
<!-- todo add conditions if false disable dont hide -->
|
|
||||||
<b-form-group :label="$t('Substitutes')" :description="$t('substitute_help')">
|
|
||||||
<generic-multiselect
|
|
||||||
@change="food.substitute = $event.val;"
|
|
||||||
:model="Models.FOOD"
|
|
||||||
:initial_selection="food.substitute"
|
|
||||||
label="name"
|
|
||||||
:multiple="false"
|
|
||||||
:placeholder="$t('Substitutes')"
|
|
||||||
></generic-multiselect>
|
|
||||||
</b-form-group>
|
|
||||||
|
|
||||||
<b-form-group :description="$t('substitute_siblings_help')">
|
|
||||||
<b-form-checkbox v-model="food.substitute_siblings">{{ $t('substitute_siblings') }}</b-form-checkbox>
|
|
||||||
</b-form-group>
|
|
||||||
|
|
||||||
<b-form-group :label="$t('InheritFields')" :description="$t('InheritFields_help')">
|
|
||||||
<generic-multiselect
|
|
||||||
@change="food.inherit_fields = $event.val;"
|
|
||||||
:model="Models.FOOD_INHERIT_FIELDS"
|
|
||||||
:initial_selection="food.inherit_fields"
|
|
||||||
label="name"
|
|
||||||
:multiple="false"
|
|
||||||
:placeholder="$t('InheritFields')"
|
|
||||||
></generic-multiselect>
|
|
||||||
</b-form-group>
|
|
||||||
|
|
||||||
<b-form-group :label="$t('ChildInheritFields')" :description="$t('ChildInheritFields_help')">
|
|
||||||
<generic-multiselect
|
|
||||||
@change="food.child_inherit_fields = $event.val;"
|
|
||||||
:model="Models.FOOD_INHERIT_FIELDS"
|
|
||||||
:initial_selection="food.child_inherit_fields"
|
|
||||||
label="name"
|
|
||||||
:multiple="false"
|
|
||||||
:placeholder="$t('ChildInheritFields')"
|
|
||||||
></generic-multiselect>
|
|
||||||
</b-form-group>
|
|
||||||
|
|
||||||
<!-- TODO change to a button -->
|
|
||||||
<b-form-group :description="$t('reset_children_help')">
|
|
||||||
<b-form-checkbox v-model="food.reset_inherit">{{ $t('reset_children') }}</b-form-checkbox>
|
|
||||||
</b-form-group>
|
|
||||||
|
|
||||||
<b-button variant="primary" @click="updateFood">{{ $t('Save') }}</b-button>
|
|
||||||
</b-form>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
@ -107,10 +45,9 @@ import Vue from "vue"
|
|||||||
import {BootstrapVue} from "bootstrap-vue"
|
import {BootstrapVue} from "bootstrap-vue"
|
||||||
|
|
||||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||||
import {ApiApiFactory} from "@/utils/openapi/api";
|
import {ApiMixin, resolveDjangoUrl, StandardToasts} from "@/utils/utils";
|
||||||
import RecipeCard from "@/components/RecipeCard.vue";
|
import axios from "axios";
|
||||||
import GenericMultiselect from "@/components/GenericMultiselect.vue";
|
import BetaWarning from "@/components/BetaWarning.vue";
|
||||||
import {ApiMixin, StandardToasts} from "@/utils/utils";
|
|
||||||
|
|
||||||
|
|
||||||
Vue.use(BootstrapVue)
|
Vue.use(BootstrapVue)
|
||||||
@ -119,33 +56,39 @@ Vue.use(BootstrapVue)
|
|||||||
export default {
|
export default {
|
||||||
name: "TestView",
|
name: "TestView",
|
||||||
mixins: [ApiMixin],
|
mixins: [ApiMixin],
|
||||||
components: {
|
components: {BetaWarning},
|
||||||
GenericMultiselect
|
|
||||||
},
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
food: undefined,
|
metadata: undefined,
|
||||||
|
selected_version: undefined,
|
||||||
|
update_existing: true,
|
||||||
|
use_metric: true,
|
||||||
|
import_count: undefined,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.$i18n.locale = window.CUSTOM_LOCALE
|
this.$i18n.locale = window.CUSTOM_LOCALE
|
||||||
let apiClient = new ApiApiFactory()
|
|
||||||
apiClient.retrieveFood('1').then((r) => {
|
|
||||||
this.food = r.data
|
|
||||||
})
|
|
||||||
|
|
||||||
|
axios.get(resolveDjangoUrl('api_import_open_data')).then(r => {
|
||||||
|
this.metadata = r.data
|
||||||
|
}).catch(err => {
|
||||||
|
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
updateFood: function () {
|
doImport: function () {
|
||||||
let apiClient = new ApiApiFactory()
|
axios.post(resolveDjangoUrl('api_import_open_data'), {
|
||||||
apiClient.updateFood(this.food.id, this.food).then((r) => {
|
'selected_version': this.selected_version,
|
||||||
this.food = r.data
|
'selected_datatypes': this.metadata.datatypes,
|
||||||
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_UPDATE)
|
'update_existing': this.update_existing,
|
||||||
|
'use_metric': this.use_metric,
|
||||||
|
}).then(r => {
|
||||||
|
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_CREATE)
|
||||||
|
this.import_count = r.data
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
|
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_CREATE, err)
|
||||||
})
|
})
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
155
vue/src/apps/TestView/TestViewBackup.vue
Normal file
155
vue/src/apps/TestView/TestViewBackup.vue
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
<template>
|
||||||
|
|
||||||
|
<div id="app">
|
||||||
|
<div class="row" v-if="food">
|
||||||
|
<div class="col-12">
|
||||||
|
<h2>{{ food.name }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<b-form v-if="food">
|
||||||
|
<b-form-group :label="$t('Name')" description="">
|
||||||
|
<b-form-input v-model="food.name"></b-form-input>
|
||||||
|
</b-form-group>
|
||||||
|
<b-form-group :label="$t('Plural')" description="">
|
||||||
|
<b-form-input v-model="food.plural_name"></b-form-input>
|
||||||
|
</b-form-group>
|
||||||
|
|
||||||
|
|
||||||
|
<b-form-group :label="$t('Recipe')" :description="$t('food_recipe_help')">
|
||||||
|
<generic-multiselect
|
||||||
|
@change="food.recipe = $event.val;"
|
||||||
|
:model="Models.RECIPE"
|
||||||
|
:initial_selection="food.recipe"
|
||||||
|
label="name"
|
||||||
|
:multiple="false"
|
||||||
|
:placeholder="$t('Recipe')"
|
||||||
|
></generic-multiselect>
|
||||||
|
</b-form-group>
|
||||||
|
|
||||||
|
<b-form-group :description="$t('OnHand_help')">
|
||||||
|
<b-form-checkbox v-model="food.food_onhand">{{ $t('OnHand') }}</b-form-checkbox>
|
||||||
|
</b-form-group>
|
||||||
|
|
||||||
|
<b-form-group :description="$t('ignore_shopping_help')">
|
||||||
|
<b-form-checkbox v-model="food.ignore_shopping">{{ $t('Ignore_Shopping') }}</b-form-checkbox>
|
||||||
|
</b-form-group>
|
||||||
|
|
||||||
|
<b-form-group :label="$t('Shopping_Category')" :description="$t('shopping_category_help')">
|
||||||
|
<generic-multiselect
|
||||||
|
@change="food.supermarket_category = $event.val;"
|
||||||
|
:model="Models.SHOPPING_CATEGORY"
|
||||||
|
:initial_selection="food.supermarket_category"
|
||||||
|
label="name"
|
||||||
|
:multiple="false"
|
||||||
|
:placeholder="$t('Shopping_Category')"
|
||||||
|
></generic-multiselect>
|
||||||
|
</b-form-group>
|
||||||
|
|
||||||
|
<hr/>
|
||||||
|
<!-- todo add conditions if false disable dont hide -->
|
||||||
|
<b-form-group :label="$t('Substitutes')" :description="$t('substitute_help')">
|
||||||
|
<generic-multiselect
|
||||||
|
@change="food.substitute = $event.val;"
|
||||||
|
:model="Models.FOOD"
|
||||||
|
:initial_selection="food.substitute"
|
||||||
|
label="name"
|
||||||
|
:multiple="false"
|
||||||
|
:placeholder="$t('Substitutes')"
|
||||||
|
></generic-multiselect>
|
||||||
|
</b-form-group>
|
||||||
|
|
||||||
|
<b-form-group :description="$t('substitute_siblings_help')">
|
||||||
|
<b-form-checkbox v-model="food.substitute_siblings">{{ $t('substitute_siblings') }}</b-form-checkbox>
|
||||||
|
</b-form-group>
|
||||||
|
|
||||||
|
<b-form-group :label="$t('InheritFields')" :description="$t('InheritFields_help')">
|
||||||
|
<generic-multiselect
|
||||||
|
@change="food.inherit_fields = $event.val;"
|
||||||
|
:model="Models.FOOD_INHERIT_FIELDS"
|
||||||
|
:initial_selection="food.inherit_fields"
|
||||||
|
label="name"
|
||||||
|
:multiple="false"
|
||||||
|
:placeholder="$t('InheritFields')"
|
||||||
|
></generic-multiselect>
|
||||||
|
</b-form-group>
|
||||||
|
|
||||||
|
<b-form-group :label="$t('ChildInheritFields')" :description="$t('ChildInheritFields_help')">
|
||||||
|
<generic-multiselect
|
||||||
|
@change="food.child_inherit_fields = $event.val;"
|
||||||
|
:model="Models.FOOD_INHERIT_FIELDS"
|
||||||
|
:initial_selection="food.child_inherit_fields"
|
||||||
|
label="name"
|
||||||
|
:multiple="false"
|
||||||
|
:placeholder="$t('ChildInheritFields')"
|
||||||
|
></generic-multiselect>
|
||||||
|
</b-form-group>
|
||||||
|
|
||||||
|
<!-- TODO change to a button -->
|
||||||
|
<b-form-group :description="$t('reset_children_help')">
|
||||||
|
<b-form-checkbox v-model="food.reset_inherit">{{ $t('reset_children') }}</b-form-checkbox>
|
||||||
|
</b-form-group>
|
||||||
|
|
||||||
|
<b-button variant="primary" @click="updateFood">{{ $t('Save') }}</b-button>
|
||||||
|
</b-form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Vue from "vue"
|
||||||
|
import {BootstrapVue} from "bootstrap-vue"
|
||||||
|
|
||||||
|
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||||
|
import {ApiApiFactory} from "@/utils/openapi/api";
|
||||||
|
import RecipeCard from "@/components/RecipeCard.vue";
|
||||||
|
import GenericMultiselect from "@/components/GenericMultiselect.vue";
|
||||||
|
import {ApiMixin, StandardToasts} from "@/utils/utils";
|
||||||
|
|
||||||
|
|
||||||
|
Vue.use(BootstrapVue)
|
||||||
|
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "TestView",
|
||||||
|
mixins: [ApiMixin],
|
||||||
|
components: {
|
||||||
|
GenericMultiselect
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
food: undefined,
|
||||||
|
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$i18n.locale = window.CUSTOM_LOCALE
|
||||||
|
let apiClient = new ApiApiFactory()
|
||||||
|
apiClient.retrieveFood('1').then((r) => {
|
||||||
|
this.food = r.data
|
||||||
|
})
|
||||||
|
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateFood: function () {
|
||||||
|
let apiClient = new ApiApiFactory()
|
||||||
|
apiClient.updateFood(this.food.id, this.food).then((r) => {
|
||||||
|
this.food = r.data
|
||||||
|
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_UPDATE)
|
||||||
|
}).catch(err => {
|
||||||
|
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
|
||||||
|
</style>
|
315
vue/src/components/FoodEditor.vue
Normal file
315
vue/src/components/FoodEditor.vue
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
<template>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<b-modal :id="id" size="xl" @hidden="cancelAction">
|
||||||
|
|
||||||
|
<template v-slot:modal-title>
|
||||||
|
<div class="row" v-if="food">
|
||||||
|
<div class="col-12">
|
||||||
|
<h2>{{ food.name }} <small class="text-muted" v-if="food.plural_name">{{
|
||||||
|
food.plural_name
|
||||||
|
}}</small>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<b-form v-if="food">
|
||||||
|
<b-form-group :label="$t('Name')" description="">
|
||||||
|
<b-form-input v-model="food.name"></b-form-input>
|
||||||
|
</b-form-group>
|
||||||
|
<b-form-group :label="$t('Plural')" description="">
|
||||||
|
<b-form-input v-model="food.plural_name"></b-form-input>
|
||||||
|
</b-form-group>
|
||||||
|
|
||||||
|
<!-- Food properties -->
|
||||||
|
|
||||||
|
<h5><i class="fas fa-database"></i> {{ $t('Properties') }}</h5>
|
||||||
|
|
||||||
|
<b-form-group :label="$t('Properties Food Amount')" description=""> <!-- TODO localize -->
|
||||||
|
<b-form-input v-model="food.properties_food_amount"></b-form-input>
|
||||||
|
</b-form-group>
|
||||||
|
|
||||||
|
<b-form-group :label="$t('Properties Food Unit')" description=""> <!-- TODO localize -->
|
||||||
|
<generic-multiselect
|
||||||
|
@change="food.properties_food_unit = $event.val;"
|
||||||
|
:model="Models.UNIT"
|
||||||
|
:initial_single_selection="food.properties_food_unit"
|
||||||
|
label="name"
|
||||||
|
:multiple="false"
|
||||||
|
:placeholder="$t('Unit')"
|
||||||
|
></generic-multiselect>
|
||||||
|
</b-form-group>
|
||||||
|
|
||||||
|
|
||||||
|
<table class="table table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th> {{ $t('Property Amount') }}</th> <!-- TODO localize -->
|
||||||
|
<th> {{ $t('Property Type') }}</th> <!-- TODO localize -->
|
||||||
|
<th></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tr v-for="fp in food.properties" v-bind:key="fp.id">
|
||||||
|
<td><input v-model="fp.property_amount" type="number"> <span
|
||||||
|
v-if="fp.property_type">{{ fp.property_type.unit }}</span></td>
|
||||||
|
<td>
|
||||||
|
<generic-multiselect
|
||||||
|
@change="fp.property_type = $event.val"
|
||||||
|
:initial_single_selection="fp.property_type"
|
||||||
|
label="name" :model="Models.PROPERTY_TYPE"
|
||||||
|
:multiple="false"/>
|
||||||
|
</td>
|
||||||
|
<td> / <span>{{ food.properties_food_amount }} <span
|
||||||
|
v-if="food.properties_food_unit !== null">{{
|
||||||
|
food.properties_food_unit.name
|
||||||
|
}}</span></span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-danger btn-small" @click="deleteProperty(fp)"><i
|
||||||
|
class="fas fa-trash-alt"></i></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<b-button-group>
|
||||||
|
<b-btn class="btn btn-success shadow-none" @click="addProperty()"><i
|
||||||
|
class="fa fa-plus"></i>
|
||||||
|
</b-btn>
|
||||||
|
<b-btn class="btn btn-secondary shadow-none" @click="addAllProperties()"><i
|
||||||
|
class="fa fa-plus"> <i class="ml-1 fas fa-list"></i></i>
|
||||||
|
</b-btn>
|
||||||
|
</b-button-group>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<b-form-group :label="$t('Shopping_Category')" :description="$t('shopping_category_help')">
|
||||||
|
<generic-multiselect
|
||||||
|
@change="food.supermarket_category = $event.val;"
|
||||||
|
:model="Models.SHOPPING_CATEGORY"
|
||||||
|
:initial_single_selection="food.supermarket_category"
|
||||||
|
label="name"
|
||||||
|
:multiple="false"
|
||||||
|
:allow_create="true"
|
||||||
|
:placeholder="$t('Shopping_Category')"
|
||||||
|
></generic-multiselect>
|
||||||
|
</b-form-group>
|
||||||
|
|
||||||
|
<!-- Unit conversion -->
|
||||||
|
|
||||||
|
<!-- ADVANCED FEATURES somehow hide this stuff -->
|
||||||
|
<b-collapse id="collapse-advanced">
|
||||||
|
<b-form-group :label="$t('Recipe')" :description="$t('food_recipe_help')">
|
||||||
|
<generic-multiselect
|
||||||
|
@change="food.recipe = $event.val;"
|
||||||
|
:model="Models.RECIPE"
|
||||||
|
:initial_single_selection="food.recipe"
|
||||||
|
label="name"
|
||||||
|
:multiple="false"
|
||||||
|
:placeholder="$t('Recipe')"
|
||||||
|
></generic-multiselect>
|
||||||
|
</b-form-group>
|
||||||
|
|
||||||
|
<b-form-group :description="$t('OnHand_help')">
|
||||||
|
<b-form-checkbox v-model="food.food_onhand">{{ $t('OnHand') }}</b-form-checkbox>
|
||||||
|
</b-form-group>
|
||||||
|
|
||||||
|
<b-form-group :description="$t('ignore_shopping_help')">
|
||||||
|
<b-form-checkbox v-model="food.ignore_shopping">{{
|
||||||
|
$t('Ignore_Shopping')
|
||||||
|
}}
|
||||||
|
</b-form-checkbox>
|
||||||
|
</b-form-group>
|
||||||
|
|
||||||
|
<hr/>
|
||||||
|
<!-- todo add conditions if false disable dont hide -->
|
||||||
|
<b-form-group :label="$t('Substitutes')" :description="$t('substitute_help')">
|
||||||
|
<generic-multiselect
|
||||||
|
@change="food.substitute = $event.val;"
|
||||||
|
:model="Models.FOOD"
|
||||||
|
:initial_selection="food.substitute"
|
||||||
|
label="name"
|
||||||
|
:multiple="true"
|
||||||
|
:placeholder="$t('Substitutes')"
|
||||||
|
></generic-multiselect>
|
||||||
|
</b-form-group>
|
||||||
|
|
||||||
|
<b-form-group :description="$t('substitute_siblings_help')">
|
||||||
|
<b-form-checkbox v-model="food.substitute_siblings">{{
|
||||||
|
$t('substitute_siblings')
|
||||||
|
}}
|
||||||
|
</b-form-checkbox>
|
||||||
|
</b-form-group>
|
||||||
|
|
||||||
|
<b-form-group :label="$t('InheritFields')" :description="$t('InheritFields_help')">
|
||||||
|
<generic-multiselect
|
||||||
|
@change="food.inherit_fields = $event.val;"
|
||||||
|
:model="Models.FOOD_INHERIT_FIELDS"
|
||||||
|
:initial_selection="food.inherit_fields"
|
||||||
|
label="name"
|
||||||
|
:multiple="true"
|
||||||
|
:placeholder="$t('InheritFields')"
|
||||||
|
></generic-multiselect>
|
||||||
|
</b-form-group>
|
||||||
|
|
||||||
|
<b-form-group :label="$t('ChildInheritFields')"
|
||||||
|
:description="$t('ChildInheritFields_help')">
|
||||||
|
<generic-multiselect
|
||||||
|
@change="food.child_inherit_fields = $event.val;"
|
||||||
|
:model="Models.FOOD_INHERIT_FIELDS"
|
||||||
|
:initial_sselection="food.child_inherit_fields"
|
||||||
|
label="name"
|
||||||
|
:multiple="true"
|
||||||
|
:placeholder="$t('ChildInheritFields')"
|
||||||
|
></generic-multiselect>
|
||||||
|
</b-form-group>
|
||||||
|
|
||||||
|
<!-- TODO change to a button -->
|
||||||
|
<b-form-group :description="$t('reset_children_help')">
|
||||||
|
<b-form-checkbox v-model="food.reset_inherit">{{
|
||||||
|
$t('reset_children')
|
||||||
|
}}
|
||||||
|
</b-form-checkbox>
|
||||||
|
</b-form-group>
|
||||||
|
</b-collapse>
|
||||||
|
|
||||||
|
|
||||||
|
</b-form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template v-slot:modal-footer>
|
||||||
|
<b-button variant="primary" @click="updateFood">{{ $t('Save') }}</b-button>
|
||||||
|
<b-button v-b-toggle.collapse-advanced class="m-1">{{ $t('Advanced') }}</b-button>
|
||||||
|
</template>
|
||||||
|
</b-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Vue from "vue"
|
||||||
|
import {BootstrapVue} from "bootstrap-vue"
|
||||||
|
|
||||||
|
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||||
|
import {ApiApiFactory} from "@/utils/openapi/api";
|
||||||
|
import GenericMultiselect from "@/components/GenericMultiselect.vue";
|
||||||
|
import {ApiMixin, formFunctions, getForm, StandardToasts} from "@/utils/utils";
|
||||||
|
|
||||||
|
|
||||||
|
Vue.use(BootstrapVue)
|
||||||
|
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "FoodEditor",
|
||||||
|
mixins: [ApiMixin],
|
||||||
|
components: {
|
||||||
|
GenericMultiselect
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
id: {type: String, default: 'id_food_edit_modal_modal'},
|
||||||
|
show: {required: true, type: Boolean, default: false},
|
||||||
|
item1: {
|
||||||
|
type: Object,
|
||||||
|
default: undefined
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show: function () {
|
||||||
|
if (this.show) {
|
||||||
|
this.$bvModal.show(this.id)
|
||||||
|
} else {
|
||||||
|
this.$bvModal.hide(this.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
food: undefined,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$bvModal.show(this.id)
|
||||||
|
this.$i18n.locale = window.CUSTOM_LOCALE
|
||||||
|
let apiClient = new ApiApiFactory()
|
||||||
|
let pf
|
||||||
|
if (this.item1.id !== undefined) {
|
||||||
|
pf = apiClient.retrieveFood(this.item1.id).then((r) => {
|
||||||
|
this.food = r.data
|
||||||
|
}).catch(err => {
|
||||||
|
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.food = {
|
||||||
|
name: "",
|
||||||
|
plural_name: "",
|
||||||
|
description: "",
|
||||||
|
shopping: false,
|
||||||
|
recipe: null,
|
||||||
|
properties: [],
|
||||||
|
properties_food_amount: 100,
|
||||||
|
properties_food_unit: null,
|
||||||
|
food_onhand: false,
|
||||||
|
supermarket_category: null,
|
||||||
|
parent: null,
|
||||||
|
numchild: 0,
|
||||||
|
inherit_fields: [],
|
||||||
|
ignore_shopping: false,
|
||||||
|
substitute: [],
|
||||||
|
substitute_siblings: false,
|
||||||
|
substitute_children: false,
|
||||||
|
substitute_onhand: false,
|
||||||
|
child_inherit_fields: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateFood: function () {
|
||||||
|
let apiClient = new ApiApiFactory()
|
||||||
|
if (this.food.id !== undefined) {
|
||||||
|
apiClient.updateFood(this.food.id, this.food).then((r) => {
|
||||||
|
this.food = r.data
|
||||||
|
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_UPDATE)
|
||||||
|
}).catch(err => {
|
||||||
|
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
apiClient.createFood(this.food).then((r) => {
|
||||||
|
this.food = r.data
|
||||||
|
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_CREATE)
|
||||||
|
}).catch(err => {
|
||||||
|
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addProperty: function () {
|
||||||
|
this.food.properties.push({property_type: null, property_amount: 0})
|
||||||
|
},
|
||||||
|
addAllProperties: function () {
|
||||||
|
let apiClient = new ApiApiFactory()
|
||||||
|
apiClient.listPropertyTypes().then(r => {
|
||||||
|
r.data.forEach(x => {
|
||||||
|
this.food.properties.push({property_type: x, property_amount: 0})
|
||||||
|
})
|
||||||
|
}).catch(err => {
|
||||||
|
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteProperty: function (p) {
|
||||||
|
this.food.properties = this.food.properties.filter(x => x !== p)
|
||||||
|
},
|
||||||
|
cancelAction: function () {
|
||||||
|
this.$emit("hidden", "")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
|
||||||
|
</style>
|
@ -1,10 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<template v-if="form_component !== undefined">
|
<template v-if="form_component !== undefined">
|
||||||
<b-modal :id="'modal_' + id" @hidden="cancelAction" size="xl">
|
<component :is="form_component" :id="'modal_' + id" :show="show" @hidden="cancelAction" :item1="item1"></component>
|
||||||
<component :is="form_component"></component>
|
|
||||||
</b-modal>
|
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<b-modal :id="'modal_' + id" @hidden="cancelAction" size="lg">
|
<b-modal :id="'modal_' + id" @hidden="cancelAction" size="lg">
|
||||||
@ -43,13 +40,13 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Vue from "vue"
|
import Vue from "vue"
|
||||||
import { BootstrapVue } from "bootstrap-vue"
|
import {BootstrapVue} from "bootstrap-vue"
|
||||||
import { getForm, formFunctions } from "@/utils/utils"
|
import {getForm, formFunctions} from "@/utils/utils"
|
||||||
|
|
||||||
Vue.use(BootstrapVue)
|
Vue.use(BootstrapVue)
|
||||||
|
|
||||||
import { ApiApiFactory } from "@/utils/openapi/api"
|
import {ApiApiFactory} from "@/utils/openapi/api"
|
||||||
import { ApiMixin, StandardToasts, ToastMixin, getUserPreference } from "@/utils/utils"
|
import {ApiMixin, StandardToasts, ToastMixin, getUserPreference} from "@/utils/utils"
|
||||||
import CheckboxInput from "@/components/Modals/CheckboxInput"
|
import CheckboxInput from "@/components/Modals/CheckboxInput"
|
||||||
import LookupInput from "@/components/Modals/LookupInput"
|
import LookupInput from "@/components/Modals/LookupInput"
|
||||||
import TextInput from "@/components/Modals/TextInput"
|
import TextInput from "@/components/Modals/TextInput"
|
||||||
@ -63,10 +60,21 @@ import NumberInput from "@/components/Modals/NumberInput.vue";
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "GenericModalForm",
|
name: "GenericModalForm",
|
||||||
components: { FileInput, CheckboxInput, LookupInput, TextInput, EmojiInput, ChoiceInput, SmallText, HelpBadge,DateInput, NumberInput },
|
components: {
|
||||||
|
FileInput,
|
||||||
|
CheckboxInput,
|
||||||
|
LookupInput,
|
||||||
|
TextInput,
|
||||||
|
EmojiInput,
|
||||||
|
ChoiceInput,
|
||||||
|
SmallText,
|
||||||
|
HelpBadge,
|
||||||
|
DateInput,
|
||||||
|
NumberInput
|
||||||
|
},
|
||||||
mixins: [ApiMixin, ToastMixin],
|
mixins: [ApiMixin, ToastMixin],
|
||||||
props: {
|
props: {
|
||||||
model: { required: true, type: Object },
|
model: {required: true, type: Object},
|
||||||
action: {
|
action: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default() {
|
default() {
|
||||||
@ -86,7 +94,6 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
show: {required: true, type: Boolean, default: false},
|
show: {required: true, type: Boolean, default: false},
|
||||||
models: {required: false, type: Function, default: null}
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@ -128,9 +135,9 @@ export default {
|
|||||||
form_component() {
|
form_component() {
|
||||||
// TODO this leads webpack to create one .js file for each component in this folder because at runtime any one of them could be requested
|
// TODO this leads webpack to create one .js file for each component in this folder because at runtime any one of them could be requested
|
||||||
// TODO this is not necessarily bad but maybe there are better options to do this
|
// TODO this is not necessarily bad but maybe there are better options to do this
|
||||||
if (this.form.component !== undefined){
|
if (this.form.component !== undefined) {
|
||||||
return () => import(/* webpackChunkName: "header-component" */ `@/components/${this.form.component}`)
|
return () => import(/* webpackChunkName: "header-component" */ `@/components/${this.form.component}`)
|
||||||
}else{
|
} else {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -198,16 +205,16 @@ export default {
|
|||||||
return form
|
return form
|
||||||
},
|
},
|
||||||
delete: function () {
|
delete: function () {
|
||||||
this.genericAPI(this.model, this.Actions.DELETE, { id: this.item1.id })
|
this.genericAPI(this.model, this.Actions.DELETE, {id: this.item1.id})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
this.$emit("finish-action")
|
this.$emit("finish-action")
|
||||||
StandardToasts.makeStandardToast(this,StandardToasts.SUCCESS_DELETE)
|
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_DELETE)
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (err.response.status === 403){
|
if (err.response.status === 403) {
|
||||||
StandardToasts.makeStandardToast(this,StandardToasts.FAIL_DELETE_PROTECTED, err)
|
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_DELETE_PROTECTED, err)
|
||||||
}else {
|
} else {
|
||||||
StandardToasts.makeStandardToast(this,StandardToasts.FAIL_DELETE, err)
|
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_DELETE, err)
|
||||||
}
|
}
|
||||||
this.$emit("finish-action", "cancel")
|
this.$emit("finish-action", "cancel")
|
||||||
})
|
})
|
||||||
@ -217,22 +224,22 @@ export default {
|
|||||||
// if there is no item id assume it's a new item
|
// if there is no item id assume it's a new item
|
||||||
this.genericAPI(this.model, this.Actions.CREATE, this.form_data)
|
this.genericAPI(this.model, this.Actions.CREATE, this.form_data)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
this.$emit("finish-action", { item: result.data })
|
this.$emit("finish-action", {item: result.data})
|
||||||
StandardToasts.makeStandardToast(this,StandardToasts.SUCCESS_CREATE)
|
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_CREATE)
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.log(err)
|
console.log(err)
|
||||||
StandardToasts.makeStandardToast(this,StandardToasts.FAIL_CREATE, err, true)
|
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_CREATE)
|
||||||
this.$emit("finish-action", "cancel")
|
this.$emit("finish-action", "cancel")
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
this.genericAPI(this.model, this.Actions.UPDATE, this.form_data)
|
this.genericAPI(this.model, this.Actions.UPDATE, this.form_data)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
this.$emit("finish-action", { item: result.data })
|
this.$emit("finish-action", {item: result.data})
|
||||||
StandardToasts.makeStandardToast(this,StandardToasts.SUCCESS_UPDATE)
|
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_UPDATE)
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
StandardToasts.makeStandardToast(this,StandardToasts.FAIL_UPDATE, err, true)
|
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
|
||||||
this.$emit("finish-action", "cancel")
|
this.$emit("finish-action", "cancel")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -248,13 +255,13 @@ export default {
|
|||||||
this.$emit("finish-action", "cancel")
|
this.$emit("finish-action", "cancel")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.genericAPI(this.model, this.Actions.MOVE, { source: this.item1.id, target: this.form_data.target.id })
|
this.genericAPI(this.model, this.Actions.MOVE, {source: this.item1.id, target: this.form_data.target.id})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
this.$emit("finish-action", { target: this.form_data.target.id })
|
this.$emit("finish-action", {target: this.form_data.target.id})
|
||||||
StandardToasts.makeStandardToast(this,StandardToasts.SUCCESS_MOVE)
|
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_MOVE)
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
StandardToasts.makeStandardToast(this,StandardToasts.FAIL_MOVE, err)
|
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_MOVE, err)
|
||||||
this.$emit("finish-action", "cancel")
|
this.$emit("finish-action", "cancel")
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@ -274,11 +281,14 @@ export default {
|
|||||||
target: this.form_data.target.id,
|
target: this.form_data.target.id,
|
||||||
})
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
this.$emit("finish-action", { target: this.form_data.target.id, target_object: this.form_data.target }) //TODO temporary workaround to not change other apis
|
this.$emit("finish-action", {
|
||||||
StandardToasts.makeStandardToast(this,StandardToasts.SUCCESS_MERGE)
|
target: this.form_data.target.id,
|
||||||
|
target_object: this.form_data.target
|
||||||
|
}) //TODO temporary workaround to not change other apis
|
||||||
|
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_MERGE)
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
StandardToasts.makeStandardToast(this,StandardToasts.FAIL_MERGE, err)
|
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_MERGE, err)
|
||||||
this.$emit("finish-action", "cancel")
|
this.$emit("finish-action", "cancel")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<b-form-group v-bind:label="label" class="mb-3">
|
<b-form-group v-bind:label="label" class="mb-3">
|
||||||
<b-form-input v-model="new_value" type="text" :placeholder="placeholder"></b-form-input>
|
<b-form-input v-model="new_value" type="text" :placeholder="placeholder" :disabled="disabled"></b-form-input>
|
||||||
<em v-if="help" class="small text-muted">{{ help }}</em>
|
<em v-if="help" class="small text-muted">{{ help }}</em>
|
||||||
<small v-if="subtitle" class="text-muted">{{ subtitle }}</small>
|
<small v-if="subtitle" class="text-muted">{{ subtitle }}</small>
|
||||||
</b-form-group>
|
</b-form-group>
|
||||||
@ -18,6 +18,7 @@ export default {
|
|||||||
placeholder: { type: String, default: "You Should Add Placeholder Text" },
|
placeholder: { type: String, default: "You Should Add Placeholder Text" },
|
||||||
help: { type: String, default: undefined },
|
help: { type: String, default: undefined },
|
||||||
subtitle: { type: String, default: undefined },
|
subtitle: { type: String, default: undefined },
|
||||||
|
disabled: { type: Boolean, default: false }
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
94
vue/src/components/OpenDataImportComponent.vue
Normal file
94
vue/src/components/OpenDataImportComponent.vue
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<beta-warning></beta-warning>
|
||||||
|
|
||||||
|
<div v-if="metadata !== undefined">
|
||||||
|
{{ $t('Data_Import_Info') }}
|
||||||
|
<a href="https://github.com/TandoorRecipes/open-tandoor-data" target="_blank" rel="noreferrer nofollow">{{$t('Learn_More')}}</a>
|
||||||
|
|
||||||
|
|
||||||
|
<select class="form-control" v-model="selected_version">
|
||||||
|
<option v-for="v in metadata.versions" v-bind:key="v">{{ v }}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<b-checkbox v-model="update_existing" class="mt-1">{{ $t('Update_Existing_Data') }}</b-checkbox>
|
||||||
|
<b-checkbox v-model="use_metric" class="mt-1">{{ $t('Use_Metric') }}</b-checkbox>
|
||||||
|
|
||||||
|
|
||||||
|
<div v-if="selected_version !== undefined" class="mt-3">
|
||||||
|
<table class="table">
|
||||||
|
<tr>
|
||||||
|
<th>{{ $t('Datatype') }}</th>
|
||||||
|
<th>{{ $t('Number of Objects') }}</th>
|
||||||
|
<th>{{ $t('Imported') }}</th>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="d in metadata.datatypes" v-bind:key="d">
|
||||||
|
<td>{{ $t(d.charAt(0).toUpperCase() + d.slice(1)) }}</td>
|
||||||
|
<td>{{ metadata[selected_version][d] }}</td>
|
||||||
|
<td>
|
||||||
|
<template v-if="import_count !== undefined">{{ import_count[d] }}</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<button class="btn btn-success" @click="doImport">{{ $t('Import') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Vue from "vue"
|
||||||
|
import {BootstrapVue} from "bootstrap-vue"
|
||||||
|
|
||||||
|
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||||
|
import {ApiMixin, resolveDjangoUrl, StandardToasts} from "@/utils/utils";
|
||||||
|
import axios from "axios";
|
||||||
|
import BetaWarning from "@/components/BetaWarning.vue";
|
||||||
|
|
||||||
|
|
||||||
|
Vue.use(BootstrapVue)
|
||||||
|
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "OpenDataImportComponent",
|
||||||
|
mixins: [ApiMixin],
|
||||||
|
components: {BetaWarning},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
metadata: undefined,
|
||||||
|
selected_version: undefined,
|
||||||
|
update_existing: true,
|
||||||
|
use_metric: true,
|
||||||
|
import_count: undefined,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$i18n.locale = window.CUSTOM_LOCALE
|
||||||
|
|
||||||
|
axios.get(resolveDjangoUrl('api_import_open_data')).then(r => {
|
||||||
|
this.metadata = r.data
|
||||||
|
}).catch(err => {
|
||||||
|
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
doImport: function () {
|
||||||
|
axios.post(resolveDjangoUrl('api_import_open_data'), {
|
||||||
|
'selected_version': this.selected_version,
|
||||||
|
'selected_datatypes': this.metadata.datatypes,
|
||||||
|
'update_existing': this.update_existing,
|
||||||
|
'use_metric': this.use_metric,
|
||||||
|
}).then(r => {
|
||||||
|
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_CREATE)
|
||||||
|
this.import_count = r.data
|
||||||
|
}).catch(err => {
|
||||||
|
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_CREATE, err)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
195
vue/src/components/PropertyViewComponent.vue
Normal file
195
vue/src/components/PropertyViewComponent.vue
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="card p-4 pb-2" v-if="recipe !== undefined">
|
||||||
|
<b-row>
|
||||||
|
<b-col>
|
||||||
|
<h5><i class="fas fa-database"></i> {{ $t('Properties') }}</h5>
|
||||||
|
</b-col>
|
||||||
|
<b-col class="text-right">
|
||||||
|
<span v-if="!show_total">{{ $t('per_serving') }} </span>
|
||||||
|
<span v-if="show_total">{{ $t('total') }} </span>
|
||||||
|
|
||||||
|
<a href="#" @click="show_total = !show_total">
|
||||||
|
<i class="fas fa-toggle-on" v-if="!show_total"></i>
|
||||||
|
<i class="fas fa-toggle-off" v-if="show_total"></i>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div v-if="hasRecipeProperties && hasFoodProperties">
|
||||||
|
<span v-if="!show_recipe_properties">{{ $t('Food') }} </span>
|
||||||
|
<span v-if="show_recipe_properties">{{ $t('Recipe') }} </span>
|
||||||
|
|
||||||
|
<a href="#" @click="show_recipe_properties = !show_recipe_properties">
|
||||||
|
<i class="fas fa-toggle-on" v-if="!show_recipe_properties"></i>
|
||||||
|
<i class="fas fa-toggle-off" v-if="show_recipe_properties"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</b-col>
|
||||||
|
</b-row>
|
||||||
|
|
||||||
|
|
||||||
|
<table class="table table-bordered table-sm">
|
||||||
|
|
||||||
|
<tr v-for="p in property_list" v-bind:key="`id_${p.id}`">
|
||||||
|
<td>
|
||||||
|
|
||||||
|
{{ p.icon }} {{ p.name }}
|
||||||
|
</td>
|
||||||
|
<td class="text-right">{{ get_amount(p.property_amount) }}</td>
|
||||||
|
<td class=""> {{ p.unit }}</td>
|
||||||
|
|
||||||
|
<td class="align-middle text-center" v-if="!show_recipe_properties">
|
||||||
|
<a href="#" @click="selected_property = p">
|
||||||
|
<i v-if="p.missing_value" class="text-warning fas fa-exclamation-triangle"></i>
|
||||||
|
<i v-if="!p.missing_value" class="text-muted fas fa-info-circle"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<b-modal id="id_modal_property_overview" title="Property Overview" v-model="show_modal"
|
||||||
|
@hidden="selected_property = undefined">
|
||||||
|
<template v-if="selected_property !== undefined">
|
||||||
|
{{ selected_property.description }}
|
||||||
|
<table class="table table-bordered">
|
||||||
|
<tr v-for="f in selected_property.food_values"
|
||||||
|
v-bind:key="`id_${selected_property.id}_food_${f.id}`">
|
||||||
|
<td><a href="#" @click="openFoodEditModal(f)">{{ f.food }}</a></td>
|
||||||
|
<td>{{ f.value }} {{ selected_property.unit }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</b-modal>
|
||||||
|
|
||||||
|
<generic-modal-form
|
||||||
|
:model="Models.FOOD"
|
||||||
|
:action="Actions.UPDATE"
|
||||||
|
:item1="selected_food"
|
||||||
|
:show="show_food_edit_modal"
|
||||||
|
@hidden="foodEditorHidden"
|
||||||
|
>
|
||||||
|
</generic-modal-form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {ApiMixin, StandardToasts} from "@/utils/utils";
|
||||||
|
import GenericModalForm from "@/components/Modals/GenericModalForm.vue";
|
||||||
|
import {ApiApiFactory} from "@/utils/openapi/api";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "PropertyViewComponent",
|
||||||
|
mixins: [ApiMixin],
|
||||||
|
components: {GenericModalForm},
|
||||||
|
props: {
|
||||||
|
recipe: Object,
|
||||||
|
servings: Number,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selected_property: undefined,
|
||||||
|
selected_food: undefined,
|
||||||
|
show_food_edit_modal: false,
|
||||||
|
show_total: false,
|
||||||
|
show_recipe_properties: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show_modal: function () {
|
||||||
|
return this.selected_property !== undefined
|
||||||
|
},
|
||||||
|
hasRecipeProperties: function () {
|
||||||
|
return this.recipe.properties.length !== 0
|
||||||
|
},
|
||||||
|
hasFoodProperties: function () {
|
||||||
|
let has_food_properties = false
|
||||||
|
for (const [key, fp] of Object.entries(this.recipe.food_properties)) {
|
||||||
|
if (fp.total_value !== 0) {
|
||||||
|
has_food_properties = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return has_food_properties
|
||||||
|
},
|
||||||
|
property_list: function () {
|
||||||
|
let pt_list = []
|
||||||
|
if (this.show_recipe_properties) {
|
||||||
|
this.recipe.properties.forEach(rp => {
|
||||||
|
pt_list.push(
|
||||||
|
{
|
||||||
|
'id': rp.property_type.id,
|
||||||
|
'name': rp.property_type.name,
|
||||||
|
'description': rp.property_type.description,
|
||||||
|
'icon': rp.property_type.icon,
|
||||||
|
'food_values': [],
|
||||||
|
'property_amount': rp.property_amount,
|
||||||
|
'missing_value': false,
|
||||||
|
'unit': rp.property_type.unit,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
for (const [key, fp] of Object.entries(this.recipe.food_properties)) {
|
||||||
|
pt_list.push(
|
||||||
|
{
|
||||||
|
'id': fp.id,
|
||||||
|
'name': fp.name,
|
||||||
|
'description': fp.description,
|
||||||
|
'icon': fp.icon,
|
||||||
|
'food_values': fp.food_values,
|
||||||
|
'property_amount': fp.total_value,
|
||||||
|
'missing_value': fp.missing_value,
|
||||||
|
'unit': fp.unit,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pt_list
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
if (this.hasRecipeProperties && !this.hasFoodProperties) {
|
||||||
|
this.show_recipe_properties = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
get_amount: function (amount) {
|
||||||
|
if (this.show_total) {
|
||||||
|
return (amount * (this.servings / this.recipe.servings)).toLocaleString(window.CUSTOM_LOCALE, {
|
||||||
|
'maximumFractionDigits': 2,
|
||||||
|
'minimumFractionDigits': 2
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return (amount / this.recipe.servings).toLocaleString(window.CUSTOM_LOCALE, {
|
||||||
|
'maximumFractionDigits': 2,
|
||||||
|
'minimumFractionDigits': 2
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openFoodEditModal: function (food) {
|
||||||
|
console.log(food)
|
||||||
|
let apiClient = ApiApiFactory()
|
||||||
|
apiClient.retrieveFood(food.id).then(r => {
|
||||||
|
this.selected_food = r.data;
|
||||||
|
this.show_food_edit_modal = true
|
||||||
|
}).catch(err => {
|
||||||
|
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
foodEditorHidden: function () {
|
||||||
|
this.show_food_edit_modal = false;
|
||||||
|
this.$emit("foodUpdated", "")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
@ -14,6 +14,7 @@
|
|||||||
"success_moving_resource": "Successfully moved a resource!",
|
"success_moving_resource": "Successfully moved a resource!",
|
||||||
"success_merging_resource": "Successfully merged a resource!",
|
"success_merging_resource": "Successfully merged a resource!",
|
||||||
"file_upload_disabled": "File upload is not enabled for your space.",
|
"file_upload_disabled": "File upload is not enabled for your space.",
|
||||||
|
"recipe_property_info": "You can also add properties to foods to calculate them automatically based on your recipe!",
|
||||||
"warning_space_delete": "You can delete your space including all recipes, shopping lists, meal plans and whatever else you have created. This cannot be undone! Are you sure you want to do this ?",
|
"warning_space_delete": "You can delete your space including all recipes, shopping lists, meal plans and whatever else you have created. This cannot be undone! Are you sure you want to do this ?",
|
||||||
"food_inherit_info": "Fields on food that should be inherited by default.",
|
"food_inherit_info": "Fields on food that should be inherited by default.",
|
||||||
"facet_count_info": "Show recipe counts on search filters.",
|
"facet_count_info": "Show recipe counts on search filters.",
|
||||||
@ -37,6 +38,7 @@
|
|||||||
"Add_nutrition_recipe": "Add nutrition to recipe",
|
"Add_nutrition_recipe": "Add nutrition to recipe",
|
||||||
"Remove_nutrition_recipe": "Delete nutrition from recipe",
|
"Remove_nutrition_recipe": "Delete nutrition from recipe",
|
||||||
"Copy_template_reference": "Copy template reference",
|
"Copy_template_reference": "Copy template reference",
|
||||||
|
"per_serving": "per servings",
|
||||||
"Save_and_View": "Save & View",
|
"Save_and_View": "Save & View",
|
||||||
"Manage_Books": "Manage Books",
|
"Manage_Books": "Manage Books",
|
||||||
"Meal_Plan": "Meal Plan",
|
"Meal_Plan": "Meal Plan",
|
||||||
@ -76,6 +78,19 @@
|
|||||||
"Private_Recipe": "Private Recipe",
|
"Private_Recipe": "Private Recipe",
|
||||||
"Private_Recipe_Help": "Recipe is only shown to you and people its shared with.",
|
"Private_Recipe_Help": "Recipe is only shown to you and people its shared with.",
|
||||||
"reusable_help_text": "Should the invite link be usable for more than one user.",
|
"reusable_help_text": "Should the invite link be usable for more than one user.",
|
||||||
|
"open_data_help_text": "The Tandoor Open Data project provides community contributed data for Tandoor. This field is filled automatically when importing it and allows updates in the future.",
|
||||||
|
"Open_Data_Slug": "Open Data Slug",
|
||||||
|
"Open_Data_Import": "Open Data Import",
|
||||||
|
"Data_Import_Info": "Enhance your Space by importing a community curated list of foods, units and more to improve your recipe collection.",
|
||||||
|
"Update_Existing_Data": "Update Existing Data",
|
||||||
|
"Use_Metric": "Use Metric Units",
|
||||||
|
"Learn_More": "Learn More",
|
||||||
|
"converted_unit": "Converted Unit",
|
||||||
|
"converted_amount": "Converted Amount",
|
||||||
|
"base_unit": "Base Unit",
|
||||||
|
"base_amount": "Base Amount",
|
||||||
|
"Datatype": "Datatype",
|
||||||
|
"Number of Objects": "Number of Objects",
|
||||||
"Add_Step": "Add Step",
|
"Add_Step": "Add Step",
|
||||||
"Keywords": "Keywords",
|
"Keywords": "Keywords",
|
||||||
"Books": "Books",
|
"Books": "Books",
|
||||||
@ -161,6 +176,8 @@
|
|||||||
"merge_title": "Merge {type}",
|
"merge_title": "Merge {type}",
|
||||||
"move_title": "Move {type}",
|
"move_title": "Move {type}",
|
||||||
"Food": "Food",
|
"Food": "Food",
|
||||||
|
"Property": "Property",
|
||||||
|
"Conversion": "Conversion",
|
||||||
"Original_Text": "Original Text",
|
"Original_Text": "Original Text",
|
||||||
"Recipe_Book": "Recipe Book",
|
"Recipe_Book": "Recipe Book",
|
||||||
"del_confirmation_tree": "Are you sure that you want to delete {source} and all of it's children?",
|
"del_confirmation_tree": "Are you sure that you want to delete {source} and all of it's children?",
|
||||||
@ -168,6 +185,7 @@
|
|||||||
"create_title": "New {type}",
|
"create_title": "New {type}",
|
||||||
"edit_title": "Edit {type}",
|
"edit_title": "Edit {type}",
|
||||||
"Name": "Name",
|
"Name": "Name",
|
||||||
|
"Properties": "Properties",
|
||||||
"Type": "Type",
|
"Type": "Type",
|
||||||
"Description": "Description",
|
"Description": "Description",
|
||||||
"Recipe": "Recipe",
|
"Recipe": "Recipe",
|
||||||
@ -462,6 +480,7 @@
|
|||||||
"Import_Result_Info": "{imported} of {total} recipes were imported",
|
"Import_Result_Info": "{imported} of {total} recipes were imported",
|
||||||
"Recipes_In_Import": "Recipes in your import file",
|
"Recipes_In_Import": "Recipes in your import file",
|
||||||
"Toggle": "Toggle",
|
"Toggle": "Toggle",
|
||||||
|
"total": "total",
|
||||||
"Import_Error": "An Error occurred during your import. Please expand the Details at the bottom of the page to view it.",
|
"Import_Error": "An Error occurred during your import. Please expand the Details at the bottom of the page to view it.",
|
||||||
"Warning_Delete_Supermarket_Category": "Deleting a supermarket category will also delete all relations to foods. Are you sure?",
|
"Warning_Delete_Supermarket_Category": "Deleting a supermarket category will also delete all relations to foods. Are you sure?",
|
||||||
"New_Supermarket": "Create new supermarket",
|
"New_Supermarket": "Create new supermarket",
|
||||||
|
@ -91,11 +91,13 @@ export class Models {
|
|||||||
"substitute_children",
|
"substitute_children",
|
||||||
"reset_inherit",
|
"reset_inherit",
|
||||||
"child_inherit_fields",
|
"child_inherit_fields",
|
||||||
|
"open_data_slug",
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
form: {
|
form: {
|
||||||
show_help: true,
|
show_help: true,
|
||||||
|
component: "FoodEditor",
|
||||||
name: {
|
name: {
|
||||||
form_field: true,
|
form_field: true,
|
||||||
type: "text",
|
type: "text",
|
||||||
@ -126,6 +128,14 @@ export class Models {
|
|||||||
label: "Recipe", // form.label always translated in utils.getForm()
|
label: "Recipe", // form.label always translated in utils.getForm()
|
||||||
help_text: "food_recipe_help", // form.help_text always translated
|
help_text: "food_recipe_help", // form.help_text always translated
|
||||||
},
|
},
|
||||||
|
open_data_slug: {
|
||||||
|
form_field: true,
|
||||||
|
type: "text",
|
||||||
|
field: "open_data_slug",
|
||||||
|
disabled: true,
|
||||||
|
label: "Open_Data_Slug",
|
||||||
|
help_text: "open_data_help_text",
|
||||||
|
},
|
||||||
onhand: {
|
onhand: {
|
||||||
form_field: true,
|
form_field: true,
|
||||||
type: "checkbox",
|
type: "checkbox",
|
||||||
@ -269,8 +279,9 @@ export class Models {
|
|||||||
apiName: "Unit",
|
apiName: "Unit",
|
||||||
paginated: true,
|
paginated: true,
|
||||||
create: {
|
create: {
|
||||||
params: [["name", "plural_name", "description",]],
|
params: [["name", "plural_name", "description","open_data_slug",]],
|
||||||
form: {
|
form: {
|
||||||
|
show_help: true,
|
||||||
name: {
|
name: {
|
||||||
form_field: true,
|
form_field: true,
|
||||||
type: "text",
|
type: "text",
|
||||||
@ -292,6 +303,14 @@ export class Models {
|
|||||||
label: "Description",
|
label: "Description",
|
||||||
placeholder: "",
|
placeholder: "",
|
||||||
},
|
},
|
||||||
|
open_data_slug: {
|
||||||
|
form_field: true,
|
||||||
|
type: "text",
|
||||||
|
field: "open_data_slug",
|
||||||
|
disabled: true,
|
||||||
|
label: "Open_Data_Slug",
|
||||||
|
help_text: "open_data_help_text",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
merge: true,
|
merge: true,
|
||||||
@ -418,6 +437,7 @@ export class Models {
|
|||||||
create: {
|
create: {
|
||||||
params: [["name", "description", "category_to_supermarket"]],
|
params: [["name", "description", "category_to_supermarket"]],
|
||||||
form: {
|
form: {
|
||||||
|
show_help: true,
|
||||||
name: {
|
name: {
|
||||||
form_field: true,
|
form_field: true,
|
||||||
type: "text",
|
type: "text",
|
||||||
@ -442,6 +462,14 @@ export class Models {
|
|||||||
label: "Categories", // form.label always translated in utils.getForm()
|
label: "Categories", // form.label always translated in utils.getForm()
|
||||||
placeholder: "",
|
placeholder: "",
|
||||||
},
|
},
|
||||||
|
open_data_slug: {
|
||||||
|
form_field: true,
|
||||||
|
type: "text",
|
||||||
|
field: "open_data_slug",
|
||||||
|
disabled: true,
|
||||||
|
label: "Open_Data_Slug",
|
||||||
|
help_text: "open_data_help_text",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
function: "SupermarketWithCategories",
|
function: "SupermarketWithCategories",
|
||||||
@ -562,6 +590,129 @@ export class Models {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static UNIT_CONVERSION = {
|
||||||
|
name: "Unit Conversion",
|
||||||
|
apiName: "UnitConversion",
|
||||||
|
paginated: false,
|
||||||
|
list: {
|
||||||
|
header_component: {
|
||||||
|
name: "BetaWarning",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
params: [['base_amount', 'base_unit', 'converted_amount', 'converted_unit', 'food', 'open_data_slug']],
|
||||||
|
form: {
|
||||||
|
show_help: true,
|
||||||
|
// TODO add proper help texts for everything
|
||||||
|
base_amount: {
|
||||||
|
form_field: true,
|
||||||
|
type: "text",
|
||||||
|
field: "base_amount",
|
||||||
|
label: "base_amount",
|
||||||
|
placeholder: "",
|
||||||
|
},
|
||||||
|
base_unit: {
|
||||||
|
form_field: true,
|
||||||
|
type: "lookup",
|
||||||
|
field: "base_unit",
|
||||||
|
list: "UNIT",
|
||||||
|
list_label: "name",
|
||||||
|
label: "base_unit",
|
||||||
|
multiple: false,
|
||||||
|
},
|
||||||
|
converted_amount: {
|
||||||
|
form_field: true,
|
||||||
|
type: "text",
|
||||||
|
field: "converted_amount",
|
||||||
|
label: "converted_amount",
|
||||||
|
placeholder: "",
|
||||||
|
},
|
||||||
|
converted_unit: {
|
||||||
|
form_field: true,
|
||||||
|
type: "lookup",
|
||||||
|
field: "converted_unit",
|
||||||
|
list: "UNIT",
|
||||||
|
list_label: "name",
|
||||||
|
label: "converted_unit",
|
||||||
|
multiple: false,
|
||||||
|
},
|
||||||
|
food: {
|
||||||
|
form_field: true,
|
||||||
|
type: "lookup",
|
||||||
|
field: "food",
|
||||||
|
list: "FOOD",
|
||||||
|
list_label: "name",
|
||||||
|
label: "Food",
|
||||||
|
multiple: false,
|
||||||
|
},
|
||||||
|
open_data_slug: {
|
||||||
|
form_field: true,
|
||||||
|
type: "text",
|
||||||
|
field: "open_data_slug",
|
||||||
|
disabled: true,
|
||||||
|
label: "Open_Data_Slug",
|
||||||
|
help_text: "open_data_help_text",
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
static PROPERTY_TYPE = {
|
||||||
|
name: "Property Type",
|
||||||
|
apiName: "PropertyType",
|
||||||
|
paginated: false,
|
||||||
|
list: {
|
||||||
|
header_component: {
|
||||||
|
name: "BetaWarning",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
params: [['name', 'icon', 'unit', 'description']],
|
||||||
|
form: {
|
||||||
|
show_help: true,
|
||||||
|
name: {
|
||||||
|
form_field: true,
|
||||||
|
type: "text",
|
||||||
|
field: "name",
|
||||||
|
label: "Name",
|
||||||
|
placeholder: "",
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
form_field: true,
|
||||||
|
type: "emoji",
|
||||||
|
field: "icon",
|
||||||
|
label: "Icon",
|
||||||
|
placeholder: "",
|
||||||
|
},
|
||||||
|
unit: {
|
||||||
|
form_field: true,
|
||||||
|
type: "text",
|
||||||
|
field: "unit",
|
||||||
|
label: "Unit",
|
||||||
|
placeholder: "",
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
form_field: true,
|
||||||
|
type: "text",
|
||||||
|
field: "description",
|
||||||
|
label: "Description",
|
||||||
|
placeholder: "",
|
||||||
|
},
|
||||||
|
open_data_slug: {
|
||||||
|
form_field: true,
|
||||||
|
type: "text",
|
||||||
|
field: "open_data_slug",
|
||||||
|
disabled: true,
|
||||||
|
label: "Open_Data_Slug",
|
||||||
|
help_text: "open_data_help_text",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
static RECIPE = {
|
static RECIPE = {
|
||||||
name: "Recipe",
|
name: "Recipe",
|
||||||
apiName: "Recipe",
|
apiName: "Recipe",
|
||||||
|
File diff suppressed because it is too large
Load Diff
1783
vue/yarn.lock
1783
vue/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user