Merge remote-tracking branch 'origin/develop' into HomeAssistantConnector

# Conflicts:
#	cookbook/forms.py
#	requirements.txt
This commit is contained in:
Mikhail Epifanov 2024-02-20 09:18:19 +01:00
commit 3e641e4d28
No known key found for this signature in database
41 changed files with 994 additions and 4156 deletions

View File

@ -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):

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -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

View 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)

View 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),
),
]

View 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),
),
]

View File

@ -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'),
),
]

View File

@ -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')

View File

@ -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

View File

@ -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

View File

@ -4,7 +4,7 @@
{% load i18n %}
{% load l10n %}
{% block title %}{% trans 'Search' %}{% endblock %}
{% block title %}{% trans 'Space Management' %}{% endblock %}
{% block content %}

View File

@ -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/',
}

View File

@ -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(

View File

@ -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)

View File

@ -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

View File

@ -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">

View File

@ -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">

View File

@ -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

View File

@ -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>

View File

@ -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">

View File

@ -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>

View File

@ -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">

View File

@ -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>

View File

@ -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
}
}
},
}

View File

@ -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>

View File

@ -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)
}
}
}

View File

@ -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)

View File

@ -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)
})

View File

@ -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 () {

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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.",

View File

@ -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,

View File

@ -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

View File

@ -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
},
}
}