Merge remote-tracking branch 'origin/develop' into HomeAssistantConnector
# Conflicts: # cookbook/forms.py # requirements.txt
This commit is contained in:
commit
3e641e4d28
@ -10,7 +10,7 @@ from django_scopes import scopes_disabled
|
||||
from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField
|
||||
from hcaptcha.fields import hCaptchaField
|
||||
|
||||
from .models import Comment, Food, InviteLink, Keyword, Recipe, RecipeBook, RecipeBookEntry, SearchPreference, Space, Storage, Sync, User, UserPreference, ConnectorConfig
|
||||
from .models import Comment, InviteLink, Keyword, Recipe, SearchPreference, Space, Storage, Sync, User, UserPreference, ConnectorConfig
|
||||
|
||||
|
||||
class SelectWidget(widgets.Select):
|
||||
|
@ -1,5 +1,20 @@
|
||||
import traceback
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal
|
||||
|
||||
from cookbook.models import (Food, FoodProperty, Property, PropertyType, Supermarket,
|
||||
SupermarketCategory, SupermarketCategoryRelation, Unit, UnitConversion)
|
||||
import re
|
||||
|
||||
|
||||
class OpenDataImportResponse:
|
||||
total_created = 0
|
||||
total_updated = 0
|
||||
total_untouched = 0
|
||||
total_errored = 0
|
||||
|
||||
def to_dict(self):
|
||||
return {'total_created': self.total_created, 'total_updated': self.total_updated, 'total_untouched': self.total_untouched, 'total_errored': self.total_errored}
|
||||
|
||||
|
||||
class OpenDataImporter:
|
||||
@ -18,69 +33,269 @@ class OpenDataImporter:
|
||||
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 = 'unit'
|
||||
@staticmethod
|
||||
def _is_obj_identical(field_list, obj, existing_obj):
|
||||
"""
|
||||
checks if the obj meant for import is identical to an already existing one
|
||||
:param field_list: list of field names to check
|
||||
:type field_list: list[str]
|
||||
:param obj: object meant for import
|
||||
:type obj: Object
|
||||
:param existing_obj: object already in DB
|
||||
:type existing_obj: Object
|
||||
:return: if objects are identical
|
||||
:rtype: bool
|
||||
"""
|
||||
for field in field_list:
|
||||
if isinstance(getattr(obj, field), float) or isinstance(getattr(obj, field), Decimal):
|
||||
if abs(float(getattr(obj, field)) - float(existing_obj[field])) > 0.001: # convert both to float and check if basically equal
|
||||
print(f'comparing FLOAT {obj} failed because field {field} is not equal ({getattr(obj, field)} != {existing_obj[field]})')
|
||||
return False
|
||||
elif getattr(obj, field) != existing_obj[field]:
|
||||
print(f'comparing {obj} failed because field {field} is not equal ({getattr(obj, field)} != {existing_obj[field]})')
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _merge_if_conflicting(model_type, obj, existing_data_slugs, existing_data_names):
|
||||
"""
|
||||
sometimes there might be two objects conflicting for open data import (one has the slug, the other the name)
|
||||
this function checks if that is the case and merges the two objects if possible
|
||||
:param model_type: type of model to check/merge
|
||||
:type model_type: Model
|
||||
:param obj: object that should be created/updated
|
||||
:type obj: Model
|
||||
:param existing_data_slugs: dict of open data slugs mapped to objects
|
||||
:type existing_data_slugs: dict
|
||||
:param existing_data_names: dict of names mapped to objects
|
||||
:type existing_data_names: dict
|
||||
:return: true if merge was successful or not necessary else false
|
||||
:rtype: bool
|
||||
"""
|
||||
if obj.open_data_slug in existing_data_slugs and obj.name in existing_data_names and existing_data_slugs[obj.open_data_slug]['pk'] != existing_data_names[obj.name]['pk']:
|
||||
try:
|
||||
source_obj = model_type.objects.get(pk=existing_data_slugs[obj.open_data_slug]['pk'])
|
||||
del existing_data_slugs[obj.open_data_slug]
|
||||
source_obj.merge_into(model_type.objects.get(pk=existing_data_names[obj.name]['pk']))
|
||||
return True
|
||||
except RuntimeError:
|
||||
return False # in the edge case (e.g. parent/child) that an object cannot be merged don't update it for now
|
||||
else:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _get_existing_obj(obj, existing_data_slugs, existing_data_names):
|
||||
"""
|
||||
gets the existing object from slug or name cache
|
||||
:param obj: object that should be found
|
||||
:type obj: Model
|
||||
:param existing_data_slugs: dict of open data slugs mapped to objects
|
||||
:type existing_data_slugs: dict
|
||||
:param existing_data_names: dict of names mapped to objects
|
||||
:type existing_data_names: dict
|
||||
:return: existing object
|
||||
:rtype: dict
|
||||
"""
|
||||
existing_obj = None
|
||||
if obj.open_data_slug in existing_data_slugs:
|
||||
existing_obj = existing_data_slugs[obj.open_data_slug]
|
||||
elif obj.name in existing_data_names:
|
||||
existing_obj = existing_data_names[obj.name]
|
||||
|
||||
return existing_obj
|
||||
|
||||
def import_units(self):
|
||||
od_response = OpenDataImportResponse()
|
||||
datatype = 'unit'
|
||||
model_type = Unit
|
||||
field_list = ['name', 'plural_name', 'base_unit', 'open_data_slug']
|
||||
|
||||
existing_data_slugs = {}
|
||||
existing_data_names = {}
|
||||
for obj in model_type.objects.filter(space=self.request.space).values('pk', *field_list):
|
||||
existing_data_slugs[obj['open_data_slug']] = obj
|
||||
existing_data_names[obj['name']] = obj
|
||||
|
||||
update_list = []
|
||||
create_list = []
|
||||
|
||||
insert_list = []
|
||||
for u in list(self.data[datatype].keys()):
|
||||
insert_list.append(Unit(
|
||||
obj = model_type(
|
||||
name=self.data[datatype][u]['name'],
|
||||
plural_name=self.data[datatype][u]['plural_name'],
|
||||
base_unit=self.data[datatype][u]['base_unit'] if self.data[datatype][u]['base_unit'] != '' else None,
|
||||
base_unit=self.data[datatype][u]['base_unit'].lower() if self.data[datatype][u]['base_unit'] != '' else None,
|
||||
open_data_slug=u,
|
||||
space=self.request.space
|
||||
))
|
||||
)
|
||||
if obj.open_data_slug in existing_data_slugs or obj.name in existing_data_names:
|
||||
if not self._merge_if_conflicting(model_type, obj, existing_data_slugs, existing_data_names):
|
||||
od_response.total_errored += 1
|
||||
continue # if conflicting objects exist and cannot be merged skip object
|
||||
|
||||
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',))
|
||||
existing_obj = self._get_existing_obj(obj, existing_data_slugs, existing_data_names)
|
||||
|
||||
if not self._is_obj_identical(field_list, obj, existing_obj):
|
||||
obj.pk = existing_obj['pk']
|
||||
update_list.append(obj)
|
||||
else:
|
||||
od_response.total_untouched += 1
|
||||
else:
|
||||
create_list.append(obj)
|
||||
|
||||
if self.update_existing and len(update_list) > 0:
|
||||
model_type.objects.bulk_update(update_list, field_list)
|
||||
od_response.total_updated += len(update_list)
|
||||
|
||||
if len(create_list) > 0:
|
||||
model_type.objects.bulk_create(create_list, update_conflicts=True, update_fields=field_list, unique_fields=('space', 'name',))
|
||||
od_response.total_created += len(create_list)
|
||||
|
||||
return od_response
|
||||
|
||||
def import_category(self):
|
||||
od_response = OpenDataImportResponse()
|
||||
datatype = 'category'
|
||||
model_type = SupermarketCategory
|
||||
field_list = ['name', 'open_data_slug']
|
||||
|
||||
existing_data_slugs = {}
|
||||
existing_data_names = {}
|
||||
for obj in model_type.objects.filter(space=self.request.space).values('pk', *field_list):
|
||||
existing_data_slugs[obj['open_data_slug']] = obj
|
||||
existing_data_names[obj['name']] = obj
|
||||
|
||||
update_list = []
|
||||
create_list = []
|
||||
|
||||
insert_list = []
|
||||
for k in list(self.data[datatype].keys()):
|
||||
insert_list.append(SupermarketCategory(
|
||||
obj = model_type(
|
||||
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',))
|
||||
if obj.open_data_slug in existing_data_slugs or obj.name in existing_data_names:
|
||||
if not self._merge_if_conflicting(model_type, obj, existing_data_slugs, existing_data_names):
|
||||
od_response.total_errored += 1
|
||||
continue # if conflicting objects exist and cannot be merged skip object
|
||||
|
||||
existing_obj = self._get_existing_obj(obj, existing_data_slugs, existing_data_names)
|
||||
|
||||
if not self._is_obj_identical(field_list, obj, existing_obj):
|
||||
obj.pk = existing_obj['pk']
|
||||
update_list.append(obj)
|
||||
else:
|
||||
od_response.total_untouched += 1
|
||||
else:
|
||||
create_list.append(obj)
|
||||
|
||||
if self.update_existing and len(update_list) > 0:
|
||||
model_type.objects.bulk_update(update_list, field_list)
|
||||
od_response.total_updated += len(update_list)
|
||||
|
||||
if len(create_list) > 0:
|
||||
model_type.objects.bulk_create(create_list, update_conflicts=True, update_fields=field_list, unique_fields=('space', 'name',))
|
||||
od_response.total_created += len(create_list)
|
||||
|
||||
return od_response
|
||||
|
||||
def import_property(self):
|
||||
od_response = OpenDataImportResponse()
|
||||
datatype = 'property'
|
||||
model_type = PropertyType
|
||||
field_list = ['name', 'unit', 'fdc_id', 'open_data_slug']
|
||||
|
||||
existing_data_slugs = {}
|
||||
existing_data_names = {}
|
||||
for obj in model_type.objects.filter(space=self.request.space).values('pk', *field_list):
|
||||
existing_data_slugs[obj['open_data_slug']] = obj
|
||||
existing_data_names[obj['name']] = obj
|
||||
|
||||
update_list = []
|
||||
create_list = []
|
||||
|
||||
insert_list = []
|
||||
for k in list(self.data[datatype].keys()):
|
||||
insert_list.append(PropertyType(
|
||||
obj = model_type(
|
||||
name=self.data[datatype][k]['name'],
|
||||
unit=self.data[datatype][k]['unit'],
|
||||
fdc_id=self.data[datatype][k]['fdc_id'],
|
||||
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',))
|
||||
if obj.open_data_slug in existing_data_slugs or obj.name in existing_data_names:
|
||||
if not self._merge_if_conflicting(model_type, obj, existing_data_slugs, existing_data_names):
|
||||
od_response.total_errored += 1
|
||||
continue # if conflicting objects exist and cannot be merged skip object
|
||||
|
||||
existing_obj = self._get_existing_obj(obj, existing_data_slugs, existing_data_names)
|
||||
|
||||
if not self._is_obj_identical(field_list, obj, existing_obj):
|
||||
obj.pk = existing_obj['pk']
|
||||
update_list.append(obj)
|
||||
else:
|
||||
od_response.total_untouched += 1
|
||||
else:
|
||||
create_list.append(obj)
|
||||
|
||||
if self.update_existing and len(update_list) > 0:
|
||||
model_type.objects.bulk_update(update_list, field_list)
|
||||
od_response.total_updated += len(update_list)
|
||||
|
||||
if len(create_list) > 0:
|
||||
model_type.objects.bulk_create(create_list, update_conflicts=True, update_fields=field_list, unique_fields=('space', 'name',))
|
||||
od_response.total_created += len(create_list)
|
||||
|
||||
return od_response
|
||||
|
||||
def import_supermarket(self):
|
||||
od_response = OpenDataImportResponse()
|
||||
datatype = 'store'
|
||||
model_type = Supermarket
|
||||
field_list = ['name', 'open_data_slug']
|
||||
|
||||
existing_data_slugs = {}
|
||||
existing_data_names = {}
|
||||
for obj in model_type.objects.filter(space=self.request.space).values('pk', *field_list):
|
||||
existing_data_slugs[obj['open_data_slug']] = obj
|
||||
existing_data_names[obj['name']] = obj
|
||||
|
||||
update_list = []
|
||||
create_list = []
|
||||
|
||||
self._update_slug_cache(SupermarketCategory, 'category')
|
||||
insert_list = []
|
||||
for k in list(self.data[datatype].keys()):
|
||||
insert_list.append(Supermarket(
|
||||
obj = model_type(
|
||||
name=self.data[datatype][k]['name'],
|
||||
open_data_slug=k,
|
||||
space=self.request.space
|
||||
))
|
||||
)
|
||||
if obj.open_data_slug in existing_data_slugs or obj.name in existing_data_names:
|
||||
if not self._merge_if_conflicting(model_type, obj, existing_data_slugs, existing_data_names):
|
||||
od_response.total_errored += 1
|
||||
continue # if conflicting objects exist and cannot be merged skip object
|
||||
|
||||
existing_obj = self._get_existing_obj(obj, existing_data_slugs, existing_data_names)
|
||||
|
||||
if not self._is_obj_identical(field_list, obj, existing_obj):
|
||||
obj.pk = existing_obj['pk']
|
||||
update_list.append(obj)
|
||||
else:
|
||||
od_response.total_untouched += 1
|
||||
else:
|
||||
create_list.append(obj)
|
||||
|
||||
if self.update_existing and len(update_list) > 0:
|
||||
model_type.objects.bulk_update(update_list, field_list)
|
||||
od_response.total_updated += len(update_list)
|
||||
|
||||
if len(create_list) > 0:
|
||||
model_type.objects.bulk_create(create_list, update_conflicts=True, update_fields=field_list, unique_fields=('space', 'name',))
|
||||
od_response.total_created += len(create_list)
|
||||
|
||||
# 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, 'store')
|
||||
|
||||
insert_list = []
|
||||
for k in list(self.data[datatype].keys()):
|
||||
relations = []
|
||||
order = 0
|
||||
@ -96,115 +311,186 @@ class OpenDataImporter:
|
||||
|
||||
SupermarketCategoryRelation.objects.bulk_create(relations, ignore_conflicts=True, unique_fields=('supermarket', 'category',))
|
||||
|
||||
return supermarkets
|
||||
return od_response
|
||||
|
||||
def import_food(self):
|
||||
identifier_list = []
|
||||
od_response = OpenDataImportResponse()
|
||||
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'])
|
||||
model_type = Food
|
||||
field_list = ['name', 'open_data_slug']
|
||||
|
||||
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
|
||||
existing_data_slugs = {}
|
||||
existing_data_names = {}
|
||||
for obj in model_type.objects.filter(space=self.request.space).values('pk', *field_list):
|
||||
existing_data_slugs[obj['open_data_slug']] = obj
|
||||
existing_data_names[obj['name']] = obj
|
||||
|
||||
update_list = []
|
||||
create_list = []
|
||||
|
||||
self._update_slug_cache(Unit, 'unit')
|
||||
self._update_slug_cache(PropertyType, 'property')
|
||||
self._update_slug_cache(SupermarketCategory, 'category')
|
||||
|
||||
unit_g = Unit.objects.filter(space=self.request.space, base_unit__iexact='g').first()
|
||||
|
||||
insert_list = []
|
||||
insert_list_flat = []
|
||||
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):
|
||||
if not (self.data[datatype][k]['name'] in insert_list_flat or self.data[datatype][k]['plural_name'] in insert_list_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,
|
||||
'supermarket_category_id': self.slug_id_cache['category'][self.data[datatype][k]['store_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,
|
||||
}})
|
||||
# build a fake second flat array to prevent duplicate foods from being inserted.
|
||||
# trying to insert a duplicate would throw a db error :(
|
||||
insert_list_flat.append(self.data[datatype][k]['name'])
|
||||
insert_list_flat.append(self.data[datatype][k]['plural_name'])
|
||||
|
||||
obj_dict = {
|
||||
'name': self.data[datatype][k]['name'],
|
||||
'plural_name': self.data[datatype][k]['plural_name'] if self.data[datatype][k]['plural_name'] != '' else None,
|
||||
'supermarket_category_id': self.slug_id_cache['category'][self.data[datatype][k]['store_category']],
|
||||
'fdc_id': re.sub(r'\D', '', self.data[datatype][k]['fdc_id']) if self.data[datatype][k]['fdc_id'] != '' else None,
|
||||
'open_data_slug': k,
|
||||
'properties_food_unit_id': None,
|
||||
'space_id': self.request.space.id,
|
||||
}
|
||||
|
||||
if unit_g:
|
||||
obj_dict['properties_food_unit_id'] = unit_g.id
|
||||
|
||||
obj = model_type(**obj_dict)
|
||||
|
||||
if obj.open_data_slug in existing_data_slugs or obj.name in existing_data_names:
|
||||
if not self._merge_if_conflicting(model_type, obj, existing_data_slugs, existing_data_names):
|
||||
od_response.total_errored += 1
|
||||
continue # if conflicting objects exist and cannot be merged skip object
|
||||
|
||||
existing_obj = self._get_existing_obj(obj, existing_data_slugs, existing_data_names)
|
||||
|
||||
if not self._is_obj_identical(field_list, obj, existing_obj):
|
||||
obj.pk = existing_obj['pk']
|
||||
update_list.append(obj)
|
||||
else:
|
||||
od_response.total_untouched += 1
|
||||
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]
|
||||
create_list.append({'data': obj_dict})
|
||||
|
||||
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,
|
||||
supermarket_category_id=self.slug_id_cache['category'][self.data[datatype][k]['store_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, ))
|
||||
if self.update_existing and len(update_list) > 0:
|
||||
model_type.objects.bulk_update(update_list, field_list)
|
||||
od_response.total_updated += len(update_list)
|
||||
|
||||
Food.load_bulk(insert_list, None)
|
||||
if len(update_list) > 0:
|
||||
Food.objects.bulk_update(update_list, update_field_list)
|
||||
if len(create_list) > 0:
|
||||
Food.load_bulk(create_list, None)
|
||||
od_response.total_created += len(create_list)
|
||||
|
||||
# --------------- PROPERTY STUFF -----------------------
|
||||
model_type = Property
|
||||
field_list = ['property_type_id', 'property_amount', 'open_data_food_slug']
|
||||
|
||||
existing_data_slugs = {}
|
||||
existing_data_property_types = {}
|
||||
for obj in model_type.objects.filter(space=self.request.space).values('pk', *field_list):
|
||||
existing_data_slugs[obj['open_data_food_slug']] = obj
|
||||
existing_data_property_types[obj['property_type_id']] = obj
|
||||
|
||||
update_list = []
|
||||
create_list = []
|
||||
|
||||
self._update_slug_cache(Food, 'food')
|
||||
|
||||
food_property_list = []
|
||||
# alias_list = []
|
||||
for k in list(self.data['food'].keys()):
|
||||
for fp in self.data['food'][k]['properties']['type_values']:
|
||||
obj = model_type(
|
||||
property_type_id=self.slug_id_cache['property'][fp['property_type']],
|
||||
property_amount=fp['property_value'],
|
||||
open_data_food_slug=k,
|
||||
space=self.request.space,
|
||||
)
|
||||
|
||||
for k in list(self.data[datatype].keys()):
|
||||
for fp in self.data[datatype][k]['properties']['type_values']:
|
||||
# try catch here because somettimes key "k" is not set for he food cache
|
||||
try:
|
||||
food_property_list.append(Property(
|
||||
property_type_id=self.slug_id_cache['property'][fp['property_type']],
|
||||
property_amount=fp['property_value'],
|
||||
import_food_id=self.slug_id_cache['food'][k],
|
||||
space=self.request.space,
|
||||
))
|
||||
except KeyError:
|
||||
print(str(k) + ' is not in self.slug_id_cache["food"]')
|
||||
if obj.open_data_food_slug in existing_data_slugs and obj.property_type_id in existing_data_property_types and existing_data_slugs[obj.open_data_food_slug] == existing_data_property_types[obj.property_type_id]:
|
||||
existing_obj = existing_data_slugs[obj.open_data_food_slug]
|
||||
|
||||
Property.objects.bulk_create(food_property_list, ignore_conflicts=True, unique_fields=('space', 'import_food_id', 'property_type',))
|
||||
if not self._is_obj_identical(field_list, obj, existing_obj):
|
||||
obj.pk = existing_obj['pk']
|
||||
update_list.append(obj)
|
||||
else:
|
||||
create_list.append(obj)
|
||||
|
||||
if self.update_existing and len(update_list) > 0:
|
||||
model_type.objects.bulk_update(update_list, field_list)
|
||||
|
||||
if len(create_list) > 0:
|
||||
model_type.objects.bulk_create(create_list, ignore_conflicts=True, unique_fields=('space', 'open_data_food_slug', 'property_type',))
|
||||
|
||||
linked_properties = list(FoodProperty.objects.filter(food__space=self.request.space).values_list('property_id', flat=True).all())
|
||||
|
||||
property_food_relation_list = []
|
||||
for p in Property.objects.filter(space=self.request.space, import_food_id__isnull=False).values_list('import_food_id', 'id', ):
|
||||
property_food_relation_list.append(Food.properties.through(food_id=p[0], property_id=p[1]))
|
||||
for p in model_type.objects.filter(space=self.request.space, open_data_food_slug__isnull=False).values_list('open_data_food_slug', 'id', ):
|
||||
if p[1] == 147:
|
||||
pass
|
||||
# slug_id_cache should always exist, don't create relations for already linked properties (ignore_conflicts would do that as well but this is more performant)
|
||||
if p[0] in self.slug_id_cache['food'] and p[1] not in linked_properties:
|
||||
property_food_relation_list.append(Food.properties.through(food_id=self.slug_id_cache['food'][p[0]], property_id=p[1]))
|
||||
|
||||
FoodProperty.objects.bulk_create(property_food_relation_list, ignore_conflicts=True, unique_fields=('food_id', 'property_id',))
|
||||
FoodProperty.objects.bulk_create(property_food_relation_list, unique_fields=('food_id', 'property_id',))
|
||||
|
||||
return insert_list + update_list
|
||||
return od_response
|
||||
|
||||
def import_conversion(self):
|
||||
od_response = OpenDataImportResponse()
|
||||
datatype = 'conversion'
|
||||
model_type = UnitConversion
|
||||
field_list = ['base_amount', 'base_unit_id', 'converted_amount', 'converted_unit_id', 'food_id', 'open_data_slug']
|
||||
|
||||
self._update_slug_cache(Food, 'food')
|
||||
self._update_slug_cache(Unit, 'unit')
|
||||
|
||||
existing_data_slugs = {}
|
||||
existing_data_foods = defaultdict(list)
|
||||
for obj in model_type.objects.filter(space=self.request.space).values('pk', *field_list):
|
||||
existing_data_slugs[obj['open_data_slug']] = obj
|
||||
existing_data_foods[obj['food_id']].append(obj)
|
||||
|
||||
update_list = []
|
||||
create_list = []
|
||||
|
||||
insert_list = []
|
||||
for k in list(self.data[datatype].keys()):
|
||||
# try catch here because sometimes key "k" is not set for he food cache
|
||||
# try catch here because sometimes key "k" is not set for the food cache
|
||||
try:
|
||||
insert_list.append(UnitConversion(
|
||||
base_amount=self.data[datatype][k]['base_amount'],
|
||||
obj = model_type(
|
||||
base_amount=Decimal(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_amount=Decimal(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,
|
||||
))
|
||||
except KeyError:
|
||||
print(str(k) + ' is not in self.slug_id_cache["food"]')
|
||||
created_by_id=self.request.user.id,
|
||||
)
|
||||
|
||||
return UnitConversion.objects.bulk_create(insert_list, ignore_conflicts=True, unique_fields=('space', 'base_unit', 'converted_unit', 'food', 'open_data_slug'))
|
||||
if obj.open_data_slug in existing_data_slugs:
|
||||
existing_obj = existing_data_slugs[obj.open_data_slug]
|
||||
if not self._is_obj_identical(field_list, obj, existing_obj):
|
||||
obj.pk = existing_obj['pk']
|
||||
update_list.append(obj)
|
||||
else:
|
||||
od_response.total_untouched += 1
|
||||
else:
|
||||
matching_existing_found = False
|
||||
if obj.food_id in existing_data_foods:
|
||||
for edf in existing_data_foods[obj.food_id]:
|
||||
if obj.base_unit_id == edf['base_unit_id'] and obj.converted_unit_id == edf['converted_unit_id']:
|
||||
matching_existing_found = True
|
||||
if not self._is_obj_identical(field_list, obj, edf):
|
||||
obj.pk = edf['pk']
|
||||
update_list.append(obj)
|
||||
else:
|
||||
od_response.total_untouched += 1
|
||||
if not matching_existing_found:
|
||||
create_list.append(obj)
|
||||
except KeyError as e:
|
||||
traceback.print_exc()
|
||||
od_response.total_errored += 1
|
||||
print(self.data[datatype][k]['food'] + ' is not in self.slug_id_cache["food"]')
|
||||
|
||||
if self.update_existing and len(update_list) > 0:
|
||||
od_response.total_updated = model_type.objects.bulk_update(update_list, field_list)
|
||||
od_response.total_errored += len(update_list) - od_response.total_updated
|
||||
|
||||
if len(create_list) > 0:
|
||||
objs_created = model_type.objects.bulk_create(create_list, ignore_conflicts=True, unique_fields=('space', 'base_unit', 'converted_unit', 'food', 'open_data_slug'))
|
||||
od_response.total_created = len(objs_created)
|
||||
od_response.total_errored += len(create_list) - od_response.total_created
|
||||
|
||||
return od_response
|
||||
|
@ -45,12 +45,12 @@ class FoodPropertyHelper:
|
||||
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]['food_values'][i.food.id] = {'id': i.food.id, 'food': i.food.name, 'value': 0}
|
||||
computed_properties[pt.id]['missing_value'] = i.food.properties_food_unit is None
|
||||
if i.food.properties_food_amount == 0 or i.food.properties_food_unit is None: # if food is configured incorrectly
|
||||
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': i.food.name, 'value': None}
|
||||
computed_properties[pt.id]['missing_value'] = True
|
||||
else:
|
||||
for p in i.food.properties.all():
|
||||
if p.property_type == pt:
|
||||
if p.property_type == pt and p.property_amount is not None:
|
||||
for c in conversions:
|
||||
if c.unit == i.food.properties_food_unit:
|
||||
found_property = True
|
||||
@ -58,13 +58,17 @@ class FoodPropertyHelper:
|
||||
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}
|
||||
if i.amount == 0: # don't count ingredients without an amount as missing
|
||||
computed_properties[pt.id]['missing_value'] = computed_properties[pt.id]['missing_value'] or False # don't override if another food was already missing
|
||||
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': i.food.name, 'value': 0}
|
||||
else:
|
||||
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': None}
|
||||
|
||||
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 ?
|
||||
# TODO move to central helper ? --> use defaultdict
|
||||
@staticmethod
|
||||
def add_or_create(d, key, value, food):
|
||||
if key in d:
|
||||
|
@ -231,7 +231,7 @@ def get_recipe_properties(space, property_data):
|
||||
'id': pt.id,
|
||||
'name': pt.name,
|
||||
},
|
||||
'property_amount': parse_servings(property_data[properties[p]]) / float(property_data['servingSize']),
|
||||
'property_amount': parse_servings(property_data[properties[p]]) / parse_servings(property_data['servingSize']),
|
||||
})
|
||||
|
||||
return recipe_properties
|
||||
|
@ -1,6 +1,6 @@
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from xml import etree
|
||||
from lxml import etree
|
||||
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text
|
||||
@ -53,7 +53,10 @@ class Rezeptsuitede(Integration):
|
||||
u = ingredient_parser.get_unit(ingredient.attrib['unit'])
|
||||
amount = 0
|
||||
if ingredient.attrib['qty'].strip() != '':
|
||||
amount, unit, note = ingredient_parser.parse_amount(ingredient.attrib['qty'])
|
||||
try:
|
||||
amount, unit, note = ingredient_parser.parse_amount(ingredient.attrib['qty'])
|
||||
except ValueError: # sometimes quantities contain words which cant be parsed
|
||||
pass
|
||||
ingredient_step.ingredients.add(Ingredient.objects.create(food=f, unit=u, amount=amount, space=self.request.space, ))
|
||||
|
||||
try:
|
||||
|
@ -12,8 +12,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-05-18 14:28+0200\n"
|
||||
"PO-Revision-Date: 2024-02-01 17:22+0000\n"
|
||||
"Last-Translator: Lorenzo <gerosa.lorenzo.gl@gmail.com>\n"
|
||||
"PO-Revision-Date: 2024-02-17 19:16+0000\n"
|
||||
"Last-Translator: Andrea <giovannibecco@mailo.com>\n"
|
||||
"Language-Team: Italian <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/it/>\n"
|
||||
"Language: it\n"
|
||||
@ -2093,7 +2093,7 @@ msgstr "Proprietario"
|
||||
#, fuzzy
|
||||
#| msgid "Create Space"
|
||||
msgid "Leave Space"
|
||||
msgstr "Crea Istanza"
|
||||
msgstr "Lascia Istanza"
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:78
|
||||
#: .\cookbook\templates\space_overview.html:88
|
||||
|
34
cookbook/management/commands/fix_duplicate_properties.py
Normal file
34
cookbook/management/commands/fix_duplicate_properties.py
Normal file
@ -0,0 +1,34 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.search import SearchVector
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Count
|
||||
from django.utils import translation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.managers import DICTIONARY
|
||||
from cookbook.models import Recipe, Step, FoodProperty, Food
|
||||
|
||||
|
||||
# can be executed at the command line with 'python manage.py rebuildindex'
|
||||
class Command(BaseCommand):
|
||||
help = _('Fixes foods with ')
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('-d', '--dry-run', help='does not delete properties but instead prints them', action='store_true')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
with scopes_disabled():
|
||||
foods_with_duplicate_properties = Food.objects.annotate(property_type_count=Count('foodproperty__property__property_type') - Count('foodproperty__property__property_type', distinct=True)).filter(property_type_count__gt=0).all()
|
||||
for f in foods_with_duplicate_properties:
|
||||
found_property_types = []
|
||||
for fp in f.properties.all():
|
||||
if fp.property_type.id in found_property_types:
|
||||
if options['dry_run']:
|
||||
print(f'Property id {fp.id} duplicate type {fp.property_type}({fp.property_type.id}) for food {f}({f.id})')
|
||||
else:
|
||||
print(f'DELETING property id {fp.id} duplicate type {fp.property_type}({fp.property_type.id}) for food {f}({f.id})')
|
||||
fp.delete()
|
||||
|
||||
else:
|
||||
found_property_types.append(fp.property_type.id)
|
18
cookbook/migrations/0211_recipebook_order.py
Normal file
18
cookbook/migrations/0211_recipebook_order.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.7 on 2024-02-16 19:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0210_shoppinglistentry_updated_at'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='recipebook',
|
||||
name='order',
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
]
|
18
cookbook/migrations/0212_alter_property_property_amount.py
Normal file
18
cookbook/migrations/0212_alter_property_property_amount.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.7 on 2024-02-18 07:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0211_recipebook_order'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='property',
|
||||
name='property_amount',
|
||||
field=models.DecimalField(decimal_places=4, default=None, max_digits=32, null=True),
|
||||
),
|
||||
]
|
@ -0,0 +1,54 @@
|
||||
# Generated by Django 4.2.10 on 2024-02-19 13:48
|
||||
|
||||
from django.db import migrations, models
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def migrate_property_import_slug(apps, schema_editor):
|
||||
with scopes_disabled():
|
||||
Property = apps.get_model('cookbook', 'Property')
|
||||
Food = apps.get_model('cookbook', 'Food')
|
||||
|
||||
id_slug_mapping = {}
|
||||
with scopes_disabled():
|
||||
for f in Food.objects.filter(open_data_slug__isnull=False).values('id', 'open_data_slug').all():
|
||||
id_slug_mapping[f['id']] = f['open_data_slug']
|
||||
|
||||
property_update_list = []
|
||||
|
||||
for p in Property.objects.filter().values('id', 'import_food_id').all():
|
||||
if p['import_food_id'] in id_slug_mapping:
|
||||
property_update_list.append(Property(
|
||||
id=p['id'],
|
||||
open_data_food_slug=id_slug_mapping[p['import_food_id']]
|
||||
))
|
||||
|
||||
Property.objects.bulk_update(property_update_list, ('open_data_food_slug',))
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('cookbook', '0212_alter_property_property_amount'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='property',
|
||||
name='open_data_food_slug',
|
||||
field=models.CharField(blank=True, default=None, max_length=128, null=True),
|
||||
),
|
||||
migrations.RunPython(migrate_property_import_slug),
|
||||
migrations.RemoveConstraint(
|
||||
model_name='property',
|
||||
name='property_unique_import_food_per_space',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='property',
|
||||
name='import_food_id',
|
||||
),
|
||||
|
||||
migrations.AddConstraint(
|
||||
model_name='property',
|
||||
constraint=models.UniqueConstraint(fields=('space', 'property_type', 'open_data_food_slug'), name='property_unique_import_food_per_space'),
|
||||
),
|
||||
]
|
@ -209,6 +209,27 @@ class TreeModel(MP_Node):
|
||||
abstract = True
|
||||
|
||||
|
||||
class MergeModelMixin:
|
||||
|
||||
def merge_into(self, target):
|
||||
"""
|
||||
very simple merge function that replaces the current instance with the target instance
|
||||
:param target: target object
|
||||
:return: target with data merged
|
||||
"""
|
||||
|
||||
if self == target:
|
||||
raise ValueError('Cannot merge an object with itself')
|
||||
|
||||
if getattr(self, 'space', 0) != getattr(target, 'space', 0):
|
||||
raise RuntimeError('Cannot merge objects from different spaces')
|
||||
|
||||
if hasattr(self, 'get_descendants_and_self') and target in callable(getattr(self, 'get_descendants_and_self')):
|
||||
raise RuntimeError('Cannot merge parent (source) with child (target) object')
|
||||
|
||||
# TODO copy field values
|
||||
|
||||
|
||||
class PermissionModelMixin:
|
||||
@staticmethod
|
||||
def get_space_key():
|
||||
@ -320,10 +341,18 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
|
||||
BookmarkletImport.objects.filter(space=self).delete()
|
||||
CustomFilter.objects.filter(space=self).delete()
|
||||
|
||||
Property.objects.filter(space=self).delete()
|
||||
PropertyType.objects.filter(space=self).delete()
|
||||
|
||||
Comment.objects.filter(recipe__space=self).delete()
|
||||
Keyword.objects.filter(space=self).delete()
|
||||
Ingredient.objects.filter(space=self).delete()
|
||||
Food.objects.filter(space=self).delete()
|
||||
Keyword.objects.filter(space=self).delete()
|
||||
|
||||
# delete food in batches because treabeard might fail to delete otherwise
|
||||
while Food.objects.filter(space=self).count() > 0:
|
||||
pks = Food.objects.filter(space=self).values_list('pk')[:200]
|
||||
Food.objects.filter(pk__in=pks).delete()
|
||||
|
||||
Unit.objects.filter(space=self).delete()
|
||||
Step.objects.filter(space=self).delete()
|
||||
NutritionInformation.objects.filter(space=self).delete()
|
||||
@ -348,9 +377,11 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
|
||||
SupermarketCategory.objects.filter(space=self).delete()
|
||||
Supermarket.objects.filter(space=self).delete()
|
||||
|
||||
InviteLink.objects.filter(space=self).delete()
|
||||
UserFile.objects.filter(space=self).delete()
|
||||
UserSpace.objects.filter(space=self).delete()
|
||||
Automation.objects.filter(space=self).delete()
|
||||
InviteLink.objects.filter(space=self).delete()
|
||||
TelegramBot.objects.filter(space=self).delete()
|
||||
self.delete()
|
||||
|
||||
def get_owner(self):
|
||||
@ -468,6 +499,7 @@ class UserPreference(models.Model, PermissionModelMixin):
|
||||
self.use_fractions = FRACTION_PREF_DEFAULT
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.user)
|
||||
|
||||
@ -527,7 +559,7 @@ class Sync(models.Model, PermissionModelMixin):
|
||||
return self.path
|
||||
|
||||
|
||||
class SupermarketCategory(models.Model, PermissionModelMixin):
|
||||
class SupermarketCategory(models.Model, PermissionModelMixin, MergeModelMixin):
|
||||
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
||||
description = models.TextField(blank=True, null=True)
|
||||
open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||
@ -538,6 +570,14 @@ class SupermarketCategory(models.Model, PermissionModelMixin):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def merge_into(self, target):
|
||||
super().merge_into(target)
|
||||
|
||||
Food.objects.filter(supermarket_category=self).update(supermarket_category=target)
|
||||
SupermarketCategoryRelation.objects.filter(category=self).update(category=target)
|
||||
self.delete()
|
||||
return target
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='smc_unique_name_per_space'),
|
||||
@ -612,7 +652,7 @@ class Keyword(ExportModelOperationsMixin('keyword'), TreeModel, PermissionModelM
|
||||
indexes = (Index(fields=['id', 'name']),)
|
||||
|
||||
|
||||
class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixin):
|
||||
class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixin, MergeModelMixin):
|
||||
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
||||
plural_name = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
@ -622,6 +662,17 @@ class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixi
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def merge_into(self, target):
|
||||
super().merge_into(target)
|
||||
|
||||
Ingredient.objects.filter(unit=self).update(unit=target)
|
||||
ShoppingListEntry.objects.filter(unit=self).update(unit=target)
|
||||
Food.objects.filter(properties_food_unit=self).update(properties_food_unit=target)
|
||||
Food.objects.filter(preferred_unit=self).update(preferred_unit=target)
|
||||
Food.objects.filter(preferred_shopping_unit=self).update(preferred_shopping_unit=target)
|
||||
self.delete()
|
||||
return target
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@ -670,6 +721,32 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def merge_into(self, target):
|
||||
"""
|
||||
very simple merge function that replaces the current food with the target food
|
||||
also replaces a few attributes on the target field if they were empty before
|
||||
:param target: target food object
|
||||
:return: target with data merged
|
||||
"""
|
||||
if self == target:
|
||||
raise ValueError('Cannot merge an object with itself')
|
||||
|
||||
if self.space != target.space:
|
||||
raise RuntimeError('Cannot merge objects from different spaces')
|
||||
|
||||
try:
|
||||
if target in self.get_descendants_and_self():
|
||||
raise RuntimeError('Cannot merge parent (source) with child (target) object')
|
||||
except AttributeError:
|
||||
pass # AttributeError is raised when the object is not a tree and thus does not have the get_descendants_and_self() function
|
||||
|
||||
self.properties.all().delete()
|
||||
self.properties.clear()
|
||||
Ingredient.objects.filter(food=self).update(food=target)
|
||||
ShoppingListEntry.objects.filter(food=self).update(food=target)
|
||||
self.delete()
|
||||
return target
|
||||
|
||||
def delete(self):
|
||||
if self.ingredient_set.all().exclude(step=None).count() > 0:
|
||||
raise ProtectedError(self.name + _(" is part of a recipe step and cannot be deleted"), self.ingredient_set.all().exclude(step=None))
|
||||
@ -827,7 +904,7 @@ class Step(ExportModelOperationsMixin('step'), models.Model, PermissionModelMixi
|
||||
indexes = (GinIndex(fields=["search_vector"]),)
|
||||
|
||||
|
||||
class PropertyType(models.Model, PermissionModelMixin):
|
||||
class PropertyType(models.Model, PermissionModelMixin, MergeModelMixin):
|
||||
NUTRITION = 'NUTRITION'
|
||||
ALLERGEN = 'ALLERGEN'
|
||||
PRICE = 'PRICE'
|
||||
@ -852,6 +929,13 @@ class PropertyType(models.Model, PermissionModelMixin):
|
||||
def __str__(self):
|
||||
return f'{self.name}'
|
||||
|
||||
def merge_into(self, target):
|
||||
super().merge_into(target)
|
||||
|
||||
Property.objects.filter(property_type=self).update(property_type=target)
|
||||
self.delete()
|
||||
return target
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='property_type_unique_name_per_space'),
|
||||
@ -861,10 +945,10 @@ class PropertyType(models.Model, PermissionModelMixin):
|
||||
|
||||
|
||||
class Property(models.Model, PermissionModelMixin):
|
||||
property_amount = models.DecimalField(default=0, decimal_places=4, max_digits=32)
|
||||
property_amount = models.DecimalField(default=None, null=True, decimal_places=4, max_digits=32)
|
||||
property_type = models.ForeignKey(PropertyType, on_delete=models.PROTECT)
|
||||
|
||||
import_food_id = models.IntegerField(null=True, blank=True) # field to hold food id when importing properties from the open data project
|
||||
open_data_food_slug = models.CharField(max_length=128, null=True, blank=True, default=None) # field to hold food id when importing properties from the open data project
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
@ -874,7 +958,7 @@ class Property(models.Model, PermissionModelMixin):
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['space', 'property_type', 'import_food_id'], name='property_unique_import_food_per_space')
|
||||
models.UniqueConstraint(fields=['space', 'property_type', 'open_data_food_slug'], name='property_unique_import_food_per_space')
|
||||
]
|
||||
|
||||
|
||||
@ -1011,7 +1095,6 @@ class RecipeBook(ExportModelOperationsMixin('book'), models.Model, PermissionMod
|
||||
filter = models.ForeignKey('cookbook.CustomFilter', null=True, blank=True, on_delete=models.SET_NULL)
|
||||
order = models.IntegerField(default=0)
|
||||
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
|
@ -44,12 +44,12 @@ class TreeSchema(AutoSchema):
|
||||
"name": 'root', "in": "query", "required": False,
|
||||
"description": 'Return first level children of {obj} with ID [int]. Integer 0 will return root {obj}s.'.format(
|
||||
obj=api_name),
|
||||
'schema': {'type': 'int', },
|
||||
'schema': {'type': 'integer', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'tree', "in": "query", "required": False,
|
||||
"description": 'Return all self and children of {} with ID [int].'.format(api_name),
|
||||
'schema': {'type': 'int', },
|
||||
'schema': {'type': 'integer', },
|
||||
})
|
||||
return parameters
|
||||
|
||||
|
@ -584,7 +584,7 @@ class PropertyTypeSerializer(OpenDataModelMixin, WritableNestedModelSerializer,
|
||||
|
||||
class PropertySerializer(UniqueFieldsMixin, WritableNestedModelSerializer):
|
||||
property_type = PropertyTypeSerializer()
|
||||
property_amount = CustomDecimalField()
|
||||
property_amount = CustomDecimalField(allow_null=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['space'] = self.context['request'].space
|
||||
|
@ -4,7 +4,7 @@
|
||||
{% load i18n %}
|
||||
{% load l10n %}
|
||||
|
||||
{% block title %}{% trans 'Search' %}{% endblock %}
|
||||
{% block title %}{% trans 'Space Management' %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
@ -117,6 +117,7 @@ def page_help(page_name):
|
||||
'edit_connector_config': 'https://docs.tandoor.dev/features/connectors/',
|
||||
'view_shopping': 'https://docs.tandoor.dev/features/shopping/',
|
||||
'view_import': 'https://docs.tandoor.dev/features/import_export/',
|
||||
'data_import_url': 'https://docs.tandoor.dev/features/import_export/',
|
||||
'view_export': 'https://docs.tandoor.dev/features/import_export/',
|
||||
'list_automation': 'https://docs.tandoor.dev/features/automation/',
|
||||
}
|
||||
|
@ -67,7 +67,7 @@ def test_food_property(space_1, space_2, u1_s1):
|
||||
|
||||
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
|
||||
assert property_values[property_fat.id]['food_values'][food_1.id]['value'] is None
|
||||
|
||||
print('\n----------- TEST PROPERTY - PROPERTY CALCULATION UNIT CONVERSION ---------------')
|
||||
uc1 = UnitConversion.objects.create(
|
||||
|
@ -262,7 +262,9 @@ class MergeMixin(ViewSetMixin):
|
||||
|
||||
try:
|
||||
if isinstance(source, Food):
|
||||
source.properties.remove()
|
||||
source.properties.all().delete()
|
||||
source.properties.clear()
|
||||
UnitConversion.objects.filter(food=source).delete()
|
||||
|
||||
for link in [field for field in source._meta.get_fields() if issubclass(type(field), ForeignObjectRel)]:
|
||||
linkManager = getattr(source, link.get_accessor_name())
|
||||
@ -564,11 +566,8 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
|
||||
shared_users = c
|
||||
else:
|
||||
try:
|
||||
shared_users = [x.id for x in list(self.request.user.get_shopping_share())] + [
|
||||
self.request.user.id]
|
||||
caches['default'].set(
|
||||
f'shopping_shared_users_{self.request.space.id}_{self.request.user.id}',
|
||||
shared_users, timeout=5 * 60)
|
||||
shared_users = [x.id for x in list(self.request.user.get_shopping_share())] + [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
|
||||
@ -616,11 +615,21 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
|
||||
if properties with a fdc_id already exist they will be overridden, if existing properties don't have a fdc_id they won't be changed
|
||||
"""
|
||||
food = self.get_object()
|
||||
if not food.fdc_id:
|
||||
return JsonResponse({'msg': 'Food has no FDC ID associated.'}, status=400,
|
||||
json_dumps_params={'indent': 4})
|
||||
|
||||
response = requests.get(f'https://api.nal.usda.gov/fdc/v1/food/{food.fdc_id}?api_key={FDC_API_KEY}')
|
||||
if response.status_code == 429:
|
||||
return JsonResponse({'msg', 'API Key Rate Limit reached/exceeded, see https://api.data.gov/docs/rate-limits/ for more information. Configure your key in Tandoor using environment FDC_API_KEY variable.'}, status=429,
|
||||
return JsonResponse({'msg': 'API Key Rate Limit reached/exceeded, see https://api.data.gov/docs/rate-limits/ for more information. Configure your key in Tandoor using environment FDC_API_KEY variable.'}, status=429,
|
||||
json_dumps_params={'indent': 4})
|
||||
if response.status_code != 200:
|
||||
return JsonResponse({'msg': f'Error while requesting FDC data using url https://api.nal.usda.gov/fdc/v1/food/{food.fdc_id}?api_key=****'}, status=response.status_code,
|
||||
json_dumps_params={'indent': 4})
|
||||
|
||||
food.properties_food_amount = 100
|
||||
food.properties_food_unit = Unit.objects.get_or_create(base_unit__iexact='g', space=self.request.space, defaults={'name': 'g', 'base_unit': 'g', 'space': self.request.space})[0]
|
||||
food.save()
|
||||
|
||||
try:
|
||||
data = json.loads(response.content)
|
||||
@ -634,23 +643,29 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
|
||||
|
||||
for pt in PropertyType.objects.filter(space=request.space, fdc_id__gte=0).all():
|
||||
if pt.fdc_id:
|
||||
property_found = False
|
||||
for fn in data['foodNutrients']:
|
||||
if fn['nutrient']['id'] == pt.fdc_id:
|
||||
property_found = True
|
||||
food_property_list.append(Property(
|
||||
property_type_id=pt.id,
|
||||
property_amount=round(fn['amount'], 2),
|
||||
import_food_id=food.id,
|
||||
property_amount=max(0, round(fn['amount'], 2)), # sometimes FDC might return negative values which make no sense, set to 0
|
||||
space=self.request.space,
|
||||
))
|
||||
if not property_found:
|
||||
food_property_list.append(Property(
|
||||
property_type_id=pt.id,
|
||||
property_amount=0, # if field not in FDC data the food does not have that property
|
||||
space=self.request.space,
|
||||
))
|
||||
|
||||
Property.objects.bulk_create(food_property_list, ignore_conflicts=True, unique_fields=('space', 'import_food_id', 'property_type',))
|
||||
properties = Property.objects.bulk_create(food_property_list, unique_fields=('space', 'property_type',))
|
||||
|
||||
property_food_relation_list = []
|
||||
for p in Property.objects.filter(space=self.request.space, import_food_id=food.id).values_list('import_food_id', 'id', ):
|
||||
property_food_relation_list.append(Food.properties.through(food_id=p[0], property_id=p[1]))
|
||||
for p in properties:
|
||||
property_food_relation_list.append(Food.properties.through(food_id=food.id, property_id=p.pk))
|
||||
|
||||
FoodProperty.objects.bulk_create(property_food_relation_list, ignore_conflicts=True, unique_fields=('food_id', 'property_id',))
|
||||
Property.objects.filter(space=self.request.space, import_food_id=food.id).update(import_food_id=None)
|
||||
|
||||
return self.retrieve(request, pk)
|
||||
except Exception:
|
||||
@ -680,7 +695,7 @@ class RecipeBookViewSet(viewsets.ModelViewSet, StandardFilterMixin):
|
||||
ordering = f"{'' if order_direction == 'asc' else '-'}{order_field}"
|
||||
|
||||
self.queryset = self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter(
|
||||
space=self.request.space).distinct().order_by(ordering)
|
||||
space=self.request.space).distinct().order_by(ordering)
|
||||
return super().get_queryset()
|
||||
|
||||
|
||||
@ -728,7 +743,7 @@ class MealPlanViewSet(viewsets.ModelViewSet):
|
||||
query_params = [
|
||||
QueryParam(name='from_date', description=_('Filter meal plans from date (inclusive) in the format of YYYY-MM-DD.'), qtype='string'),
|
||||
QueryParam(name='to_date', description=_('Filter meal plans to date (inclusive) in the format of YYYY-MM-DD.'), qtype='string'),
|
||||
QueryParam(name='meal_type', description=_('Filter meal plans with MealType ID. For multiple repeat parameter.'), qtype='int'),
|
||||
QueryParam(name='meal_type', description=_('Filter meal plans with MealType ID. For multiple repeat parameter.'), qtype='integer'),
|
||||
]
|
||||
schema = QueryParamAutoSchema()
|
||||
|
||||
@ -858,7 +873,7 @@ class StepViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
|
||||
pagination_class = DefaultPagination
|
||||
query_params = [
|
||||
QueryParam(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'), qtype='int'),
|
||||
QueryParam(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'), qtype='integer'),
|
||||
QueryParam(name='query', description=_('Query string matched (fuzzy) against object name.'), qtype='string'),
|
||||
]
|
||||
schema = QueryParamAutoSchema()
|
||||
@ -901,27 +916,27 @@ class RecipeViewSet(viewsets.ModelViewSet):
|
||||
|
||||
query_params = [
|
||||
QueryParam(name='query', description=_('Query string matched (fuzzy) against recipe name. In the future also fulltext search.')),
|
||||
QueryParam(name='keywords', description=_('ID of keyword a recipe should have. For multiple repeat parameter. Equivalent to keywords_or'), qtype='int'),
|
||||
QueryParam(name='keywords_or', description=_('Keyword IDs, repeat for multiple. Return recipes with any of the keywords'), qtype='int'),
|
||||
QueryParam(name='keywords_and', description=_('Keyword IDs, repeat for multiple. Return recipes with all of the keywords.'), qtype='int'),
|
||||
QueryParam(name='keywords_or_not', description=_('Keyword IDs, repeat for multiple. Exclude recipes with any of the keywords.'), qtype='int'),
|
||||
QueryParam(name='keywords_and_not', description=_('Keyword IDs, repeat for multiple. Exclude recipes with all of the keywords.'), qtype='int'),
|
||||
QueryParam(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'), qtype='int'),
|
||||
QueryParam(name='foods_or', description=_('Food IDs, repeat for multiple. Return recipes with any of the foods'), qtype='int'),
|
||||
QueryParam(name='foods_and', description=_('Food IDs, repeat for multiple. Return recipes with all of the foods.'), qtype='int'),
|
||||
QueryParam(name='foods_or_not', description=_('Food IDs, repeat for multiple. Exclude recipes with any of the foods.'), qtype='int'),
|
||||
QueryParam(name='foods_and_not', description=_('Food IDs, repeat for multiple. Exclude recipes with all of the foods.'), qtype='int'),
|
||||
QueryParam(name='units', description=_('ID of unit a recipe should have.'), qtype='int'),
|
||||
QueryParam(name='rating', description=_('Rating a recipe should have or greater. [0 - 5] Negative value filters rating less than.'), qtype='int'),
|
||||
QueryParam(name='keywords', description=_('ID of keyword a recipe should have. For multiple repeat parameter. Equivalent to keywords_or'), qtype='integer'),
|
||||
QueryParam(name='keywords_or', description=_('Keyword IDs, repeat for multiple. Return recipes with any of the keywords'), qtype='integer'),
|
||||
QueryParam(name='keywords_and', description=_('Keyword IDs, repeat for multiple. Return recipes with all of the keywords.'), qtype='integer'),
|
||||
QueryParam(name='keywords_or_not', description=_('Keyword IDs, repeat for multiple. Exclude recipes with any of the keywords.'), qtype='integer'),
|
||||
QueryParam(name='keywords_and_not', description=_('Keyword IDs, repeat for multiple. Exclude recipes with all of the keywords.'), qtype='integer'),
|
||||
QueryParam(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'), qtype='integer'),
|
||||
QueryParam(name='foods_or', description=_('Food IDs, repeat for multiple. Return recipes with any of the foods'), qtype='integer'),
|
||||
QueryParam(name='foods_and', description=_('Food IDs, repeat for multiple. Return recipes with all of the foods.'), qtype='integer'),
|
||||
QueryParam(name='foods_or_not', description=_('Food IDs, repeat for multiple. Exclude recipes with any of the foods.'), qtype='integer'),
|
||||
QueryParam(name='foods_and_not', description=_('Food IDs, repeat for multiple. Exclude recipes with all of the foods.'), qtype='integer'),
|
||||
QueryParam(name='units', description=_('ID of unit a recipe should have.'), qtype='integer'),
|
||||
QueryParam(name='rating', description=_('Rating a recipe should have or greater. [0 - 5] Negative value filters rating less than.'), qtype='integer'),
|
||||
QueryParam(name='books', description=_('ID of book a recipe should be in. For multiple repeat parameter.')),
|
||||
QueryParam(name='books_or', description=_('Book IDs, repeat for multiple. Return recipes with any of the books'), qtype='int'),
|
||||
QueryParam(name='books_and', description=_('Book IDs, repeat for multiple. Return recipes with all of the books.'), qtype='int'),
|
||||
QueryParam(name='books_or_not', description=_('Book IDs, repeat for multiple. Exclude recipes with any of the books.'), qtype='int'),
|
||||
QueryParam(name='books_and_not', description=_('Book IDs, repeat for multiple. Exclude recipes with all of the books.'), qtype='int'),
|
||||
QueryParam(name='books_or', description=_('Book IDs, repeat for multiple. Return recipes with any of the books'), qtype='integer'),
|
||||
QueryParam(name='books_and', description=_('Book IDs, repeat for multiple. Return recipes with all of the books.'), qtype='integer'),
|
||||
QueryParam(name='books_or_not', description=_('Book IDs, repeat for multiple. Exclude recipes with any of the books.'), qtype='integer'),
|
||||
QueryParam(name='books_and_not', description=_('Book IDs, repeat for multiple. Exclude recipes with all of the books.'), qtype='integer'),
|
||||
QueryParam(name='internal', description=_('If only internal recipes should be returned. [''true''/''<b>false</b>'']')),
|
||||
QueryParam(name='random', description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')),
|
||||
QueryParam(name='new', description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')),
|
||||
QueryParam(name='timescooked', description=_('Filter recipes cooked X times or more. Negative values returns cooked less than X times'), qtype='int'),
|
||||
QueryParam(name='timescooked', description=_('Filter recipes cooked X times or more. Negative values returns cooked less than X times'), qtype='integer'),
|
||||
QueryParam(name='cookedon', description=_('Filter recipes last cooked on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
|
||||
QueryParam(name='createdon', description=_('Filter recipes created on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
|
||||
QueryParam(name='updatedon', description=_('Filter recipes updated on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
|
||||
@ -1095,7 +1110,7 @@ class UnitConversionViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = UnitConversionSerializer
|
||||
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
|
||||
query_params = [
|
||||
QueryParam(name='food_id', description='ID of food to filter for', qtype='int'),
|
||||
QueryParam(name='food_id', description='ID of food to filter for', qtype='integer'),
|
||||
]
|
||||
schema = QueryParamAutoSchema()
|
||||
|
||||
@ -1146,10 +1161,10 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = ShoppingListEntrySerializer
|
||||
permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope]
|
||||
query_params = [
|
||||
QueryParam(name='id', description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'), qtype='int'),
|
||||
QueryParam(name='id', description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'), qtype='integer'),
|
||||
QueryParam(name='checked', description=_('Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''<b>recent</b>'']<br> - ''recent'' includes unchecked items and recently completed items.')
|
||||
),
|
||||
QueryParam(name='supermarket', description=_('Returns the shopping list entries sorted by supermarket category order.'), qtype='int'),
|
||||
QueryParam(name='supermarket', description=_('Returns the shopping list entries sorted by supermarket category order.'), qtype='integer'),
|
||||
]
|
||||
schema = QueryParamAutoSchema()
|
||||
|
||||
@ -1614,6 +1629,7 @@ class ImportOpenData(APIView):
|
||||
# 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'])
|
||||
|
||||
@ -1623,12 +1639,19 @@ class ImportOpenData(APIView):
|
||||
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['store'] = len(data_importer.import_supermarket())
|
||||
response_obj['food'] = len(data_importer.import_food())
|
||||
response_obj['conversion'] = len(data_importer.import_conversion())
|
||||
|
||||
if selected_datatypes['unit']['selected']:
|
||||
response_obj['unit'] = data_importer.import_units().to_dict()
|
||||
if selected_datatypes['category']['selected']:
|
||||
response_obj['category'] = data_importer.import_category().to_dict()
|
||||
if selected_datatypes['property']['selected']:
|
||||
response_obj['property'] = data_importer.import_property().to_dict()
|
||||
if selected_datatypes['store']['selected']:
|
||||
response_obj['store'] = data_importer.import_supermarket().to_dict()
|
||||
if selected_datatypes['food']['selected']:
|
||||
response_obj['food'] = data_importer.import_food().to_dict()
|
||||
if selected_datatypes['conversion']['selected']:
|
||||
response_obj['conversion'] = data_importer.import_conversion().to_dict()
|
||||
|
||||
return Response(response_obj)
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
Django==4.2.10
|
||||
cryptography===42.0.0
|
||||
cryptography===42.0.2
|
||||
django-annoying==0.10.6
|
||||
django-autocomplete-light==3.9.7
|
||||
django-cleanup==8.0.0
|
||||
@ -33,9 +33,9 @@ git+https://github.com/BITSOLVER/django-js-reverse@071e304fd600107bc64bbde6f2491
|
||||
django-allauth==0.61.1
|
||||
recipe-scrapers==14.52.0
|
||||
django-scopes==2.0.0
|
||||
pytest==7.4.3
|
||||
pytest==8.0.0
|
||||
pytest-asyncio==0.23.3
|
||||
pytest-django==4.6.0
|
||||
pytest-django==4.8.0
|
||||
django-treebeard==4.7
|
||||
django-cors-headers==4.2.0
|
||||
django-storages==1.14.2
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div id="app" class="col-12 col-xl-8 col-lg-10 offset-xl-2 offset-lg-1 offset">
|
||||
<div id="app" class="books col-12 col-xl-8 col-lg-10 offset-xl-2 offset-lg-1 offset">
|
||||
<div class="col-12 col-xl-8 col-lg-10 offset-xl-2 offset-lg-1">
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div id="app" class="mealplan">
|
||||
<div class="d-none d-lg-block">
|
||||
<div class="row ">
|
||||
<div class="col col-2">
|
||||
|
@ -119,7 +119,6 @@ export default {
|
||||
show_split: false,
|
||||
paginated: false,
|
||||
header_component_name: undefined,
|
||||
use_plural: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@ -145,17 +144,6 @@ export default {
|
||||
}
|
||||
})
|
||||
this.$i18n.locale = window.CUSTOM_LOCALE
|
||||
let apiClient = new ApiApiFactory()
|
||||
apiClient.retrieveSpace(window.ACTIVE_SPACE_ID).then(r => {
|
||||
this.use_plural = r.data.use_plural
|
||||
if (!this.use_plural && this.this_model !== null && this.this_model.create.params[0] !== null && this.this_model.create.params[0].includes('plural_name')) {
|
||||
let index = this.this_model.create.params[0].indexOf('plural_name')
|
||||
if (index > -1){
|
||||
this.this_model.create.params[0].splice(index, 1)
|
||||
}
|
||||
delete this.this_model.create.form.plural_name
|
||||
}
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
// this.genericAPI inherited from ApiMixin
|
||||
|
@ -8,6 +8,7 @@
|
||||
<h2><a :href="resolveDjangoUrl('view_recipe', recipe.id)">{{ recipe.name }}</a></h2>
|
||||
{{ recipe.description }}
|
||||
<keywords-component :recipe="recipe"></keywords-component>
|
||||
|
||||
</div>
|
||||
<div class="col col-4" v-if="recipe.image">
|
||||
<img style="max-height: 10vh" class="img-thumbnail float-right" :src="recipe.image">
|
||||
@ -17,7 +18,7 @@
|
||||
|
||||
<div class="row mt-3">
|
||||
<div class="col col-12">
|
||||
<b-button variant="success" href="https://fdc.nal.usda.gov/index.html" target="_blank"><i class="fas fa-external-link-alt"></i> {{$t('FDC_Search')}}</b-button>
|
||||
<b-button variant="success" href="https://fdc.nal.usda.gov/index.html" target="_blank"><i class="fas fa-external-link-alt"></i> {{ $t('FDC_Search') }}</b-button>
|
||||
|
||||
<table class="table table-sm table-bordered table-responsive mt-2 pb-5">
|
||||
<thead>
|
||||
@ -29,7 +30,7 @@
|
||||
<td v-for="pt in property_types" v-bind:key="pt.id">
|
||||
<b-button variant="primary" @click="editing_property_type = pt" class="btn-block">{{ pt.name }}
|
||||
<span v-if="pt.unit !== ''">({{ pt.unit }}) </span> <br/>
|
||||
<b-badge variant="light" ><i class="fas fa-sort-amount-down-alt"></i> {{ pt.order}}</b-badge>
|
||||
<b-badge variant="light"><i class="fas fa-sort-amount-down-alt"></i> {{ pt.order }}</b-badge>
|
||||
<b-badge variant="success" v-if="pt.fdc_id > 0" class="mt-2" v-b-tooltip.hover :title="$t('property_type_fdc_hint')"><i class="fas fa-check"></i> FDC</b-badge>
|
||||
<b-badge variant="warning" v-if="pt.fdc_id < 1" class="mt-2" v-b-tooltip.hover :title="$t('property_type_fdc_hint')"><i class="fas fa-times"></i> FDC</b-badge>
|
||||
</b-button>
|
||||
@ -48,7 +49,7 @@
|
||||
<b-input-group>
|
||||
<b-form-input v-model="f.fdc_id" type="number" @change="updateFood(f)" :disabled="f.loading"></b-form-input>
|
||||
<b-input-group-append>
|
||||
<b-button variant="success" @click="updateFoodFromFDC(f)" :disabled="f.loading"><i class="fas fa-sync-alt" :class="{'fa-spin': loading}"></i></b-button>
|
||||
<b-button variant="success" @click="updateFoodFromFDC(f)" :disabled="f.loading || f.fdc_id < 1"><i class="fas fa-sync-alt" :class="{'fa-spin': loading}"></i></b-button>
|
||||
<b-button variant="info" :href="`https://fdc.nal.usda.gov/fdc-app.html#/food-details/${f.fdc_id}`" :disabled="f.fdc_id < 1" target="_blank"><i class="fas fa-external-link-alt"></i></b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
@ -67,7 +68,19 @@
|
||||
</td>
|
||||
<td v-for="p in f.properties" v-bind:key="`${f.id}_${p.property_type.id}`">
|
||||
<b-input-group>
|
||||
<b-form-input v-model="p.property_amount" type="number" :disabled="f.loading" v-b-tooltip.focus :title="p.property_type.name" @change="updateFood(f)"></b-form-input>
|
||||
<template v-if="p.property_amount == null">
|
||||
<b-btn class="btn-sm btn-block btn-success" @click="enableProperty(p,f)">Add</b-btn>
|
||||
</template>
|
||||
<template v-else>
|
||||
<b-input-group>
|
||||
|
||||
<b-form-input v-model="p.property_amount" type="number" :ref="`id_input_${f.id}_${p.property_type.id}`" :disabled="f.loading" v-b-tooltip.focus :title="p.property_type.name"
|
||||
@change="updateFood(f)"></b-form-input>
|
||||
<b-input-group-append>
|
||||
<b-btn @click="p.property_amount = null; updateFood(f)"><i class="fas fa-trash-alt"></i></b-btn>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</template>
|
||||
</b-input-group>
|
||||
</td>
|
||||
</tr>
|
||||
@ -76,6 +89,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b-row class="mt-2">
|
||||
<b-col>
|
||||
<b-card>
|
||||
<b-card-title>
|
||||
<i class="fas fa-calculator"></i> {{ $t('Calculator') }}
|
||||
</b-card-title>
|
||||
<b-card-text>
|
||||
<b-form inline>
|
||||
<b-input type="number" v-model="calculator_from_amount"></b-input>
|
||||
<i class="fas fa-divide fa-fw mr-1 ml-1"></i>
|
||||
<b-input type="number" v-model="calculator_from_per"></b-input>
|
||||
<i class="fas fa-equals fa-fw mr-1 ml-1"></i>
|
||||
|
||||
<b-input-group>
|
||||
<b-input v-model="calculator_to_amount" disabled></b-input>
|
||||
<b-input-group-append>
|
||||
<b-btn variant="success" @click="copyCalculatedResult()"><i class="far fa-copy"></i></b-btn>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
|
||||
|
||||
<i class="fas fa-divide fa-fw mr-1 ml-1"></i>
|
||||
<b-input type="number" v-model="calculator_to_per"></b-input>
|
||||
</b-form>
|
||||
</b-card-text>
|
||||
|
||||
</b-card>
|
||||
|
||||
|
||||
</b-col>
|
||||
|
||||
</b-row>
|
||||
|
||||
|
||||
<generic-modal-form
|
||||
:show="editing_property_type !== null"
|
||||
@ -109,8 +155,9 @@ import {ApiApiFactory} from "@/utils/openapi/api";
|
||||
import GenericMultiselect from "@/components/GenericMultiselect.vue";
|
||||
import GenericModalForm from "@/components/Modals/GenericModalForm.vue";
|
||||
import KeywordsComponent from "@/components/KeywordsComponent.vue";
|
||||
import VueClipboard from 'vue-clipboard2'
|
||||
|
||||
|
||||
Vue.use(VueClipboard)
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
|
||||
@ -118,7 +165,11 @@ export default {
|
||||
name: "PropertyEditorView",
|
||||
mixins: [ApiMixin],
|
||||
components: {KeywordsComponent, GenericModalForm, GenericMultiselect},
|
||||
computed: {},
|
||||
computed: {
|
||||
calculator_to_amount: function () {
|
||||
return (this.calculator_from_amount / this.calculator_from_per * this.calculator_to_per).toFixed(2)
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
recipe: null,
|
||||
@ -127,6 +178,10 @@ export default {
|
||||
new_property_type: false,
|
||||
loading: false,
|
||||
foods: [],
|
||||
|
||||
calculator_from_amount: 1,
|
||||
calculator_from_per: 300,
|
||||
calculator_to_per: 100,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@ -149,7 +204,7 @@ export default {
|
||||
|
||||
this.recipe.steps.forEach(s => {
|
||||
s.ingredients.forEach(i => {
|
||||
if (this.foods.filter(x => (x.id === i.food.id)).length === 0) {
|
||||
if (i.food != null && this.foods.filter(x => (x.id === i.food.id)).length === 0) {
|
||||
this.foods.push(this.buildFood(i.food))
|
||||
}
|
||||
})
|
||||
@ -176,7 +231,7 @@ export default {
|
||||
this.property_types.forEach(pt => {
|
||||
let new_food_property = {
|
||||
property_type: pt,
|
||||
property_amount: 0,
|
||||
property_amount: null,
|
||||
}
|
||||
if (pt.id in existing_properties) {
|
||||
new_food_property = existing_properties[pt.id]
|
||||
@ -200,7 +255,8 @@ export default {
|
||||
updateFood: function (food) {
|
||||
let apiClient = new ApiApiFactory()
|
||||
apiClient.partialUpdateFood(food.id, food).then(result => {
|
||||
this.spliceInFood(this.buildFood(result.data))
|
||||
// don't use result to prevent flickering
|
||||
//this.spliceInFood(this.buildFood(result.data))
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_UPDATE)
|
||||
}).catch((err) => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
|
||||
@ -217,7 +273,17 @@ export default {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
|
||||
food.loading = false;
|
||||
})
|
||||
}
|
||||
},
|
||||
copyCalculatedResult: function () {
|
||||
this.$copyText(this.calculator_to_amount)
|
||||
},
|
||||
enableProperty: async function (property, food) {
|
||||
property.property_amount = 0;
|
||||
this.updateFood(food)
|
||||
await this.$nextTick();
|
||||
this.$refs[`id_input_${food.id}_${property.property_type.id}`][0].focus()
|
||||
this.$refs[`id_input_${food.id}_${property.property_type.id}`][0].select()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div id="app" style="padding-bottom: 60px">
|
||||
<div id="app" class="search" style="padding-bottom: 60px">
|
||||
<RecipeSwitcher ref="ref_recipe_switcher" />
|
||||
<div class="row">
|
||||
<div class="col-12 col-xl-10 col-lg-10 offset-xl-1 offset-lg-1">
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div id="app" v-if="recipe_id !== undefined">
|
||||
<div id="app" class="recipe" v-if="recipe_id !== undefined">
|
||||
<recipe-view-component :recipe_id="recipe_id"></recipe-view-component>
|
||||
|
||||
<bottom-navigation-bar active-view="view_search"></bottom-navigation-bar>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<div id="app" class="shopping">
|
||||
<b-alert :show="shopping_list_store.has_failed_items" class="float-up mt-2" variant="warning">
|
||||
{{ $t('ShoppingBackgroundSyncWarning') }}
|
||||
</b-alert>
|
||||
@ -55,7 +55,6 @@
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Entry input on large screens -->
|
||||
<b-row class="d-lg-block d-print-none d-none mb-3 mt-3">
|
||||
<b-col cols="12">
|
||||
|
@ -242,6 +242,10 @@
|
||||
</b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :label="$t('Open_Data_Slug')" :description="$t('open_data_help_text')">
|
||||
<b-form-input v-model="food.open_data_slug" disabled>
|
||||
</b-form-input>
|
||||
</b-form-group>
|
||||
|
||||
</b-form>
|
||||
</b-tab>
|
||||
|
@ -1,52 +1,41 @@
|
||||
<template>
|
||||
<tr>
|
||||
<tr class="ingredients__item">
|
||||
<template v-if="ingredient.is_header">
|
||||
<td colspan="5" @click="done">
|
||||
<td class="ingredients__header-note header" colspan="5" @click="done">
|
||||
<b>{{ ingredient.note }}</b>
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<td class="d-print-none align-baseline py-2" v-if="detailed" @click="done">
|
||||
<i class="far fa-check-circle text-success" v-if="ingredient.checked"></i>
|
||||
<i class="far fa-check-circle text-primary" v-if="!ingredient.checked"></i>
|
||||
<td class="ingredients__check d-print-none align-baseline py-2" v-if="detailed" @click="done">
|
||||
<i class="ingredients__check ingredients__check_checked far fa-check-circle text-success" v-if="ingredient.checked"></i>
|
||||
<i class="ingredients__check ingredients__check_checked_false far fa-check-circle text-primary" v-if="!ingredient.checked"></i>
|
||||
</td>
|
||||
<td class="text-nowrap" @click="done">
|
||||
<span v-if="ingredient.amount !== 0 && !ingredient.no_amount" v-html="calculateAmount(ingredient.amount)"></span>
|
||||
<td class="ingredients__amount text-nowrap" @click="done">
|
||||
<span class="ingredients__amount" :class="amountClass" v-if="ingredient.amount !== 0 && !ingredient.no_amount" v-html="amount"></span>
|
||||
</td>
|
||||
<td @click="done">
|
||||
<template v-if="ingredient.unit !== null && !ingredient.no_amount">
|
||||
<template>
|
||||
<template v-if="ingredient.unit.plural_name === '' || ingredient.unit.plural_name === null">
|
||||
<span>{{ ingredient.unit.name }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="ingredient.always_use_plural_unit">{{ ingredient.unit.plural_name }}</span>
|
||||
<span v-else-if="ingredient.amount * this.ingredient_factor > 1">{{ ingredient.unit.plural_name }}</span>
|
||||
<span v-else>{{ ingredient.unit.name }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<td class="ingredients__unit" @click="done">
|
||||
<span v-if="ingredient.unit !== null && !ingredient.no_amount" :class="unitClass">{{ unitName }}</span>
|
||||
</td>
|
||||
<td @click="done">
|
||||
<td class="ingredients__food" :class="foodClass" @click="done">
|
||||
<template v-if="ingredient.food !== null">
|
||||
<a :href="resolveDjangoUrl('view_recipe', ingredient.food.recipe.id)" v-if="ingredient.food.recipe !== null" target="_blank" rel="noopener noreferrer">
|
||||
{{ ingredientName(ingredient) }}
|
||||
{{ foodName }}
|
||||
</a>
|
||||
<a :href="ingredient.food.url" v-else-if="ingredient.food.url !== ''" target="_blank" rel="noopener noreferrer">
|
||||
{{ ingredientName(ingredient) }}</a>
|
||||
{{ foodName }}</a>
|
||||
<template v-else>
|
||||
<span>{{ ingredientName(ingredient) }}</span>
|
||||
<span :class="foodClass">{{ foodName }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</td>
|
||||
<td v-if="detailed" class="align-baseline">
|
||||
<td v-if="detailed" class="ingredients__note align-baseline">
|
||||
<template v-if="ingredient.note">
|
||||
<span class="d-print-none touchable py-0 px-2" v-b-popover.hover="ingredient.note">
|
||||
<span class="ingredients__note ingredients__note_hover d-print-none touchable py-0 px-2" v-b-popover.hover="ingredient.note">
|
||||
<i class="far fa-comment"></i>
|
||||
</span>
|
||||
|
||||
<div class="d-none d-print-block"><i class="far fa-comment-alt d-print-none"></i> {{ ingredient.note }}</div>
|
||||
<div class="ingredients__note ingredients__note_print d-none d-print-block"><i class="far fa-comment-alt d-print-none"></i> {{ ingredient.note }}</div>
|
||||
</template>
|
||||
</td>
|
||||
</template>
|
||||
@ -54,7 +43,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {calculateAmount, ResolveUrlMixin} from "@/utils/utils"
|
||||
import {calculateAmount, ResolveUrlMixin, EscapeCSSMixin} from "@/utils/utils"
|
||||
|
||||
import Vue from "vue"
|
||||
import VueSanitize from "vue-sanitize"
|
||||
@ -68,7 +57,7 @@ export default {
|
||||
ingredient_factor: {type: Number, default: 1},
|
||||
detailed: {type: Boolean, default: true},
|
||||
},
|
||||
mixins: [ResolveUrlMixin],
|
||||
mixins: [ResolveUrlMixin, EscapeCSSMixin],
|
||||
data() {
|
||||
return {
|
||||
checked: false,
|
||||
@ -77,27 +66,65 @@ export default {
|
||||
watch: {},
|
||||
mounted() {
|
||||
},
|
||||
methods: {
|
||||
calculateAmount: function (x) {
|
||||
return this.$sanitize(calculateAmount(x, this.ingredient_factor))
|
||||
computed: {
|
||||
amount: function() {
|
||||
return this.$sanitize(calculateAmount(this.ingredient.amount, this.ingredient_factor))
|
||||
},
|
||||
isScaledUp: function() {
|
||||
return this.ingredient_factor > 1 ? true:false
|
||||
},
|
||||
isScaledDown: function() {
|
||||
return this.ingredient_factor < 1 ? true:false
|
||||
},
|
||||
amountClass: function () {
|
||||
if (this.isScaledDown) {
|
||||
return this.escapeCSS('ingredients__amount_scaled_down')
|
||||
} else if (this.isScaledUp) {
|
||||
return this.escapeCSS('ingredients__amount_scaled_up')
|
||||
} else {
|
||||
return this.escapeCSS('ingredients__amount_scaled_false')
|
||||
}
|
||||
},
|
||||
isUnitPlural: function () {
|
||||
if (this.ingredient.unit.plural_name === '' || this.ingredient.unit.plural_name === null) {
|
||||
return false
|
||||
} else if (this.ingredient.always_use_plural_unit || this.ingredient.amount * this.ingredient_factor > 1) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
},
|
||||
isFoodPlural: function () {
|
||||
if (this.ingredient.food.plural_name == null || this.ingredient.food.plural_name === '') {
|
||||
return false
|
||||
}
|
||||
if (this.ingredient.always_use_plural_food) {
|
||||
return true
|
||||
} else if (this.ingredient.no_amount) {
|
||||
return false
|
||||
} else if (this.ingredient.amount * this.ingredient_factor > 1) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
},
|
||||
unitClass: function () {
|
||||
return this.escapeCSS('_unitname-' + this.ingredient.unit.name)
|
||||
},
|
||||
foodClass: function () {
|
||||
return this.escapeCSS('_foodname-' + this.ingredient.food.name)
|
||||
},
|
||||
unitName: function () {
|
||||
return this.isUnitPlural ? this.ingredient.unit.plural_name : this.ingredient.unit.name
|
||||
},
|
||||
foodName: function () {
|
||||
return this.isFoodPlural ? this.ingredient.food.plural_name : this.ingredient.food.name
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// sends parent recipe ingredient to notify complete has been toggled
|
||||
done: function () {
|
||||
this.$emit("checked-state-changed", this.ingredient)
|
||||
},
|
||||
ingredientName: function (ingredient) {
|
||||
if (ingredient.food.plural_name == null || ingredient.food.plural_name === '') {
|
||||
return ingredient.food.name
|
||||
}
|
||||
if (ingredient.always_use_plural_food) {
|
||||
return ingredient.food.plural_name
|
||||
} else if (ingredient.no_amount) {
|
||||
return ingredient.food.name
|
||||
} else if (ingredient.amount * this.ingredient_factor > 1) {
|
||||
return ingredient.food.plural_name
|
||||
} else {
|
||||
return ingredient.food.name
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div :class="{ 'card border-primary no-border': header }">
|
||||
<div class="ingredients" :class="{ 'card border-primary no-border': header }">
|
||||
<div :class="{ 'card-body': header, 'p-0': header }">
|
||||
<div class="card-header" v-if="header">
|
||||
<div class="row">
|
||||
@ -15,7 +15,7 @@
|
||||
<table class="table table-sm mb-0">
|
||||
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
|
||||
<template v-for="s in steps">
|
||||
<tr v-bind:key="s.id" v-if="s.show_as_header && s.name !== '' && steps.length > 1">
|
||||
<tr class="ingredients__header-step-name" v-bind:key="s.id" v-if="s.show_as_header && s.name !== '' && steps.length > 1">
|
||||
<td colspan="5">
|
||||
<b>{{ s.name }}</b>
|
||||
</td>
|
||||
|
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div v-if="recipe.keywords.length > 0">
|
||||
<span :key="k.id" v-for="k in recipe.keywords.slice(0,keyword_splice).filter((kk) => { return kk.show || kk.show === undefined })" class="pl-1">
|
||||
<div class="keywords" v-if="recipe.keywords.length > 0">
|
||||
<span :key="k.id" v-for="k in recipe.keywords.slice(0,keyword_splice).filter((kk) => { return kk.show || kk.show === undefined })" class="keywords__item pl-1" :class="keywordClass(k)">
|
||||
<template v-if="enable_keyword_links">
|
||||
<a :href="`${resolveDjangoUrl('view_search')}?keyword=${k.id}`">
|
||||
<b-badge pill variant="light" class="font-weight-normal">{{ k.label }}</b-badge>
|
||||
@ -9,18 +9,17 @@
|
||||
<template v-else>
|
||||
<b-badge pill variant="light" class="font-weight-normal">{{ k.label }}</b-badge>
|
||||
</template>
|
||||
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import {ResolveUrlMixin} from "@/utils/utils";
|
||||
import {ResolveUrlMixin, EscapeCSSMixin} from "@/utils/utils";
|
||||
|
||||
export default {
|
||||
name: 'KeywordsComponent',
|
||||
mixins: [ResolveUrlMixin],
|
||||
mixins: [ResolveUrlMixin, EscapeCSSMixin],
|
||||
props: {
|
||||
recipe: Object,
|
||||
limit: Number,
|
||||
@ -31,7 +30,12 @@ export default {
|
||||
if (this.limit) {
|
||||
return this.limit
|
||||
}
|
||||
return this.recipe.keywords.lenght
|
||||
return this.recipe.keywords.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
keywordClass: function(k) {
|
||||
return this.escapeCSS('_keywordname-' + k.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="last-cooked">
|
||||
<span class="pl-1" v-if="recipe.last_cooked !== undefined && recipe.last_cooked !== null">
|
||||
<b-badge pill variant="primary" class="font-weight-normal"><i class="fas fa-utensils"></i> {{
|
||||
formatDate(recipe.last_cooked)
|
||||
|
@ -4,7 +4,7 @@
|
||||
|
||||
<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>
|
||||
<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">
|
||||
@ -18,15 +18,24 @@
|
||||
<div v-if="selected_version !== undefined" class="mt-3">
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th>{{ $t('Import') }}</th>
|
||||
<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>
|
||||
<tr v-for="d in datatypes" v-bind:key="d.name">
|
||||
<td>
|
||||
<template v-if="import_count !== undefined">{{ import_count[d] }}</template>
|
||||
<b-checkbox v-model="d.selected"></b-checkbox>
|
||||
</td>
|
||||
<td>{{ $t(d.name.charAt(0).toUpperCase() + d.name.slice(1)) }}</td>
|
||||
<td>{{ metadata[selected_version][d.name] }}</td>
|
||||
<td>
|
||||
<template v-if="d.name in import_count">
|
||||
<i class="fas fa-plus-circle"></i> {{ import_count[d.name]['total_created'] }} {{ $t('Created')}} <br/>
|
||||
<i class="fas fa-pencil-alt"></i> {{ import_count[d.name]['total_updated'] }} {{ $t('Updated')}} <br/>
|
||||
<i class="fas fa-forward"></i> {{ import_count[d.name]['total_untouched'] }} {{ $t('Unchanged')}} <br/>
|
||||
<i class="fas fa-exclamation-circle"></i> {{ import_count[d.name]['total_errored'] }} {{ $t('Error')}}
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@ -59,10 +68,11 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
metadata: undefined,
|
||||
datatypes: {},
|
||||
selected_version: undefined,
|
||||
update_existing: true,
|
||||
use_metric: true,
|
||||
import_count: undefined,
|
||||
import_count: {},
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@ -70,6 +80,12 @@ export default {
|
||||
|
||||
axios.get(resolveDjangoUrl('api_import_open_data')).then(r => {
|
||||
this.metadata = r.data
|
||||
for (let i in this.metadata.datatypes) {
|
||||
this.datatypes[this.metadata.datatypes[i]] = {
|
||||
name: this.metadata.datatypes[i],
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
}).catch(err => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
|
||||
})
|
||||
@ -78,12 +94,12 @@ export default {
|
||||
doImport: function () {
|
||||
axios.post(resolveDjangoUrl('api_import_open_data'), {
|
||||
'selected_version': this.selected_version,
|
||||
'selected_datatypes': this.metadata.datatypes,
|
||||
'selected_datatypes': this.datatypes,
|
||||
'update_existing': this.update_existing,
|
||||
'use_metric': this.use_metric,
|
||||
}).then(r => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_CREATE)
|
||||
this.import_count = r.data
|
||||
this.import_count = Object.assign({}, this.import_count, r.data);
|
||||
}).catch(err => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_CREATE, err)
|
||||
})
|
||||
|
@ -41,10 +41,8 @@
|
||||
|
||||
<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>-->
|
||||
<i class="text-muted fas fa-info-circle"></i>
|
||||
<!-- TODO find solution for missing values as 0 can either be missing or actually correct for any given property -->
|
||||
<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>
|
||||
@ -64,7 +62,14 @@
|
||||
<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>
|
||||
<td>
|
||||
<template v-if="f.value == null">
|
||||
<i class="text-warning fas fa-exclamation-triangle"></i>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ f.value }} {{ selected_property.unit }}
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</template>
|
||||
@ -117,6 +122,7 @@ export default {
|
||||
has_food_properties = true
|
||||
}
|
||||
}
|
||||
console.log('has food propers', has_food_properties)
|
||||
return has_food_properties
|
||||
},
|
||||
property_list: function () {
|
||||
|
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="rating">
|
||||
<span class="d-inline" v-if="recipe.rating > 0">
|
||||
<div v-if="!pill">
|
||||
<i class="fas fa-star fa-xs text-primary" v-for="i in Math.floor(recipe.rating)" v-bind:key="i"></i>
|
||||
|
@ -1,38 +1,38 @@
|
||||
<template>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<template v-if="loading">
|
||||
<loading-spinner></loading-spinner>
|
||||
</template>
|
||||
|
||||
<div v-if="!loading" style="padding-bottom: 60px">
|
||||
<RecipeSwitcher ref="ref_recipe_switcher" @switch="quickSwitch($event)" v-if="show_recipe_switcher"/>
|
||||
<div class="row">
|
||||
<div class="recipe__title row">
|
||||
<div class="col-12" style="text-align: center">
|
||||
<h3>{{ recipe.name }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row text-center">
|
||||
<div class="recipe__history row text-center">
|
||||
<div class="col col-md-12">
|
||||
<recipe-rating :recipe="recipe"></recipe-rating>
|
||||
<last-cooked :recipe="recipe" class="mt-2"></last-cooked>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-auto">
|
||||
<div class="recipe__description my-auto">
|
||||
<div class="col-12" style="text-align: center">
|
||||
<i>{{ recipe.description }}</i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center">
|
||||
<div class="recipe__keywords" style="text-align: center">
|
||||
<keywords-component :recipe="recipe" :enable_keyword_links="enable_keyword_links"></keywords-component>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
<div class="row align-items-center">
|
||||
<div class="col col-md-3">
|
||||
<div class="recipe__prep-data row align-items-center">
|
||||
<div class="recipe__prep-time col col-md-3">
|
||||
<div class="d-flex">
|
||||
<div class="my-auto mr-1">
|
||||
<i class="fas fa-fw fa-user-clock fa-2x text-primary"></i>
|
||||
@ -44,7 +44,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-3">
|
||||
<div class="recipe__wait-time col col-md-3">
|
||||
<div class="row d-flex">
|
||||
<div class="my-auto mr-1">
|
||||
<i class="far fa-fw fa-clock fa-2x text-primary"></i>
|
||||
@ -56,7 +56,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-4 col-10 mt-2 mt-md-0">
|
||||
<div class="recipe__servings col col-md-4 col-10 mt-2 mt-md-0">
|
||||
<div class="d-flex">
|
||||
<div class="my-auto mr-1">
|
||||
<i class="fas fa-fw fa-pizza-slice fa-2x text-primary"></i>
|
||||
@ -75,15 +75,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-2 col-2 mt-2 mt-md-0 text-right">
|
||||
<div class="recipe__context-menu col col-md-2 col-2 mt-2 mt-md-0 text-right">
|
||||
<recipe-context-menu v-bind:recipe="recipe" :servings="servings"
|
||||
:disabled_options="{print:false}" v-if="show_context_menu"></recipe-context-menu>
|
||||
</div>
|
||||
</div>
|
||||
<hr/>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 order-md-1 col-sm-12 order-sm-2 col-12 order-2"
|
||||
<div class="recipe__overview row">
|
||||
<div class="recipe__ingredients col-md-6 order-md-1 col-sm-12 order-sm-2 col-12 order-2"
|
||||
v-if="recipe && ingredient_count > 0 && (recipe.show_ingredient_overview || recipe.steps.length < 2)">
|
||||
<ingredients-card
|
||||
:recipe="recipe.id"
|
||||
@ -97,7 +97,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-12 order-1 col-sm-12 order-sm-1 col-md-6 order-md-2">
|
||||
<div class="recipe__image col-12 order-1 col-sm-12 order-sm-1 col-md-6 order-md-2">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<img class="img img-fluid rounded" :src="recipe.image" :alt="$t('Recipe_Image')"
|
||||
@ -109,16 +109,17 @@
|
||||
</div>
|
||||
|
||||
<template v-if="!recipe.internal">
|
||||
<div v-if="recipe.file_path.includes('.pdf')">
|
||||
<div v-if="recipe.file_path.includes('.pdf')" class="recipe__file-pdf">
|
||||
<PdfViewer :recipe="recipe"></PdfViewer>
|
||||
</div>
|
||||
<div
|
||||
v-if="recipe.file_path.includes('.png') || recipe.file_path.includes('.jpg') || recipe.file_path.includes('.jpeg') || recipe.file_path.includes('.gif')">
|
||||
v-if="recipe.file_path.includes('.png') || recipe.file_path.includes('.jpg') || recipe.file_path.includes('.jpeg') || recipe.file_path.includes('.gif')"
|
||||
class="recipe__file-img">
|
||||
<ImageViewer :recipe="recipe"></ImageViewer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-for="(s, index) in recipe.steps" v-bind:key="s.id" style="margin-top: 1vh">
|
||||
<div class="recipe__step" v-for="(s, index) in recipe.steps" v-bind:key="s.id" style="margin-top: 1vh">
|
||||
<step-component
|
||||
:recipe="recipe"
|
||||
:step="s"
|
||||
@ -130,13 +131,13 @@
|
||||
></step-component>
|
||||
</div>
|
||||
|
||||
<div v-if="recipe.source_url !== null">
|
||||
<div class="recipe__source" v-if="recipe.source_url !== null">
|
||||
<h6 class="d-print-none"><i class="fas fa-file-import"></i> {{ $t("Imported_From") }}</h6>
|
||||
<span class="text-muted mt-1"><a style="overflow-wrap: break-word;"
|
||||
:href="recipe.source_url">{{ recipe.source_url }}</a></span>
|
||||
</div>
|
||||
|
||||
<div class="row" style="margin-top: 2vh; ">
|
||||
<div class="recipe__properties row" style="margin-top: 2vh; ">
|
||||
<div class="col-lg-6 offset-lg-3 col-12">
|
||||
<property-view-component :recipe="recipe" :servings="servings" @foodUpdated="loadRecipe(recipe.id)"></property-view-component>
|
||||
</div>
|
||||
|
@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<span v-html="calculateAmount(number)"></span>
|
||||
|
||||
<span class="step__scalable-num" :class="[this.factor===1 ? 'step__scalable-num_scaled_false' : (this.factor > 1 ? 'step__scalable-num_scaled_up':'step__scalable-num_scaled_down')]" v-html="calculateAmount(number)"></span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
@ -1,15 +1,14 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="step" :class="stepClassName" >
|
||||
<hr/>
|
||||
<!-- Step header (only shown if more than one step -->
|
||||
<div class="row mb-1" v-if="recipe.steps.length > 1">
|
||||
<div class="col col-md-8">
|
||||
<h5 class="text-primary">
|
||||
<template v-if="step.name">{{ step.name }}</template>
|
||||
<template v-else>{{ $t("Step") }} {{ index + 1 }}</template>
|
||||
<small style="margin-left: 4px" class="text-muted" v-if="step.time !== 0"><i
|
||||
<h5 class="step__name text-primary" :class="stepClassNameType">
|
||||
{{ step_name }}
|
||||
<small style="margin-left: 4px" class="step__time text-muted" v-if="step.time !== 0"><i
|
||||
class="fas fa-user-clock"></i> {{ step_time }}</small>
|
||||
<small v-if="start_time !== ''" class="d-print-none">
|
||||
<small v-if="start_time !== ''" class="step__start-time d-print-none">
|
||||
<b-link :id="`id_reactive_popover_${step.id}`" @click="openPopover" href="#">
|
||||
{{ moment(start_time).add(step.time_offset, "minutes").format("HH:mm") }}
|
||||
</b-link>
|
||||
@ -20,19 +19,19 @@
|
||||
<b-button
|
||||
@click="details_visible = !details_visible"
|
||||
style="border: none; background: none"
|
||||
class="shadow-none d-print-none"
|
||||
:class="{ 'text-primary': details_visible, 'text-success': !details_visible }"
|
||||
class="shadow-none d-print-none step__button-collapse"
|
||||
:class="{ 'step__button-collapse_visible text-primary': details_visible, 'step_button-collapse_visible_false text-success': !details_visible }"
|
||||
>
|
||||
<i class="far fa-check-circle"></i>
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b-collapse id="collapse-1" v-model="details_visible">
|
||||
<b-collapse id="collapse-1" class="step__details" :class="[details_visible ? 'step__details_visible':'step__details_visible_false']" v-model="details_visible">
|
||||
|
||||
<div class="row">
|
||||
<!-- ingredients table -->
|
||||
<div class="col col-md-4"
|
||||
<div class="step__ingredients col col-md-4"
|
||||
v-if="step.show_ingredients_table && step.ingredients.length > 0 && (recipe.steps.length > 1 || force_ingredients)">
|
||||
<table class="table table-sm">
|
||||
<ingredients-card :steps="[step]" :ingredient_factor="ingredient_factor"
|
||||
@ -40,7 +39,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="col"
|
||||
<div class="step__instructions col"
|
||||
:class="{ 'col-md-8 col-12': recipe.steps.length > 1, 'col-md-12 col-12': recipe.steps.length <= 1 }">
|
||||
<!-- step text -->
|
||||
<div class="row">
|
||||
@ -51,7 +50,7 @@
|
||||
</div>
|
||||
|
||||
<!-- File (preview if image, download else) -->
|
||||
<div class="row" v-if="step.file !== null">
|
||||
<div class="step__file row" v-if="step.file !== null">
|
||||
<div class="col col-md-12">
|
||||
<template>
|
||||
<div
|
||||
@ -71,10 +70,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Sub recipe (always full width own row) -->
|
||||
<div class="row">
|
||||
<div class="step__subrecipe row">
|
||||
<div class="col col-md-12">
|
||||
<div class="card" v-if="step.step_recipe_data !== null">
|
||||
<b-collapse id="collapse-1" v-model="details_visible">
|
||||
<b-collapse id="collapse-1" :class="[details_visible ? 'step__details_visible':'step__details_visible_false']" v-model="details_visible">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<a :href="resolveDjangoUrl('view_recipe', step.step_recipe_data.id)">{{
|
||||
@ -128,13 +127,13 @@ import CompileComponent from "@/components/CompileComponent"
|
||||
import IngredientsCard from "@/components/IngredientsCard"
|
||||
import Vue from "vue"
|
||||
import moment from "moment"
|
||||
import {ResolveUrlMixin, calculateHourMinuteSplit} from "@/utils/utils"
|
||||
import {ResolveUrlMixin, calculateHourMinuteSplit, EscapeCSSMixin} from "@/utils/utils"
|
||||
|
||||
Vue.prototype.moment = moment
|
||||
|
||||
export default {
|
||||
name: "StepComponent",
|
||||
mixins: [GettextMixin, ResolveUrlMixin],
|
||||
mixins: [GettextMixin, ResolveUrlMixin, EscapeCSSMixin],
|
||||
components: {CompileComponent, IngredientsCard},
|
||||
props: {
|
||||
step: Object,
|
||||
@ -149,7 +148,27 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
step_time: function() {
|
||||
return calculateHourMinuteSplit(this.step.time)},
|
||||
return calculateHourMinuteSplit(this.step.time)
|
||||
},
|
||||
step_name: function() {
|
||||
if (this.step.name) {
|
||||
return this.step.name
|
||||
}
|
||||
return this.$t("Step") + ' ' + String(this.index+1)
|
||||
},
|
||||
stepClassName: function() {
|
||||
let classes = {}
|
||||
const nameclass = this.escapeCSS("_stepname-" + this.step_name)
|
||||
classes[nameclass] = !!this.step.name
|
||||
classes['_stepname-' + String(this.index+1)] = !this.step.name
|
||||
return classes
|
||||
},
|
||||
stepClassNameType: function() {
|
||||
let classes = {}
|
||||
classes['step__name_custom'] = !!this.step.name
|
||||
classes['step__name_custom_false'] = !this.step.name
|
||||
return classes
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@ -178,7 +197,7 @@ export default {
|
||||
},
|
||||
openPopover: function () {
|
||||
this.$refs[`id_reactive_popover_${this.step.id}`].$emit("open")
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
@ -83,6 +83,7 @@
|
||||
"Open_Data_Import": "Open Data Import",
|
||||
"Properties_Food_Amount": "Properties Food Amount",
|
||||
"Properties_Food_Unit": "Properties Food Unit",
|
||||
"Calculator": "Calculator",
|
||||
"FDC_ID": "FDC ID",
|
||||
"FDC_Search": "FDC Search",
|
||||
"FDC_ID_help": "FDC database ID",
|
||||
@ -409,6 +410,10 @@
|
||||
"show_sortby": "Show Sort By",
|
||||
"search_rank": "Search Rank",
|
||||
"make_now": "Make Now",
|
||||
"Created": "Created",
|
||||
"Updated": "Updated",
|
||||
"Unchanged": "Unchanged",
|
||||
"Error": "Error",
|
||||
"make_now_count": "At most missing ingredients",
|
||||
"recipe_filter": "Recipe Filter",
|
||||
"book_filter_help": "Include recipes from recipe filter in addition to manually assigned ones.",
|
||||
|
@ -47,7 +47,7 @@ export const useMealPlanStore = defineStore(_STORE_ID, {
|
||||
},
|
||||
actions: {
|
||||
refreshFromAPI(from_date, to_date) {
|
||||
if (this.currently_updating !== [from_date, to_date]) {
|
||||
if (this.currently_updating == null || (this.currently_updating[0] !== from_date || this.currently_updating[1] !== to_date)) {
|
||||
this.currently_updating = [from_date, to_date] // certainly no perfect check but better than nothing
|
||||
|
||||
let apiClient = new ApiApiFactory()
|
||||
@ -102,7 +102,7 @@ export const useMealPlanStore = defineStore(_STORE_ID, {
|
||||
},
|
||||
loadClientSettings() {
|
||||
let s = localStorage.getItem(_LOCAL_STORAGE_KEY)
|
||||
if (s === null || s === {}) {
|
||||
if (s === null) {
|
||||
return {
|
||||
displayPeriodUom: "week",
|
||||
displayPeriodCount: 3,
|
||||
|
@ -73,7 +73,7 @@ export const useUserPreferenceStore = defineStore(_STORE_ID, {
|
||||
*/
|
||||
loadDeviceSettings() {
|
||||
let s = localStorage.getItem(_LS_DEVICE_SETTINGS)
|
||||
if (!(s === null || s === {})) {
|
||||
if (s !== null) {
|
||||
let settings = JSON.parse(s)
|
||||
for (s in settings) {
|
||||
Vue.set(this.device_settings, s, settings[s])
|
||||
@ -90,7 +90,7 @@ export const useUserPreferenceStore = defineStore(_STORE_ID, {
|
||||
// ---------------- new methods for user settings
|
||||
loadUserSettings: function (allow_cached_results) {
|
||||
let s = localStorage.getItem(_LS_USER_SETTINGS)
|
||||
if (!(s === null || s === {})) {
|
||||
if (s !== null) {
|
||||
let settings = JSON.parse(s)
|
||||
for (s in settings) {
|
||||
Vue.set(this.user_settings, s, settings[s])
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -317,6 +317,15 @@ export function calculateAmount(amount, factor) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Replace spaces by dashes, then use DOM method to escape special characters. Use for dynamically generated CSS classes*/
|
||||
export const EscapeCSSMixin = {
|
||||
methods: {
|
||||
escapeCSS: function(classname) {
|
||||
return CSS.escape(classname.replace(/\s+/g, "-").toLowerCase())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function roundDecimals(num) {
|
||||
let decimals = getUserPreference("user_fractions") ? getUserPreference("user_fractions") : 2
|
||||
return +(Math.round(num + `e+${decimals}`) + `e-${decimals}`)
|
||||
@ -765,4 +774,4 @@ export const formFunctions = {
|
||||
}
|
||||
return form
|
||||
},
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user