mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2025-12-28 12:39:36 -05:00
Compare commits
135 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5aa80746f9 | ||
|
|
cc64717818 | ||
|
|
6acd892116 | ||
|
|
3955408aa4 | ||
|
|
3de2468df3 | ||
|
|
b1d983fbc3 | ||
|
|
5f443d2593 | ||
|
|
436158f596 | ||
|
|
dcc56fc138 | ||
|
|
0eef10079b | ||
|
|
2b839dfb19 | ||
|
|
491b678d6e | ||
|
|
151dce006d | ||
|
|
d4f538b4aa | ||
|
|
a727439c57 | ||
|
|
f779107749 | ||
|
|
4a5c8f41fa | ||
|
|
bf458e22e8 | ||
|
|
9b8088fca2 | ||
|
|
68435aa335 | ||
|
|
afe5465044 | ||
|
|
9decf3cf14 | ||
|
|
b31c3cfd2f | ||
|
|
1306c7381c | ||
|
|
dbd2025e71 | ||
|
|
f19f4abe0c | ||
|
|
7c4a854bfd | ||
|
|
04322b56a4 | ||
|
|
45b4ac3e9e | ||
|
|
362ed9b088 | ||
|
|
8bf347dd09 | ||
|
|
d449f0c2fc | ||
|
|
6dab514817 | ||
|
|
8ce0d416c2 | ||
|
|
dd88641763 | ||
|
|
fb52f34ef9 | ||
|
|
561c2f2d1f | ||
|
|
4b48c1046e | ||
|
|
3e0f2fbddc | ||
|
|
c5eb025186 | ||
|
|
23bfc3c3b0 | ||
|
|
813c7a46f1 | ||
|
|
6b475468fc | ||
|
|
053ff9506a | ||
|
|
11a699ed47 | ||
|
|
b3c6cacdad | ||
|
|
4875b158fd | ||
|
|
6bb04dc56d | ||
|
|
2dc038edc7 | ||
|
|
8597c3e95d | ||
|
|
5c0094fd43 | ||
|
|
23d67a5bd3 | ||
|
|
3a26f09307 | ||
|
|
2592e606cc | ||
|
|
11f2b95b4d | ||
|
|
c171a01b7d | ||
|
|
2671519386 | ||
|
|
19750cf499 | ||
|
|
711f80b1fb | ||
|
|
1ffa0f396a | ||
|
|
e052a7869d | ||
|
|
d57f35e4e8 | ||
|
|
2cb7030b04 | ||
|
|
a53f17c1b9 | ||
|
|
326549568f | ||
|
|
c0577abb89 | ||
|
|
a65e93a9b3 | ||
|
|
cadf14c338 | ||
|
|
7b49f1f437 | ||
|
|
2214540a51 | ||
|
|
256b7b1543 | ||
|
|
ebc213395d | ||
|
|
7af581f0ff | ||
|
|
aeb944b281 | ||
|
|
43105ddd2f | ||
|
|
f2b3cfb8f0 | ||
|
|
3302dacdc3 | ||
|
|
5f07ef04d2 | ||
|
|
4c69a0b721 | ||
|
|
2a538abf80 | ||
|
|
3236b65d9e | ||
|
|
79cd17a5ba | ||
|
|
06a08dcf6e | ||
|
|
e860d0aa83 | ||
|
|
0539e1ea15 | ||
|
|
6030fa1d68 | ||
|
|
2a5cba0178 | ||
|
|
9a77089c6d | ||
|
|
5f79895a97 | ||
|
|
19f5da77b2 | ||
|
|
2cc7278865 | ||
|
|
60f31608b9 | ||
|
|
763f71a05c | ||
|
|
e3921cd6a8 | ||
|
|
54a5c145cc | ||
|
|
86fd0dcab1 | ||
|
|
12da77f037 | ||
|
|
071926aada | ||
|
|
33d048e623 | ||
|
|
274fce5236 | ||
|
|
1046065f46 | ||
|
|
60243ad901 | ||
|
|
d62c49eb2f | ||
|
|
7e3313f48c | ||
|
|
1bb6eb7141 | ||
|
|
89e3e85d1e | ||
|
|
dfde340447 | ||
|
|
3ec02db2f6 | ||
|
|
b275c53e5a | ||
|
|
7d9fcac0c7 | ||
|
|
ec083214ef | ||
|
|
2a6fc723d0 | ||
|
|
25c914606e | ||
|
|
44cb2d9807 | ||
|
|
f90a66af1e | ||
|
|
b8cbda10f1 | ||
|
|
7e350b2f90 | ||
|
|
6d5592c1be | ||
|
|
9241638686 | ||
|
|
cb518a0cca | ||
|
|
6efe4ab08d | ||
|
|
27c5749b21 | ||
|
|
b10be8d321 | ||
|
|
8a648a5e41 | ||
|
|
fcf861f5eb | ||
|
|
1efcf386e2 | ||
|
|
38010117e5 | ||
|
|
c217bf2445 | ||
|
|
671269dca7 | ||
|
|
2e013e7b43 | ||
|
|
ff6c8d5822 | ||
|
|
a2b987352f | ||
|
|
5651beffb2 | ||
|
|
29bb391bfe | ||
|
|
3ced8c7a1e |
2
.github/workflows/build-docker-open-data.yml
vendored
2
.github/workflows/build-docker-open-data.yml
vendored
@@ -55,7 +55,7 @@ jobs:
|
||||
# Build Vue frontend
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14'
|
||||
node-version: '18'
|
||||
cache: yarn
|
||||
cache-dependency-path: vue/yarn.lock
|
||||
- name: Install dependencies
|
||||
|
||||
2
.github/workflows/build-docker.yml
vendored
2
.github/workflows/build-docker.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
# Build Vue frontend
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14'
|
||||
node-version: '18'
|
||||
cache: yarn
|
||||
cache-dependency-path: vue/yarn.lock
|
||||
- name: Install dependencies
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
# Build Vue frontend
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '16'
|
||||
node-version: '18'
|
||||
- name: Install Vue dependencies
|
||||
working-directory: ./vue
|
||||
run: yarn install
|
||||
|
||||
@@ -10,12 +10,13 @@ from treebeard.forms import movenodeform_factory
|
||||
|
||||
from cookbook.managers import DICTIONARY
|
||||
|
||||
from .models import (BookmarkletImport, Comment, CookLog, Food, FoodInheritField, ImportLog,
|
||||
Ingredient, InviteLink, Keyword, MealPlan, MealType, NutritionInformation,
|
||||
Recipe, RecipeBook, RecipeBookEntry, RecipeImport, SearchPreference, ShareLink,
|
||||
ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
|
||||
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog,
|
||||
TelegramBot, Unit, UserFile, UserPreference, ViewLog, Automation, UserSpace)
|
||||
from .models import (Automation, BookmarkletImport, Comment, CookLog, Food, FoodInheritField,
|
||||
ImportLog, Ingredient, InviteLink, Keyword, MealPlan, MealType,
|
||||
NutritionInformation, Property, PropertyType, Recipe, RecipeBook,
|
||||
RecipeBookEntry, RecipeImport, SearchPreference, ShareLink, ShoppingList,
|
||||
ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage, Supermarket,
|
||||
SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog, TelegramBot,
|
||||
Unit, UnitConversion, UserFile, UserPreference, UserSpace, ViewLog)
|
||||
|
||||
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
@@ -150,9 +151,16 @@ class KeywordAdmin(TreeAdmin):
|
||||
admin.site.register(Keyword, KeywordAdmin)
|
||||
|
||||
|
||||
@admin.action(description='Delete Steps not part of a Recipe.')
|
||||
def delete_unattached_steps(modeladmin, request, queryset):
|
||||
with scopes_disabled():
|
||||
Step.objects.filter(recipe=None).delete()
|
||||
|
||||
|
||||
class StepAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'order',)
|
||||
search_fields = ('name',)
|
||||
actions = [delete_unattached_steps]
|
||||
|
||||
|
||||
admin.site.register(Step, StepAdmin)
|
||||
@@ -201,9 +209,24 @@ class FoodAdmin(TreeAdmin):
|
||||
admin.site.register(Food, FoodAdmin)
|
||||
|
||||
|
||||
class UnitConversionAdmin(admin.ModelAdmin):
|
||||
list_display = ('base_amount', 'base_unit', 'food', 'converted_amount', 'converted_unit')
|
||||
search_fields = ('food__name', 'unit__name')
|
||||
|
||||
|
||||
admin.site.register(UnitConversion, UnitConversionAdmin)
|
||||
|
||||
|
||||
@admin.action(description='Delete Ingredients not part of a Recipe.')
|
||||
def delete_unattached_ingredients(modeladmin, request, queryset):
|
||||
with scopes_disabled():
|
||||
Ingredient.objects.filter(step__recipe=None).delete()
|
||||
|
||||
|
||||
class IngredientAdmin(admin.ModelAdmin):
|
||||
list_display = ('food', 'amount', 'unit')
|
||||
search_fields = ('food__name', 'unit__name')
|
||||
actions = [delete_unattached_ingredients]
|
||||
|
||||
|
||||
admin.site.register(Ingredient, IngredientAdmin)
|
||||
@@ -319,6 +342,20 @@ class ShareLinkAdmin(admin.ModelAdmin):
|
||||
admin.site.register(ShareLink, ShareLinkAdmin)
|
||||
|
||||
|
||||
class PropertyTypeAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'name')
|
||||
|
||||
|
||||
admin.site.register(PropertyType, PropertyTypeAdmin)
|
||||
|
||||
|
||||
class PropertyAdmin(admin.ModelAdmin):
|
||||
list_display = ('property_amount', 'property_type')
|
||||
|
||||
|
||||
admin.site.register(Property, PropertyAdmin)
|
||||
|
||||
|
||||
class NutritionInformationAdmin(admin.ModelAdmin):
|
||||
list_display = ('id',)
|
||||
|
||||
|
||||
11
cookbook/helper/cache_helper.py
Normal file
11
cookbook/helper/cache_helper.py
Normal file
@@ -0,0 +1,11 @@
|
||||
class CacheHelper:
|
||||
space = None
|
||||
|
||||
BASE_UNITS_CACHE_KEY = None
|
||||
PROPERTY_TYPE_CACHE_KEY = None
|
||||
|
||||
def __init__(self, space):
|
||||
self.space = space
|
||||
|
||||
self.BASE_UNITS_CACHE_KEY = f'SPACE_{space.id}_BASE_UNITS'
|
||||
self.PROPERTY_TYPE_CACHE_KEY = f'SPACE_{space.id}_PROPERTY_TYPES'
|
||||
@@ -40,7 +40,12 @@ def get_filetype(name):
|
||||
# TODO also add env variable to define which images sizes should be compressed
|
||||
# filetype argument can not be optional, otherwise this function will treat all images as if they were a jpeg
|
||||
# Because it's no longer optional, no reason to return it
|
||||
def handle_image(request, image_object, filetype):
|
||||
def handle_image(request, image_object, filetype):
|
||||
try:
|
||||
Image.open(image_object).verify()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if (image_object.size / 1000) > 500: # if larger than 500 kb compress
|
||||
if filetype == '.jpeg' or filetype == '.jpg':
|
||||
return rescale_image_jpeg(image_object)
|
||||
|
||||
214
cookbook/helper/open_data_importer.py
Normal file
214
cookbook/helper/open_data_importer.py
Normal file
@@ -0,0 +1,214 @@
|
||||
from django.db.models import Q
|
||||
|
||||
from cookbook.models import Unit, SupermarketCategory, Property, PropertyType, Supermarket, SupermarketCategoryRelation, Food, Automation, UnitConversion, FoodProperty
|
||||
|
||||
|
||||
class OpenDataImporter:
|
||||
request = None
|
||||
data = {}
|
||||
slug_id_cache = {}
|
||||
update_existing = False
|
||||
use_metric = True
|
||||
|
||||
def __init__(self, request, data, update_existing=False, use_metric=True):
|
||||
self.request = request
|
||||
self.data = data
|
||||
self.update_existing = update_existing
|
||||
self.use_metric = use_metric
|
||||
|
||||
def _update_slug_cache(self, object_class, datatype):
|
||||
self.slug_id_cache[datatype] = dict(object_class.objects.filter(space=self.request.space, open_data_slug__isnull=False).values_list('open_data_slug', 'id', ))
|
||||
|
||||
def import_units(self):
|
||||
datatype = 'unit'
|
||||
|
||||
insert_list = []
|
||||
for u in list(self.data[datatype].keys()):
|
||||
insert_list.append(Unit(
|
||||
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,
|
||||
open_data_slug=u,
|
||||
space=self.request.space
|
||||
))
|
||||
|
||||
if self.update_existing:
|
||||
return Unit.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('name', 'plural_name', 'base_unit', 'open_data_slug'), unique_fields=('space', 'name',))
|
||||
else:
|
||||
return Unit.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('open_data_slug',), unique_fields=('space', 'name',))
|
||||
|
||||
def import_category(self):
|
||||
datatype = 'category'
|
||||
|
||||
insert_list = []
|
||||
for k in list(self.data[datatype].keys()):
|
||||
insert_list.append(SupermarketCategory(
|
||||
name=self.data[datatype][k]['name'],
|
||||
open_data_slug=k,
|
||||
space=self.request.space
|
||||
))
|
||||
|
||||
return SupermarketCategory.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('open_data_slug',), unique_fields=('space', 'name',))
|
||||
|
||||
def import_property(self):
|
||||
datatype = 'property'
|
||||
|
||||
insert_list = []
|
||||
for k in list(self.data[datatype].keys()):
|
||||
insert_list.append(PropertyType(
|
||||
name=self.data[datatype][k]['name'],
|
||||
unit=self.data[datatype][k]['unit'],
|
||||
open_data_slug=k,
|
||||
space=self.request.space
|
||||
))
|
||||
|
||||
return PropertyType.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('open_data_slug',), unique_fields=('space', 'name',))
|
||||
|
||||
def import_supermarket(self):
|
||||
datatype = 'store'
|
||||
|
||||
self._update_slug_cache(SupermarketCategory, 'category')
|
||||
insert_list = []
|
||||
for k in list(self.data[datatype].keys()):
|
||||
insert_list.append(Supermarket(
|
||||
name=self.data[datatype][k]['name'],
|
||||
open_data_slug=k,
|
||||
space=self.request.space
|
||||
))
|
||||
|
||||
# always add open data slug if matching supermarket is found, otherwise relation might fail
|
||||
supermarkets = Supermarket.objects.bulk_create(insert_list, unique_fields=('space', 'name',), update_conflicts=True, update_fields=('open_data_slug',))
|
||||
self._update_slug_cache(Supermarket, 'store')
|
||||
|
||||
insert_list = []
|
||||
for k in list(self.data[datatype].keys()):
|
||||
relations = []
|
||||
order = 0
|
||||
for c in self.data[datatype][k]['categories']:
|
||||
relations.append(
|
||||
SupermarketCategoryRelation(
|
||||
supermarket_id=self.slug_id_cache[datatype][k],
|
||||
category_id=self.slug_id_cache['category'][c],
|
||||
order=order,
|
||||
)
|
||||
)
|
||||
order += 1
|
||||
|
||||
SupermarketCategoryRelation.objects.bulk_create(relations, ignore_conflicts=True, unique_fields=('supermarket', 'category',))
|
||||
|
||||
return supermarkets
|
||||
|
||||
def import_food(self):
|
||||
identifier_list = []
|
||||
datatype = 'food'
|
||||
for k in list(self.data[datatype].keys()):
|
||||
identifier_list.append(self.data[datatype][k]['name'])
|
||||
identifier_list.append(self.data[datatype][k]['plural_name'])
|
||||
|
||||
existing_objects_flat = []
|
||||
existing_objects = {}
|
||||
for f in Food.objects.filter(space=self.request.space).filter(name__in=identifier_list).values_list('id', 'name', 'plural_name'):
|
||||
existing_objects_flat.append(f[1])
|
||||
existing_objects_flat.append(f[2])
|
||||
existing_objects[f[1]] = f
|
||||
existing_objects[f[2]] = f
|
||||
|
||||
self._update_slug_cache(Unit, 'unit')
|
||||
self._update_slug_cache(PropertyType, 'property')
|
||||
|
||||
# pref_unit_key = 'preferred_unit_metric'
|
||||
# pref_shopping_unit_key = 'preferred_packaging_unit_metric'
|
||||
# if not self.use_metric:
|
||||
# pref_unit_key = 'preferred_unit_imperial'
|
||||
# pref_shopping_unit_key = 'preferred_packaging_unit_imperial'
|
||||
|
||||
insert_list = []
|
||||
update_list = []
|
||||
update_field_list = []
|
||||
for k in list(self.data[datatype].keys()):
|
||||
if not (self.data[datatype][k]['name'] in existing_objects_flat or self.data[datatype][k]['plural_name'] in existing_objects_flat):
|
||||
insert_list.append({'data': {
|
||||
'name': self.data[datatype][k]['name'],
|
||||
'plural_name': self.data[datatype][k]['plural_name'] if self.data[datatype][k]['plural_name'] != '' else None,
|
||||
# 'preferred_unit_id': self.slug_id_cache['unit'][self.data[datatype][k][pref_unit_key]],
|
||||
# 'preferred_shopping_unit_id': self.slug_id_cache['unit'][self.data[datatype][k][pref_shopping_unit_key]],
|
||||
'supermarket_category_id': self.slug_id_cache['category'][self.data[datatype][k]['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,
|
||||
}})
|
||||
else:
|
||||
if self.data[datatype][k]['name'] in existing_objects:
|
||||
existing_food_id = existing_objects[self.data[datatype][k]['name']][0]
|
||||
else:
|
||||
existing_food_id = existing_objects[self.data[datatype][k]['plural_name']][0]
|
||||
|
||||
if self.update_existing:
|
||||
update_field_list = ['name', 'plural_name', 'preferred_unit_id', 'preferred_shopping_unit_id', 'supermarket_category_id', 'fdc_id', 'open_data_slug', ]
|
||||
update_list.append(Food(
|
||||
id=existing_food_id,
|
||||
name=self.data[datatype][k]['name'],
|
||||
plural_name=self.data[datatype][k]['plural_name'] if self.data[datatype][k]['plural_name'] != '' else None,
|
||||
# preferred_unit_id=self.slug_id_cache['unit'][self.data[datatype][k][pref_unit_key]],
|
||||
# preferred_shopping_unit_id=self.slug_id_cache['unit'][self.data[datatype][k][pref_shopping_unit_key]],
|
||||
supermarket_category_id=self.slug_id_cache['category'][self.data[datatype][k]['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, ))
|
||||
|
||||
Food.load_bulk(insert_list, None)
|
||||
if len(update_list) > 0:
|
||||
Food.objects.bulk_update(update_list, update_field_list)
|
||||
|
||||
self._update_slug_cache(Food, 'food')
|
||||
|
||||
food_property_list = []
|
||||
alias_list = []
|
||||
for k in list(self.data[datatype].keys()):
|
||||
for fp in self.data[datatype][k]['properties']['type_values']:
|
||||
food_property_list.append(Property(
|
||||
property_type_id=self.slug_id_cache['property'][fp['property_type']],
|
||||
property_amount=fp['property_value'],
|
||||
import_food_id=self.slug_id_cache['food'][k],
|
||||
space=self.request.space,
|
||||
))
|
||||
|
||||
# for a in self.data[datatype][k]['alias']:
|
||||
# alias_list.append(Automation(
|
||||
# param_1=a,
|
||||
# param_2=self.data[datatype][k]['name'],
|
||||
# space=self.request.space,
|
||||
# created_by=self.request.user,
|
||||
# ))
|
||||
|
||||
Property.objects.bulk_create(food_property_list, ignore_conflicts=True, unique_fields=('space', 'import_food_id', 'property_type',))
|
||||
|
||||
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]))
|
||||
|
||||
FoodProperty.objects.bulk_create(property_food_relation_list, ignore_conflicts=True, unique_fields=('food_id', 'property_id',))
|
||||
|
||||
# Automation.objects.bulk_create(alias_list, ignore_conflicts=True, unique_fields=('space', 'param_1', 'param_2',))
|
||||
return insert_list + update_list
|
||||
|
||||
def import_conversion(self):
|
||||
datatype = 'conversion'
|
||||
|
||||
insert_list = []
|
||||
for k in list(self.data[datatype].keys()):
|
||||
insert_list.append(UnitConversion(
|
||||
base_amount=self.data[datatype][k]['base_amount'],
|
||||
base_unit_id=self.slug_id_cache['unit'][self.data[datatype][k]['base_unit']],
|
||||
converted_amount=self.data[datatype][k]['converted_amount'],
|
||||
converted_unit_id=self.slug_id_cache['unit'][self.data[datatype][k]['converted_unit']],
|
||||
food_id=self.slug_id_cache['food'][self.data[datatype][k]['food']],
|
||||
open_data_slug=k,
|
||||
space=self.request.space,
|
||||
created_by=self.request.user,
|
||||
))
|
||||
|
||||
return UnitConversion.objects.bulk_create(insert_list, ignore_conflicts=True, unique_fields=('space', 'base_unit', 'converted_unit', 'food', 'open_data_slug'))
|
||||
71
cookbook/helper/property_helper.py
Normal file
71
cookbook/helper/property_helper.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from django.core.cache import caches
|
||||
|
||||
from cookbook.helper.cache_helper import CacheHelper
|
||||
from cookbook.helper.unit_conversion_helper import UnitConversionHelper
|
||||
from cookbook.models import PropertyType, Unit, Food, Property, Recipe, Step
|
||||
|
||||
|
||||
class FoodPropertyHelper:
|
||||
space = None
|
||||
|
||||
def __init__(self, space):
|
||||
"""
|
||||
Helper to perform food property calculations
|
||||
:param space: space to limit scope to
|
||||
"""
|
||||
self.space = space
|
||||
|
||||
def calculate_recipe_properties(self, recipe):
|
||||
"""
|
||||
Calculate all food properties for a given recipe.
|
||||
:param recipe: recipe to calculate properties for
|
||||
:return: dict of with property keys and total/food values for each property available
|
||||
"""
|
||||
ingredients = []
|
||||
computed_properties = {}
|
||||
|
||||
for s in recipe.steps.all():
|
||||
ingredients += s.ingredients.all()
|
||||
|
||||
property_types = caches['default'].get(CacheHelper(self.space).PROPERTY_TYPE_CACHE_KEY, None)
|
||||
|
||||
if not property_types:
|
||||
property_types = PropertyType.objects.filter(space=self.space).all()
|
||||
caches['default'].set(CacheHelper(self.space).PROPERTY_TYPE_CACHE_KEY, property_types, 60 * 60) # cache is cleared on property type save signal so long duration is fine
|
||||
|
||||
for fpt in property_types:
|
||||
computed_properties[fpt.id] = {'id': fpt.id, 'name': fpt.name, 'icon': fpt.icon, 'description': fpt.description, 'unit': fpt.unit, 'food_values': {}, 'total_value': 0, 'missing_value': False}
|
||||
|
||||
uch = UnitConversionHelper(self.space)
|
||||
|
||||
for i in ingredients:
|
||||
if i.food is not None:
|
||||
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
|
||||
else:
|
||||
for p in i.food.properties.all():
|
||||
if p.property_type == pt:
|
||||
for c in conversions:
|
||||
if c.unit == i.food.properties_food_unit:
|
||||
found_property = True
|
||||
computed_properties[pt.id]['total_value'] += (c.amount / i.food.properties_food_amount) * p.property_amount
|
||||
computed_properties[pt.id]['food_values'] = self.add_or_create(computed_properties[p.property_type.id]['food_values'], c.food.id, (c.amount / i.food.properties_food_amount) * p.property_amount, c.food)
|
||||
if not found_property:
|
||||
computed_properties[pt.id]['missing_value'] = True
|
||||
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': i.food.name, 'value': 0}
|
||||
|
||||
return computed_properties
|
||||
|
||||
# small dict helper to add to existing key or create new, probably a better way of doing this
|
||||
# TODO move to central helper ?
|
||||
@staticmethod
|
||||
def add_or_create(d, key, value, food):
|
||||
if key in d:
|
||||
d[key]['value'] += value
|
||||
else:
|
||||
d[key] = {'id': food.id, 'food': food.name, 'value': value}
|
||||
return d
|
||||
@@ -1,5 +1,6 @@
|
||||
# import random
|
||||
import re
|
||||
import traceback
|
||||
from html import unescape
|
||||
|
||||
from django.core.cache import caches
|
||||
@@ -12,7 +13,8 @@ from recipe_scrapers._utils import get_host_name, get_minutes
|
||||
|
||||
# from cookbook.helper import recipe_url_import as helper
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.models import Automation, Keyword
|
||||
from cookbook.models import Automation, Keyword, PropertyType
|
||||
|
||||
|
||||
# from unicodedata import decomposition
|
||||
|
||||
@@ -33,6 +35,9 @@ def get_from_scraper(scrape, request):
|
||||
except Exception:
|
||||
recipe_json['name'] = ''
|
||||
|
||||
if isinstance(recipe_json['name'], list) and len(recipe_json['name']) > 0:
|
||||
recipe_json['name'] = recipe_json['name'][0]
|
||||
|
||||
try:
|
||||
description = scrape.description() or None
|
||||
except Exception:
|
||||
@@ -193,7 +198,14 @@ def get_from_scraper(scrape, request):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if recipe_json['source_url']:
|
||||
try:
|
||||
recipe_json['properties'] = get_recipe_properties(request.space, scrape.schema.nutrients())
|
||||
print(recipe_json['properties'])
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
pass
|
||||
|
||||
if 'source_url' in recipe_json and recipe_json['source_url']:
|
||||
automations = Automation.objects.filter(type=Automation.INSTRUCTION_REPLACE, space=request.space, disabled=False).only('param_1', 'param_2', 'param_3').order_by('order').all()[:512]
|
||||
for a in automations:
|
||||
if re.match(a.param_1, (recipe_json['source_url'])[:512]):
|
||||
@@ -203,6 +215,30 @@ def get_from_scraper(scrape, request):
|
||||
return recipe_json
|
||||
|
||||
|
||||
def get_recipe_properties(space, property_data):
|
||||
# {'servingSize': '1', 'calories': '302 kcal', 'proteinContent': '7,66g', 'fatContent': '11,56g', 'carbohydrateContent': '41,33g'}
|
||||
properties = {
|
||||
"property-calories": "calories",
|
||||
"property-carbohydrates": "carbohydrateContent",
|
||||
"property-proteins": "proteinContent",
|
||||
"property-fats": "fatContent",
|
||||
}
|
||||
recipe_properties = []
|
||||
for pt in PropertyType.objects.filter(space=space, open_data_slug__in=list(properties.keys())).all():
|
||||
for p in list(properties.keys()):
|
||||
if pt.open_data_slug == p:
|
||||
if properties[p] in property_data:
|
||||
recipe_properties.append({
|
||||
'property_type': {
|
||||
'id': pt.id,
|
||||
'name': pt.name,
|
||||
},
|
||||
'property_amount': parse_servings(property_data[properties[p]]) / float(property_data['servingSize']),
|
||||
})
|
||||
|
||||
return recipe_properties
|
||||
|
||||
|
||||
def get_from_youtube_scraper(url, request):
|
||||
"""A YouTube Information Scraper."""
|
||||
kw, created = Keyword.objects.get_or_create(name='YouTube', space=request.space)
|
||||
@@ -373,7 +409,7 @@ def parse_keywords(keyword_json, space):
|
||||
# retrieve keyword automation cache if it exists, otherwise build from database
|
||||
KEYWORD_CACHE_KEY = f'automation_keyword_alias_{space.pk}'
|
||||
if c := caches['default'].get(KEYWORD_CACHE_KEY, None):
|
||||
self.food_aliases = c
|
||||
keyword_aliases = c
|
||||
caches['default'].touch(KEYWORD_CACHE_KEY, 30)
|
||||
else:
|
||||
for a in Automation.objects.filter(space=space, disabled=False, type=Automation.KEYWORD_ALIAS).only('param_1', 'param_2').order_by('order').all():
|
||||
|
||||
141
cookbook/helper/unit_conversion_helper.py
Normal file
141
cookbook/helper/unit_conversion_helper.py
Normal file
@@ -0,0 +1,141 @@
|
||||
from django.core.cache import caches
|
||||
from decimal import Decimal
|
||||
|
||||
from cookbook.helper.cache_helper import CacheHelper
|
||||
from cookbook.models import Ingredient, Unit
|
||||
|
||||
CONVERSION_TABLE = {
|
||||
'weight': {
|
||||
'g': 1000,
|
||||
'kg': 1,
|
||||
'ounce': 35.274,
|
||||
'pound': 2.20462
|
||||
},
|
||||
'volume': {
|
||||
'ml': 1000,
|
||||
'l': 1,
|
||||
'fluid_ounce': 33.814,
|
||||
'pint': 2.11338,
|
||||
'quart': 1.05669,
|
||||
'gallon': 0.264172,
|
||||
'tbsp': 67.628,
|
||||
'tsp': 202.884,
|
||||
'imperial_fluid_ounce': 35.1951,
|
||||
'imperial_pint': 1.75975,
|
||||
'imperial_quart': 0.879877,
|
||||
'imperial_gallon': 0.219969,
|
||||
'imperial_tbsp': 56.3121,
|
||||
'imperial_tsp': 168.936,
|
||||
},
|
||||
}
|
||||
|
||||
BASE_UNITS_WEIGHT = list(CONVERSION_TABLE['weight'].keys())
|
||||
BASE_UNITS_VOLUME = list(CONVERSION_TABLE['volume'].keys())
|
||||
|
||||
|
||||
class ConversionException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UnitConversionHelper:
|
||||
space = None
|
||||
|
||||
def __init__(self, space):
|
||||
"""
|
||||
Initializes unit conversion helper
|
||||
:param space: space to perform conversions on
|
||||
"""
|
||||
self.space = space
|
||||
|
||||
@staticmethod
|
||||
def convert_from_to(from_unit, to_unit, amount):
|
||||
"""
|
||||
Convert from one base unit to another. Throws ConversionException if trying to convert between different systems (weight/volume) or if units are not supported.
|
||||
:param from_unit: str unit to convert from
|
||||
:param to_unit: str unit to convert to
|
||||
:param amount: amount to convert
|
||||
:return: Decimal converted amount
|
||||
"""
|
||||
system = None
|
||||
if from_unit in BASE_UNITS_WEIGHT and to_unit in BASE_UNITS_WEIGHT:
|
||||
system = 'weight'
|
||||
if from_unit in BASE_UNITS_VOLUME and to_unit in BASE_UNITS_VOLUME:
|
||||
system = 'volume'
|
||||
|
||||
if not system:
|
||||
raise ConversionException('Trying to convert units not existing or not in one unit system (weight/volume)')
|
||||
|
||||
return Decimal(amount / Decimal(CONVERSION_TABLE[system][from_unit] / CONVERSION_TABLE[system][to_unit]))
|
||||
|
||||
def base_conversions(self, ingredient_list):
|
||||
"""
|
||||
Calculates all possible base unit conversions for each ingredient give.
|
||||
Converts to all common base units IF they exist in the unit database of the space.
|
||||
For useful results all ingredients passed should be of the same food, otherwise filtering afterwards might be required.
|
||||
:param ingredient_list: list of ingredients to convert
|
||||
:return: ingredient list with appended conversions
|
||||
"""
|
||||
base_conversion_ingredient_list = ingredient_list.copy()
|
||||
for i in ingredient_list:
|
||||
try:
|
||||
conversion_unit = i.unit.name
|
||||
if i.unit.base_unit:
|
||||
conversion_unit = i.unit.base_unit
|
||||
|
||||
# TODO allow setting which units to convert to? possibly only once conversions become visible
|
||||
units = caches['default'].get(CacheHelper(self.space).BASE_UNITS_CACHE_KEY, None)
|
||||
if not units:
|
||||
units = Unit.objects.filter(space=self.space, base_unit__in=(BASE_UNITS_VOLUME + BASE_UNITS_WEIGHT)).all()
|
||||
caches['default'].set(CacheHelper(self.space).BASE_UNITS_CACHE_KEY, units, 60 * 60) # cache is cleared on unit save signal so long duration is fine
|
||||
|
||||
for u in units:
|
||||
try:
|
||||
ingredient = Ingredient(amount=self.convert_from_to(conversion_unit, u.base_unit, i.amount), unit=u, food=ingredient_list[0].food, )
|
||||
if not any((x.unit.name == ingredient.unit.name or x.unit.base_unit == ingredient.unit.name) for x in base_conversion_ingredient_list):
|
||||
base_conversion_ingredient_list.append(ingredient)
|
||||
except ConversionException:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return base_conversion_ingredient_list
|
||||
|
||||
def get_conversions(self, ingredient):
|
||||
"""
|
||||
Converts an ingredient to all possible conversions based on the custom unit conversion database.
|
||||
After that passes conversion to UnitConversionHelper.base_conversions() to get all base conversions possible.
|
||||
:param ingredient: Ingredient object
|
||||
:return: list of ingredients with all possible custom and base conversions
|
||||
"""
|
||||
conversions = [ingredient]
|
||||
if ingredient.unit:
|
||||
for c in ingredient.unit.unit_conversion_base_relation.all():
|
||||
if c.space == self.space:
|
||||
r = self._uc_convert(c, ingredient.amount, ingredient.unit, ingredient.food)
|
||||
if r and r not in conversions:
|
||||
conversions.append(r)
|
||||
for c in ingredient.unit.unit_conversion_converted_relation.all():
|
||||
if c.space == self.space:
|
||||
r = self._uc_convert(c, ingredient.amount, ingredient.unit, ingredient.food)
|
||||
if r and r not in conversions:
|
||||
conversions.append(r)
|
||||
|
||||
conversions = self.base_conversions(conversions)
|
||||
|
||||
return conversions
|
||||
|
||||
def _uc_convert(self, uc, amount, unit, food):
|
||||
"""
|
||||
Helper to calculate values for custom unit conversions.
|
||||
Converts given base values using the passed UnitConversion object into a converted Ingredient
|
||||
:param uc: UnitConversion object
|
||||
:param amount: base amount
|
||||
:param unit: base unit
|
||||
:param food: base food
|
||||
:return: converted ingredient object from base amount/unit/food
|
||||
"""
|
||||
if uc.food is None or uc.food == food:
|
||||
if unit == uc.base_unit:
|
||||
return Ingredient(amount=amount * (uc.converted_amount / uc.base_amount), unit=uc.converted_unit, food=food, space=self.space)
|
||||
else:
|
||||
return Ingredient(amount=amount * (uc.base_amount / uc.converted_amount), unit=uc.base_unit, food=food, space=self.space)
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import traceback
|
||||
from io import BytesIO, StringIO
|
||||
from re import match
|
||||
from zipfile import ZipFile
|
||||
@@ -19,7 +20,10 @@ class Default(Integration):
|
||||
recipe = self.decode_recipe(recipe_string)
|
||||
images = list(filter(lambda v: match('image.*', v), recipe_zip.namelist()))
|
||||
if images:
|
||||
self.import_recipe_image(recipe, BytesIO(recipe_zip.read(images[0])), filetype=get_filetype(images[0]))
|
||||
try:
|
||||
self.import_recipe_image(recipe, BytesIO(recipe_zip.read(images[0])), filetype=get_filetype(images[0]))
|
||||
except AttributeError as e:
|
||||
traceback.print_exc()
|
||||
return recipe
|
||||
|
||||
def decode_recipe(self, string):
|
||||
|
||||
@@ -51,9 +51,15 @@ class NextcloudCookbook(Integration):
|
||||
|
||||
ingredients_added = False
|
||||
for s in recipe_json['recipeInstructions']:
|
||||
step = Step.objects.create(
|
||||
instruction=s, space=self.request.space,
|
||||
)
|
||||
instruction_text = ''
|
||||
if 'text' in s:
|
||||
step = Step.objects.create(
|
||||
instruction=s['text'], name=s['name'], space=self.request.space,
|
||||
)
|
||||
else:
|
||||
step = Step.objects.create(
|
||||
instruction=s, space=self.request.space,
|
||||
)
|
||||
if not ingredients_added:
|
||||
if len(recipe_json['description'].strip()) > 500:
|
||||
step.instruction = recipe_json['description'].strip() + '\n\n' + step.instruction
|
||||
@@ -98,11 +104,10 @@ class NextcloudCookbook(Integration):
|
||||
return recipe
|
||||
|
||||
def formatTime(self, min):
|
||||
h = min//60
|
||||
h = min // 60
|
||||
m = min % 60
|
||||
return f'PT{h}H{m}M0S'
|
||||
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
|
||||
export = {}
|
||||
@@ -111,7 +116,7 @@ class NextcloudCookbook(Integration):
|
||||
export['url'] = recipe.source_url
|
||||
export['prepTime'] = self.formatTime(recipe.working_time)
|
||||
export['cookTime'] = self.formatTime(recipe.waiting_time)
|
||||
export['totalTime'] = self.formatTime(recipe.working_time+recipe.waiting_time)
|
||||
export['totalTime'] = self.formatTime(recipe.working_time + recipe.waiting_time)
|
||||
export['recipeYield'] = recipe.servings
|
||||
export['image'] = f'/Recipes/{recipe.name}/full.jpg'
|
||||
export['imageUrl'] = f'/Recipes/{recipe.name}/full.jpg'
|
||||
@@ -133,7 +138,6 @@ class NextcloudCookbook(Integration):
|
||||
export['recipeIngredient'] = recipeIngredient
|
||||
export['recipeInstructions'] = recipeInstructions
|
||||
|
||||
|
||||
return "recipe.json", json.dumps(export)
|
||||
|
||||
def get_files_from_recipes(self, recipes, el, cookie):
|
||||
@@ -163,7 +167,7 @@ class NextcloudCookbook(Integration):
|
||||
|
||||
export_zip_obj.close()
|
||||
|
||||
return [[ self.get_export_file_name(), export_zip_stream.getvalue() ]]
|
||||
return [[self.get_export_file_name(), export_zip_stream.getvalue()]]
|
||||
|
||||
def getJPEG(self, imageByte):
|
||||
image = Image.open(BytesIO(imageByte))
|
||||
@@ -172,14 +176,14 @@ class NextcloudCookbook(Integration):
|
||||
bytes = BytesIO()
|
||||
image.save(bytes, "JPEG")
|
||||
return bytes.getvalue()
|
||||
|
||||
|
||||
def getThumb(self, size, imageByte):
|
||||
image = Image.open(BytesIO(imageByte))
|
||||
|
||||
w, h = image.size
|
||||
m = min(w, h)
|
||||
m = min(w, h)
|
||||
|
||||
image = image.crop(((w-m)//2, (h-m)//2, (w+m)//2, (h+m)//2))
|
||||
image = image.crop(((w - m) // 2, (h - m) // 2, (w + m) // 2, (h + m) // 2))
|
||||
image = image.resize([size, size], Image.Resampling.LANCZOS)
|
||||
image = image.convert('RGB')
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from io import BytesIO
|
||||
|
||||
import requests
|
||||
import validators
|
||||
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.integration.integration import Integration
|
||||
@@ -67,8 +68,9 @@ class Plantoeat(Integration):
|
||||
|
||||
if image_url:
|
||||
try:
|
||||
response = requests.get(image_url)
|
||||
self.import_recipe_image(recipe, BytesIO(response.content))
|
||||
if validators.url(image_url, public=True):
|
||||
response = requests.get(image_url)
|
||||
self.import_recipe_image(recipe, BytesIO(response.content))
|
||||
except Exception as e:
|
||||
print('failed to import image ', str(e))
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import requests
|
||||
import validators
|
||||
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text, parse_time
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Ingredient, Recipe, Step
|
||||
|
||||
@@ -18,19 +19,21 @@ class RecipeSage(Integration):
|
||||
created_by=self.request.user, internal=True,
|
||||
space=self.request.space)
|
||||
|
||||
if file['recipeYield'] != '':
|
||||
recipe.servings = parse_servings(file['recipeYield'])
|
||||
recipe.servings_text = parse_servings_text(file['recipeYield'])
|
||||
|
||||
try:
|
||||
if file['recipeYield'] != '':
|
||||
recipe.servings = int(file['recipeYield'])
|
||||
if 'totalTime' in file and file['totalTime'] != '':
|
||||
recipe.working_time = parse_time(file['totalTime'])
|
||||
|
||||
if file['totalTime'] != '':
|
||||
recipe.waiting_time = int(file['totalTime']) - int(file['timePrep'])
|
||||
|
||||
if file['prepTime'] != '':
|
||||
recipe.working_time = int(file['timePrep'])
|
||||
|
||||
recipe.save()
|
||||
if 'timePrep' in file and file['prepTime'] != '':
|
||||
recipe.working_time = parse_time(file['timePrep'])
|
||||
recipe.waiting_time = parse_time(file['totalTime']) - parse_time(file['timePrep'])
|
||||
except Exception as e:
|
||||
print('failed to parse yield or time ', str(e))
|
||||
print('failed to parse time ', str(e))
|
||||
|
||||
recipe.save()
|
||||
|
||||
ingredient_parser = IngredientParser(self.request, True)
|
||||
ingredients_added = False
|
||||
|
||||
@@ -22,9 +22,12 @@ class Rezeptsuitede(Integration):
|
||||
name=recipe_xml.find('head').attrib['title'].strip(),
|
||||
created_by=self.request.user, internal=True, space=self.request.space)
|
||||
|
||||
if recipe_xml.find('head').attrib['servingtype']:
|
||||
recipe.servings = parse_servings(recipe_xml.find('head').attrib['servingtype'].strip())
|
||||
recipe.servings_text = parse_servings_text(recipe_xml.find('head').attrib['servingtype'].strip())
|
||||
try:
|
||||
if recipe_xml.find('head').attrib['servingtype']:
|
||||
recipe.servings = parse_servings(recipe_xml.find('head').attrib['servingtype'].strip())
|
||||
recipe.servings_text = parse_servings_text(recipe_xml.find('head').attrib['servingtype'].strip())
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
if recipe_xml.find('remark') is not None: # description is a list of <li>'s with text
|
||||
if recipe_xml.find('remark').find('line') is not None:
|
||||
@@ -50,7 +53,9 @@ class Rezeptsuitede(Integration):
|
||||
for ingredient in recipe_xml.find('part').findall('ingredient'):
|
||||
f = ingredient_parser.get_food(ingredient.attrib['item'])
|
||||
u = ingredient_parser.get_unit(ingredient.attrib['unit'])
|
||||
amount, unit, note = ingredient_parser.parse_amount(ingredient.attrib['qty'])
|
||||
amount = 0
|
||||
if ingredient.attrib['qty'].strip() != '':
|
||||
amount, unit, note = ingredient_parser.parse_amount(ingredient.attrib['qty'])
|
||||
ingredient_step.ingredients.add(Ingredient.objects.create(food=f, unit=u, amount=amount, space=self.request.space, ))
|
||||
|
||||
try:
|
||||
|
||||
@@ -15,10 +15,10 @@ 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: 2023-02-09 13:55+0000\n"
|
||||
"Last-Translator: Marion Kämpfer <marion@murphyslantech.de>\n"
|
||||
"Language-Team: German <http://translate.tandoor.dev/projects/tandoor/recipes-"
|
||||
"backend/de/>\n"
|
||||
"PO-Revision-Date: 2023-06-21 14:19+0000\n"
|
||||
"Last-Translator: Tobias Huppertz <tobias.huppertz@mail.de>\n"
|
||||
"Language-Team: German <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/de/>\n"
|
||||
"Language: de\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
@@ -600,9 +600,8 @@ msgid "Imported %s recipes."
|
||||
msgstr "%s Rezepte importiert."
|
||||
|
||||
#: .\cookbook\integration\openeats.py:26
|
||||
#, fuzzy
|
||||
msgid "Recipe source:"
|
||||
msgstr "Rezept-Hauptseite"
|
||||
msgstr "Rezept-Quelle:"
|
||||
|
||||
#: .\cookbook\integration\paprika.py:49
|
||||
msgid "Notes"
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -14,8 +14,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: 2023-03-13 06:55+0000\n"
|
||||
"Last-Translator: Amara Ude <apu24@drexel.edu>\n"
|
||||
"PO-Revision-Date: 2023-05-26 16:19+0000\n"
|
||||
"Last-Translator: Luis Cacho <luiscachog@gmail.com>\n"
|
||||
"Language-Team: Spanish <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/es/>\n"
|
||||
"Language: es\n"
|
||||
@@ -603,10 +603,8 @@ msgid "Imported %s recipes."
|
||||
msgstr "Se importaron %s recetas."
|
||||
|
||||
#: .\cookbook\integration\openeats.py:26
|
||||
#, fuzzy
|
||||
#| msgid "Recipe Home"
|
||||
msgid "Recipe source:"
|
||||
msgstr "Página de inicio"
|
||||
msgstr "Recipe source:"
|
||||
|
||||
#: .\cookbook\integration\paprika.py:49
|
||||
msgid "Notes"
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
# Generated by Django 4.1.9 on 2023-05-25 13:05
|
||||
|
||||
import cookbook.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_prometheus.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('cookbook', '0188_space_no_sharing_limit'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Property',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('property_amount', models.DecimalField(decimal_places=4, default=0, max_digits=32)),
|
||||
],
|
||||
bases=(models.Model, cookbook.models.PermissionModelMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PropertyType',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=128)),
|
||||
('unit', models.CharField(blank=True, max_length=64, null=True)),
|
||||
('icon', models.CharField(blank=True, max_length=16, null=True)),
|
||||
('description', models.CharField(blank=True, max_length=512, null=True)),
|
||||
('category', models.CharField(blank=True, choices=[('NUTRITION', 'Nutrition'), ('ALLERGEN', 'Allergen'), ('PRICE', 'Price'), ('GOAL', 'Goal'), ('OTHER', 'Other')], max_length=64, null=True)),
|
||||
('open_data_slug', models.CharField(blank=True, default=None, max_length=128, null=True)),
|
||||
],
|
||||
bases=(models.Model, cookbook.models.PermissionModelMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UnitConversion',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('base_amount', models.DecimalField(decimal_places=16, default=0, max_digits=32)),
|
||||
('converted_amount', models.DecimalField(decimal_places=16, default=0, max_digits=32)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('open_data_slug', models.CharField(blank=True, default=None, max_length=128, null=True)),
|
||||
],
|
||||
bases=(django_prometheus.models.ExportModelOperationsMixin('unit_conversion'), models.Model, cookbook.models.PermissionModelMixin),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='food',
|
||||
name='fdc_id',
|
||||
field=models.CharField(blank=True, default=None, max_length=128, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='food',
|
||||
name='open_data_slug',
|
||||
field=models.CharField(blank=True, default=None, max_length=128, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='food',
|
||||
name='preferred_shopping_unit',
|
||||
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='preferred_shopping_unit', to='cookbook.unit'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='food',
|
||||
name='preferred_unit',
|
||||
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='preferred_unit', to='cookbook.unit'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='food',
|
||||
name='properties_food_amount',
|
||||
field=models.IntegerField(blank=True, default=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='food',
|
||||
name='properties_food_unit',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='cookbook.unit'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='supermarket',
|
||||
name='open_data_slug',
|
||||
field=models.CharField(blank=True, default=None, max_length=128, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='supermarketcategory',
|
||||
name='open_data_slug',
|
||||
field=models.CharField(blank=True, default=None, max_length=128, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='unit',
|
||||
name='base_unit',
|
||||
field=models.TextField(blank=True, default=None, max_length=256, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='unit',
|
||||
name='open_data_slug',
|
||||
field=models.CharField(blank=True, default=None, max_length=128, null=True),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='supermarketcategoryrelation',
|
||||
constraint=models.UniqueConstraint(fields=('supermarket', 'category'), name='unique_sm_category_relation'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='unitconversion',
|
||||
name='base_unit',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='unit_conversion_base_relation', to='cookbook.unit'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='unitconversion',
|
||||
name='converted_unit',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='unit_conversion_converted_relation', to='cookbook.unit'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='unitconversion',
|
||||
name='created_by',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='unitconversion',
|
||||
name='food',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.food'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='unitconversion',
|
||||
name='space',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='propertytype',
|
||||
name='space',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='property',
|
||||
name='property_type',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='cookbook.propertytype'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='property',
|
||||
name='space',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='food',
|
||||
name='properties',
|
||||
field=models.ManyToManyField(blank=True, to='cookbook.property'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='recipe',
|
||||
name='properties',
|
||||
field=models.ManyToManyField(blank=True, to='cookbook.property'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='unitconversion',
|
||||
constraint=models.UniqueConstraint(fields=('space', 'base_unit', 'converted_unit', 'food'), name='f_unique_conversion_per_space'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='propertytype',
|
||||
constraint=models.UniqueConstraint(fields=('space', 'name'), name='property_type_unique_name_per_space'),
|
||||
),
|
||||
]
|
||||
38
cookbook/migrations/0190_auto_20230525_1506.py
Normal file
38
cookbook/migrations/0190_auto_20230525_1506.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 4.1.9 on 2023-05-25 13:06
|
||||
|
||||
from django.db import migrations
|
||||
from django_scopes import scopes_disabled
|
||||
from gettext import gettext as _
|
||||
|
||||
def migrate_old_nutrition_data(apps, schema_editor):
|
||||
print('Transforming nutrition information, this might take a while on large databases')
|
||||
with scopes_disabled():
|
||||
PropertyType = apps.get_model('cookbook', 'PropertyType')
|
||||
RecipeProperty = apps.get_model('cookbook', 'Property')
|
||||
Recipe = apps.get_model('cookbook', 'Recipe')
|
||||
Space = apps.get_model('cookbook', 'Space')
|
||||
|
||||
# TODO respect space
|
||||
for s in Space.objects.all():
|
||||
property_fat = PropertyType.objects.get_or_create(name=_('Fat'), unit=_('g'), space=s, )[0]
|
||||
property_carbohydrates = PropertyType.objects.get_or_create(name=_('Carbohydrates'), unit=_('g'), space=s, )[0]
|
||||
property_proteins = PropertyType.objects.get_or_create(name=_('Proteins'), unit=_('g'), space=s, )[0]
|
||||
property_calories = PropertyType.objects.get_or_create(name=_('Calories'), unit=_('kcal'), space=s, )[0]
|
||||
|
||||
for r in Recipe.objects.filter(nutrition__isnull=False, space=s).all():
|
||||
rp_fat = RecipeProperty.objects.create(property_type=property_fat, property_amount=r.nutrition.fats, space=s)
|
||||
rp_carbohydrates = RecipeProperty.objects.create(property_type=property_carbohydrates, property_amount=r.nutrition.carbohydrates, space=s)
|
||||
rp_proteins = RecipeProperty.objects.create(property_type=property_proteins, property_amount=r.nutrition.proteins, space=s)
|
||||
rp_calories = RecipeProperty.objects.create(property_type=property_calories, property_amount=r.nutrition.calories, space=s)
|
||||
r.properties.add(rp_fat, rp_carbohydrates, rp_proteins, rp_calories)
|
||||
r.nutrition = None
|
||||
r.save()
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0189_property_propertytype_unitconversion_food_fdc_id_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(migrate_old_nutrition_data)
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
# Generated by Django 4.1.9 on 2023-06-20 13:07
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('cookbook', '0190_auto_20230525_1506'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.SeparateDatabaseAndState(
|
||||
database_operations=[
|
||||
migrations.RunSQL(
|
||||
sql="ALTER TABLE cookbook_food_properties RENAME TO cookbook_foodproperty",
|
||||
reverse_sql="ALTER TABLE cookbook_foodproperty RENAME TO cookbook_food_properties",
|
||||
),
|
||||
],
|
||||
state_operations=[
|
||||
migrations.CreateModel(
|
||||
name='FoodProperty',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('food', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.food')),
|
||||
('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.property')),
|
||||
],
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='food',
|
||||
name='properties',
|
||||
field=models.ManyToManyField(blank=True, through='cookbook.FoodProperty', to='cookbook.property'),
|
||||
),
|
||||
]
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='foodproperty',
|
||||
constraint=models.UniqueConstraint(fields=('food', 'property'), name='property_unique_food'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='property',
|
||||
name='import_food_id',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='property',
|
||||
constraint=models.UniqueConstraint(fields=('space', 'property_type', 'import_food_id'), name='property_unique_import_food_per_space'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 4.1.9 on 2023-06-20 13:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0191_foodproperty_property_import_food_id_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name='food',
|
||||
constraint=models.UniqueConstraint(fields=('space', 'open_data_slug'), name='food_unique_open_data_slug_per_space'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='propertytype',
|
||||
constraint=models.UniqueConstraint(fields=('space', 'open_data_slug'), name='property_type_unique_open_data_slug_per_space'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='supermarket',
|
||||
constraint=models.UniqueConstraint(fields=('space', 'open_data_slug'), name='supermarket_unique_open_data_slug_per_space'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='supermarketcategory',
|
||||
constraint=models.UniqueConstraint(fields=('space', 'open_data_slug'), name='supermarket_category_unique_open_data_slug_per_space'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='unit',
|
||||
constraint=models.UniqueConstraint(fields=('space', 'open_data_slug'), name='unit_unique_open_data_slug_per_space'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='unitconversion',
|
||||
constraint=models.UniqueConstraint(fields=('space', 'open_data_slug'), name='unit_conversion_unique_open_data_slug_per_space'),
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0193_space_internal_note.py
Normal file
18
cookbook/migrations/0193_space_internal_note.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.9 on 2023-06-21 13:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0192_food_food_unique_open_data_slug_per_space_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='internal_note',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.9 on 2023-06-26 13:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0193_space_internal_note'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='food',
|
||||
name='properties_food_amount',
|
||||
field=models.DecimalField(blank=True, decimal_places=2, default=100, max_digits=16),
|
||||
),
|
||||
]
|
||||
@@ -82,31 +82,34 @@ class TreeManager(MP_NodeManager):
|
||||
# model.Manager get_or_create() is not compatible with MP_Tree
|
||||
def get_or_create(self, *args, **kwargs):
|
||||
kwargs['name'] = kwargs['name'].strip()
|
||||
|
||||
if obj := self.filter(name__iexact=kwargs['name'], space=kwargs['space']).first():
|
||||
return obj, False
|
||||
if hasattr(self, 'space'):
|
||||
if obj := self.filter(name__iexact=kwargs['name'], space=kwargs['space']).first():
|
||||
return obj, False
|
||||
else:
|
||||
with scopes_disabled():
|
||||
try:
|
||||
defaults = kwargs.pop('defaults', None)
|
||||
if defaults:
|
||||
kwargs = {**kwargs, **defaults}
|
||||
# ManyToMany fields can't be set this way, so pop them out to save for later
|
||||
fields = [field.name for field in self.model._meta.get_fields() if issubclass(type(field), ManyToManyField)]
|
||||
many_to_many = {field: kwargs.pop(field) for field in list(kwargs) if field in fields}
|
||||
obj = self.model.add_root(**kwargs)
|
||||
for field in many_to_many:
|
||||
field_model = getattr(obj, field).model
|
||||
for related_obj in many_to_many[field]:
|
||||
if isinstance(related_obj, User):
|
||||
getattr(obj, field).add(field_model.objects.get(id=related_obj.id))
|
||||
else:
|
||||
getattr(obj, field).add(field_model.objects.get(**dict(related_obj)))
|
||||
return obj, True
|
||||
except IntegrityError as e:
|
||||
if 'Key (path)' in e.args[0]:
|
||||
self.model.fix_tree(fix_paths=True)
|
||||
return self.model.add_root(**kwargs), True
|
||||
if obj := self.filter(name__iexact=kwargs['name']).first():
|
||||
return obj, False
|
||||
|
||||
with scopes_disabled():
|
||||
try:
|
||||
defaults = kwargs.pop('defaults', None)
|
||||
if defaults:
|
||||
kwargs = {**kwargs, **defaults}
|
||||
# ManyToMany fields can't be set this way, so pop them out to save for later
|
||||
fields = [field.name for field in self.model._meta.get_fields() if issubclass(type(field), ManyToManyField)]
|
||||
many_to_many = {field: kwargs.pop(field) for field in list(kwargs) if field in fields}
|
||||
obj = self.model.add_root(**kwargs)
|
||||
for field in many_to_many:
|
||||
field_model = getattr(obj, field).model
|
||||
for related_obj in many_to_many[field]:
|
||||
if isinstance(related_obj, User):
|
||||
getattr(obj, field).add(field_model.objects.get(id=related_obj.id))
|
||||
else:
|
||||
getattr(obj, field).add(field_model.objects.get(**dict(related_obj)))
|
||||
return obj, True
|
||||
except IntegrityError as e:
|
||||
if 'Key (path)' in e.args[0]:
|
||||
self.model.fix_tree(fix_paths=True)
|
||||
return self.model.add_root(**kwargs), True
|
||||
|
||||
|
||||
class TreeModel(MP_Node):
|
||||
@@ -267,6 +270,8 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
|
||||
food_inherit = models.ManyToManyField(FoodInheritField, blank=True)
|
||||
show_facet_count = models.BooleanField(default=False)
|
||||
|
||||
internal_note = models.TextField(blank=True, null=True)
|
||||
|
||||
def safe_delete(self):
|
||||
"""
|
||||
Safely deletes a space by deleting all objects belonging to the space first and then deleting the space itself
|
||||
@@ -454,6 +459,7 @@ class Sync(models.Model, PermissionModelMixin):
|
||||
class SupermarketCategory(models.Model, PermissionModelMixin):
|
||||
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)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
@@ -463,7 +469,8 @@ class SupermarketCategory(models.Model, PermissionModelMixin):
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='smc_unique_name_per_space')
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='smc_unique_name_per_space'),
|
||||
models.UniqueConstraint(fields=['space', 'open_data_slug'], name='supermarket_category_unique_open_data_slug_per_space')
|
||||
]
|
||||
|
||||
|
||||
@@ -471,6 +478,7 @@ class Supermarket(models.Model, PermissionModelMixin):
|
||||
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
||||
description = models.TextField(blank=True, null=True)
|
||||
categories = models.ManyToManyField(SupermarketCategory, through='SupermarketCategoryRelation')
|
||||
open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
@@ -480,7 +488,8 @@ class Supermarket(models.Model, PermissionModelMixin):
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='sm_unique_name_per_space')
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='sm_unique_name_per_space'),
|
||||
models.UniqueConstraint(fields=['space', 'open_data_slug'], name='supermarket_unique_open_data_slug_per_space')
|
||||
]
|
||||
|
||||
|
||||
@@ -496,6 +505,9 @@ class SupermarketCategoryRelation(models.Model, PermissionModelMixin):
|
||||
return 'supermarket', 'space'
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['supermarket', 'category'], name='unique_sm_category_relation')
|
||||
]
|
||||
ordering = ('order',)
|
||||
|
||||
|
||||
@@ -534,6 +546,8 @@ class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixi
|
||||
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)
|
||||
base_unit = models.TextField(max_length=256, null=True, blank=True, default=None)
|
||||
open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
@@ -543,7 +557,8 @@ class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixi
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='u_unique_name_per_space')
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='u_unique_name_per_space'),
|
||||
models.UniqueConstraint(fields=['space', 'open_data_slug'], name='unit_unique_open_data_slug_per_space')
|
||||
]
|
||||
|
||||
|
||||
@@ -569,6 +584,15 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
||||
substitute_children = models.BooleanField(default=False)
|
||||
child_inherit_fields = models.ManyToManyField(FoodInheritField, blank=True, related_name='child_inherit')
|
||||
|
||||
properties = models.ManyToManyField("Property", blank=True, through='FoodProperty')
|
||||
properties_food_amount = models.DecimalField(default=100, max_digits=16, decimal_places=2, blank=True)
|
||||
properties_food_unit = models.ForeignKey(Unit, on_delete=models.PROTECT, blank=True, null=True)
|
||||
|
||||
preferred_unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True, default=None, related_name='preferred_unit')
|
||||
preferred_shopping_unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True, default=None, related_name='preferred_shopping_unit')
|
||||
fdc_id = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||
|
||||
open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space', _manager_class=TreeManager)
|
||||
|
||||
@@ -642,7 +666,8 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='f_unique_name_per_space')
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='f_unique_name_per_space'),
|
||||
models.UniqueConstraint(fields=['space', 'open_data_slug'], name='food_unique_open_data_slug_per_space')
|
||||
]
|
||||
indexes = (
|
||||
Index(fields=['id']),
|
||||
@@ -650,6 +675,32 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
||||
)
|
||||
|
||||
|
||||
class UnitConversion(ExportModelOperationsMixin('unit_conversion'), models.Model, PermissionModelMixin):
|
||||
base_amount = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
||||
base_unit = models.ForeignKey('Unit', on_delete=models.CASCADE, related_name='unit_conversion_base_relation')
|
||||
converted_amount = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
||||
converted_unit = models.ForeignKey('Unit', on_delete=models.CASCADE, related_name='unit_conversion_converted_relation')
|
||||
|
||||
food = models.ForeignKey('Food', on_delete=models.CASCADE, null=True, blank=True)
|
||||
|
||||
created_by = models.ForeignKey(User, on_delete=models.PROTECT)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.base_amount} {self.base_unit} -> {self.converted_amount} {self.converted_unit} {self.food}'
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['space', 'base_unit', 'converted_unit', 'food'], name='f_unique_conversion_per_space'),
|
||||
models.UniqueConstraint(fields=['space', 'open_data_slug'], name='unit_conversion_unique_open_data_slug_per_space')
|
||||
]
|
||||
|
||||
|
||||
class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, PermissionModelMixin):
|
||||
# delete method on Food and Unit checks if they are part of a Recipe, if it is raises a ProtectedError instead of cascading the delete
|
||||
food = models.ForeignKey(Food, on_delete=models.CASCADE, null=True, blank=True)
|
||||
@@ -663,8 +714,6 @@ class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, Permiss
|
||||
order = models.IntegerField(default=0)
|
||||
original_text = models.CharField(max_length=512, null=True, blank=True, default=None)
|
||||
|
||||
original_text = models.CharField(max_length=512, null=True, blank=True, default=None)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
@@ -720,6 +769,64 @@ class Step(ExportModelOperationsMixin('step'), models.Model, PermissionModelMixi
|
||||
indexes = (GinIndex(fields=["search_vector"]),)
|
||||
|
||||
|
||||
class PropertyType(models.Model, PermissionModelMixin):
|
||||
NUTRITION = 'NUTRITION'
|
||||
ALLERGEN = 'ALLERGEN'
|
||||
PRICE = 'PRICE'
|
||||
GOAL = 'GOAL'
|
||||
OTHER = 'OTHER'
|
||||
|
||||
name = models.CharField(max_length=128)
|
||||
unit = models.CharField(max_length=64, blank=True, null=True)
|
||||
icon = models.CharField(max_length=16, blank=True, null=True)
|
||||
description = models.CharField(max_length=512, blank=True, null=True)
|
||||
category = models.CharField(max_length=64, choices=((NUTRITION, _('Nutrition')), (ALLERGEN, _('Allergen')), (PRICE, _('Price')), (GOAL, _('Goal')), (OTHER, _('Other'))), null=True, blank=True)
|
||||
open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||
|
||||
# TODO show if empty property?
|
||||
# TODO formatting property?
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name}'
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='property_type_unique_name_per_space'),
|
||||
models.UniqueConstraint(fields=['space', 'open_data_slug'], name='property_type_unique_open_data_slug_per_space')
|
||||
]
|
||||
|
||||
|
||||
class Property(models.Model, PermissionModelMixin):
|
||||
property_amount = models.DecimalField(default=0, decimal_places=4, max_digits=32)
|
||||
property_type = models.ForeignKey(PropertyType, on_delete=models.PROTECT)
|
||||
|
||||
import_food_id = models.IntegerField(null=True, blank=True) # 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')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.property_amount} {self.property_type.unit} {self.property_type.name}'
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['space', 'property_type', 'import_food_id'], name='property_unique_import_food_per_space')
|
||||
]
|
||||
|
||||
|
||||
class FoodProperty(models.Model):
|
||||
food = models.ForeignKey(Food, on_delete=models.CASCADE)
|
||||
property = models.ForeignKey(Property, on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['food', 'property'], name='property_unique_food')
|
||||
]
|
||||
|
||||
|
||||
class NutritionInformation(models.Model, PermissionModelMixin):
|
||||
fats = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
||||
carbohydrates = models.DecimalField(
|
||||
@@ -736,14 +843,6 @@ class NutritionInformation(models.Model, PermissionModelMixin):
|
||||
return f'Nutrition {self.pk}'
|
||||
|
||||
|
||||
# class NutritionType(models.Model, PermissionModelMixin):
|
||||
# name = models.CharField(max_length=128)
|
||||
# icon = models.CharField(max_length=16, blank=True, null=True)
|
||||
# description = models.CharField(max_length=512, blank=True, null=True)
|
||||
#
|
||||
# space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
# objects = ScopedManager(space='space')
|
||||
|
||||
class RecipeManager(models.Manager.from_queryset(models.QuerySet)):
|
||||
def get_queryset(self):
|
||||
return super(RecipeManager, self).get_queryset().annotate(rating=Avg('cooklog__rating')).annotate(last_cooked=Max('cooklog__created_at'))
|
||||
@@ -766,6 +865,7 @@ class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModel
|
||||
waiting_time = models.IntegerField(default=0)
|
||||
internal = models.BooleanField(default=False)
|
||||
nutrition = models.ForeignKey(NutritionInformation, blank=True, null=True, on_delete=models.CASCADE)
|
||||
properties = models.ManyToManyField(Property, blank=True)
|
||||
show_ingredient_overview = models.BooleanField(default=True)
|
||||
private = models.BooleanField(default=False)
|
||||
shared = models.ManyToManyField(User, blank=True, related_name='recipe_shared_with')
|
||||
|
||||
@@ -7,6 +7,7 @@ from html import escape
|
||||
from smtplib import SMTPException
|
||||
|
||||
from django.contrib.auth.models import Group, User, AnonymousUser
|
||||
from django.core.cache import caches
|
||||
from django.core.mail import send_mail
|
||||
from django.db.models import Avg, Q, QuerySet, Sum
|
||||
from django.http import BadHeaderError
|
||||
@@ -21,15 +22,18 @@ from rest_framework.exceptions import NotFound, ValidationError
|
||||
|
||||
from cookbook.helper.CustomStorageClass import CachedS3Boto3Storage
|
||||
from cookbook.helper.HelperFunctions import str2bool
|
||||
from cookbook.helper.property_helper import FoodPropertyHelper
|
||||
from cookbook.helper.permission_helper import above_space_limit
|
||||
from cookbook.helper.shopping_helper import RecipeShoppingEditor
|
||||
from cookbook.helper.unit_conversion_helper import UnitConversionHelper
|
||||
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, CustomFilter,
|
||||
ExportLog, Food, FoodInheritField, ImportLog, Ingredient, InviteLink,
|
||||
Keyword, MealPlan, MealType, NutritionInformation, Recipe, RecipeBook,
|
||||
RecipeBookEntry, RecipeImport, ShareLink, ShoppingList,
|
||||
ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
|
||||
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync,
|
||||
SyncLog, Unit, UserFile, UserPreference, UserSpace, ViewLog)
|
||||
SyncLog, Unit, UserFile, UserPreference, UserSpace, ViewLog, UnitConversion, Property,
|
||||
PropertyType, Property)
|
||||
from cookbook.templatetags.custom_tags import markdown
|
||||
from recipes.settings import AWS_ENABLED, MEDIA_URL
|
||||
|
||||
@@ -74,6 +78,19 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
|
||||
return path
|
||||
|
||||
|
||||
class OpenDataModelMixin(serializers.ModelSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
if 'open_data_slug' in validated_data and validated_data['open_data_slug'] is not None and validated_data['open_data_slug'].strip() == '':
|
||||
validated_data['open_data_slug'] = None
|
||||
return super().create(validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
if 'open_data_slug' in validated_data and validated_data['open_data_slug'] is not None and validated_data['open_data_slug'].strip() == '':
|
||||
validated_data['open_data_slug'] = None
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class CustomDecimalField(serializers.Field):
|
||||
"""
|
||||
Custom decimal field to normalize useless decimal places
|
||||
@@ -92,7 +109,7 @@ class CustomDecimalField(serializers.Field):
|
||||
if data == '':
|
||||
return 0
|
||||
try:
|
||||
return float(data.replace(',', ''))
|
||||
return float(data.replace(',', '.'))
|
||||
except ValueError:
|
||||
raise ValidationError('A valid number is required')
|
||||
|
||||
@@ -102,15 +119,21 @@ class CustomOnHandField(serializers.Field):
|
||||
return instance
|
||||
|
||||
def to_representation(self, obj):
|
||||
shared_users = None
|
||||
if request := self.context.get('request', None):
|
||||
shared_users = getattr(request, '_shared_users', None)
|
||||
if shared_users is None:
|
||||
if not self.context["request"].user.is_authenticated:
|
||||
return []
|
||||
shared_users = []
|
||||
if c := caches['default'].get(f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None):
|
||||
shared_users = c
|
||||
else:
|
||||
try:
|
||||
shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [
|
||||
self.context['request'].user.id]
|
||||
caches['default'].set(
|
||||
f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}',
|
||||
shared_users, timeout=5 * 60)
|
||||
# TODO ugly hack that improves API performance significantly, should be done properly
|
||||
except AttributeError: # Anonymous users (using share links) don't have shared users
|
||||
shared_users = []
|
||||
pass
|
||||
return obj.onhand_users.filter(id__in=shared_users).exists()
|
||||
|
||||
def to_internal_value(self, data):
|
||||
@@ -276,10 +299,13 @@ class SpaceSerializer(WritableNestedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Space
|
||||
fields = ('id', 'name', 'created_by', 'created_at', 'message', 'max_recipes', 'max_file_storage_mb', 'max_users',
|
||||
'allow_sharing', 'demo', 'food_inherit', 'show_facet_count', 'user_count', 'recipe_count', 'file_size_mb',
|
||||
'image', 'use_plural',)
|
||||
read_only_fields = ('id', 'created_by', 'created_at', 'max_recipes', 'max_file_storage_mb', 'max_users', 'allow_sharing', 'demo',)
|
||||
fields = (
|
||||
'id', 'name', 'created_by', 'created_at', 'message', 'max_recipes', 'max_file_storage_mb', 'max_users',
|
||||
'allow_sharing', 'demo', 'food_inherit', 'show_facet_count', 'user_count', 'recipe_count', 'file_size_mb',
|
||||
'image', 'use_plural',)
|
||||
read_only_fields = (
|
||||
'id', 'created_by', 'created_at', 'max_recipes', 'max_file_storage_mb', 'max_users', 'allow_sharing',
|
||||
'demo',)
|
||||
|
||||
|
||||
class UserSpaceSerializer(WritableNestedModelSerializer):
|
||||
@@ -427,7 +453,7 @@ class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
|
||||
read_only_fields = ('id', 'label', 'numchild', 'parent', 'image')
|
||||
|
||||
|
||||
class UnitSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
|
||||
class UnitSerializer(UniqueFieldsMixin, ExtendedRecipeMixin, OpenDataModelMixin):
|
||||
recipe_filter = 'steps__ingredients__unit'
|
||||
|
||||
def create(self, validated_data):
|
||||
@@ -440,7 +466,8 @@ class UnitSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
|
||||
return unit
|
||||
|
||||
space = validated_data.pop('space', self.context['request'].space)
|
||||
obj, created = Unit.objects.get_or_create(name=name, plural_name=plural_name, space=space, defaults=validated_data)
|
||||
obj, created = Unit.objects.get_or_create(name=name, plural_name=plural_name, space=space,
|
||||
defaults=validated_data)
|
||||
return obj
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
@@ -451,11 +478,11 @@ class UnitSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
|
||||
|
||||
class Meta:
|
||||
model = Unit
|
||||
fields = ('id', 'name', 'plural_name', 'description', 'numrecipe', 'image')
|
||||
fields = ('id', 'name', 'plural_name', 'description', 'numrecipe', 'image', 'open_data_slug')
|
||||
read_only_fields = ('id', 'numrecipe', 'image')
|
||||
|
||||
|
||||
class SupermarketCategorySerializer(UniqueFieldsMixin, WritableNestedModelSerializer):
|
||||
class SupermarketCategorySerializer(UniqueFieldsMixin, WritableNestedModelSerializer, OpenDataModelMixin):
|
||||
|
||||
def create(self, validated_data):
|
||||
name = validated_data.pop('name').strip()
|
||||
@@ -479,12 +506,41 @@ class SupermarketCategoryRelationSerializer(WritableNestedModelSerializer):
|
||||
fields = ('id', 'category', 'supermarket', 'order')
|
||||
|
||||
|
||||
class SupermarketSerializer(UniqueFieldsMixin, SpacedModelSerializer):
|
||||
class SupermarketSerializer(UniqueFieldsMixin, SpacedModelSerializer, OpenDataModelMixin):
|
||||
category_to_supermarket = SupermarketCategoryRelationSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Supermarket
|
||||
fields = ('id', 'name', 'description', 'category_to_supermarket')
|
||||
fields = ('id', 'name', 'description', 'category_to_supermarket', 'open_data_slug')
|
||||
|
||||
|
||||
class PropertyTypeSerializer(OpenDataModelMixin, WritableNestedModelSerializer, UniqueFieldsMixin):
|
||||
id = serializers.IntegerField(required=False)
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['space'] = self.context['request'].space
|
||||
|
||||
if property_type := PropertyType.objects.filter(Q(name=validated_data['name'])).filter(space=self.context['request'].space).first():
|
||||
return property_type
|
||||
|
||||
return super().create(validated_data)
|
||||
|
||||
class Meta:
|
||||
model = PropertyType
|
||||
fields = ('id', 'name', 'icon', 'unit', 'description', 'open_data_slug')
|
||||
|
||||
|
||||
class PropertySerializer(UniqueFieldsMixin, WritableNestedModelSerializer):
|
||||
property_type = PropertyTypeSerializer()
|
||||
property_amount = CustomDecimalField()
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['space'] = self.context['request'].space
|
||||
return super().create(validated_data)
|
||||
|
||||
class Meta:
|
||||
model = Property
|
||||
fields = ('id', 'property_amount', 'property_type')
|
||||
|
||||
|
||||
class RecipeSimpleSerializer(WritableNestedModelSerializer):
|
||||
@@ -512,7 +568,7 @@ class FoodSimpleSerializer(serializers.ModelSerializer):
|
||||
fields = ('id', 'name', 'plural_name')
|
||||
|
||||
|
||||
class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedRecipeMixin):
|
||||
class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedRecipeMixin, OpenDataModelMixin):
|
||||
supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False)
|
||||
recipe = RecipeSimpleSerializer(allow_null=True, required=False)
|
||||
# shopping = serializers.SerializerMethodField('get_shopping_status')
|
||||
@@ -523,19 +579,30 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
|
||||
substitute_onhand = serializers.SerializerMethodField('get_substitute_onhand')
|
||||
substitute = FoodSimpleSerializer(many=True, allow_null=True, required=False)
|
||||
|
||||
properties = PropertySerializer(many=True, allow_null=True, required=False)
|
||||
properties_food_unit = UnitSerializer(allow_null=True, required=False)
|
||||
properties_food_amount = CustomDecimalField(required=False)
|
||||
|
||||
recipe_filter = 'steps__ingredients__food'
|
||||
images = ['recipe__image']
|
||||
|
||||
def get_substitute_onhand(self, obj):
|
||||
shared_users = None
|
||||
if request := self.context.get('request', None):
|
||||
shared_users = getattr(request, '_shared_users', None)
|
||||
if shared_users is None:
|
||||
if not self.context["request"].user.is_authenticated:
|
||||
return []
|
||||
shared_users = []
|
||||
if c := caches['default'].get(
|
||||
f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None):
|
||||
shared_users = c
|
||||
else:
|
||||
try:
|
||||
shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [
|
||||
self.context['request'].user.id]
|
||||
except AttributeError:
|
||||
shared_users = []
|
||||
caches['default'].set(
|
||||
f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}',
|
||||
shared_users, timeout=5 * 60)
|
||||
# TODO ugly hack that improves API performance significantly, should be done properly
|
||||
except AttributeError: # Anonymous users (using share links) don't have shared users
|
||||
pass
|
||||
filter = Q(id__in=obj.substitute.all())
|
||||
if obj.substitute_siblings:
|
||||
filter |= Q(path__startswith=obj.path[:Food.steplen * (obj.depth - 1)], depth=obj.depth)
|
||||
@@ -547,7 +614,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
|
||||
# return ShoppingListEntry.objects.filter(space=obj.space, food=obj, checked=False).count() > 0
|
||||
|
||||
def create(self, validated_data):
|
||||
name = validated_data.pop('name').strip()
|
||||
name = validated_data['name'].strip()
|
||||
|
||||
if plural_name := validated_data.pop('plural_name', None):
|
||||
plural_name = plural_name.strip()
|
||||
@@ -579,7 +646,18 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
|
||||
else:
|
||||
validated_data['onhand_users'] = list(set(onhand_users) - set(shared_users))
|
||||
|
||||
obj, created = Food.objects.get_or_create(name=name, plural_name=plural_name, space=space, defaults=validated_data)
|
||||
if properties_food_unit := validated_data.pop('properties_food_unit', None):
|
||||
properties_food_unit = Unit.objects.filter(name=properties_food_unit['name']).first()
|
||||
|
||||
properties = validated_data.pop('properties', None)
|
||||
|
||||
obj, created = Food.objects.get_or_create(name=name, plural_name=plural_name, space=space, properties_food_unit=properties_food_unit,
|
||||
defaults=validated_data)
|
||||
|
||||
if properties and len(properties) > 0:
|
||||
for p in properties:
|
||||
obj.properties.add(Property.objects.create(property_type_id=p['property_type']['id'], property_amount=p['property_amount'], space=space))
|
||||
|
||||
return obj
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
@@ -606,9 +684,11 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
|
||||
class Meta:
|
||||
model = Food
|
||||
fields = (
|
||||
'id', 'name', 'plural_name', 'description', 'shopping', 'recipe', 'food_onhand', 'supermarket_category',
|
||||
'id', 'name', 'plural_name', 'description', 'shopping', 'recipe',
|
||||
'properties', 'properties_food_amount', 'properties_food_unit',
|
||||
'food_onhand', 'supermarket_category',
|
||||
'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name', 'ignore_shopping',
|
||||
'substitute', 'substitute_siblings', 'substitute_children', 'substitute_onhand', 'child_inherit_fields'
|
||||
'substitute', 'substitute_siblings', 'substitute_children', 'substitute_onhand', 'child_inherit_fields', 'open_data_slug',
|
||||
)
|
||||
read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe')
|
||||
|
||||
@@ -618,9 +698,24 @@ class IngredientSimpleSerializer(WritableNestedModelSerializer):
|
||||
unit = UnitSerializer(allow_null=True)
|
||||
used_in_recipes = serializers.SerializerMethodField('get_used_in_recipes')
|
||||
amount = CustomDecimalField()
|
||||
conversions = serializers.SerializerMethodField('get_conversions')
|
||||
|
||||
def get_used_in_recipes(self, obj):
|
||||
return list(Recipe.objects.filter(steps__ingredients=obj.id).values('id', 'name'))
|
||||
used_in = []
|
||||
for s in obj.step_set.all():
|
||||
for r in s.recipe_set.all():
|
||||
used_in.append({'id': r.id, 'name': r.name})
|
||||
return used_in
|
||||
|
||||
def get_conversions(self, obj):
|
||||
if obj.unit and obj.food:
|
||||
uch = UnitConversionHelper(self.context['request'].space)
|
||||
conversions = []
|
||||
for c in uch.get_conversions(obj):
|
||||
conversions.append({'food': c.food.name, 'unit': c.unit.name, 'amount': c.amount}) # TODO do formatting in helper
|
||||
return conversions
|
||||
else:
|
||||
return []
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['space'] = self.context['request'].space
|
||||
@@ -633,10 +728,11 @@ class IngredientSimpleSerializer(WritableNestedModelSerializer):
|
||||
class Meta:
|
||||
model = Ingredient
|
||||
fields = (
|
||||
'id', 'food', 'unit', 'amount', 'note', 'order',
|
||||
'id', 'food', 'unit', 'amount', 'conversions', 'note', 'order',
|
||||
'is_header', 'no_amount', 'original_text', 'used_in_recipes',
|
||||
'always_use_plural_unit', 'always_use_plural_food',
|
||||
)
|
||||
read_only_fields = ['conversions', ]
|
||||
|
||||
|
||||
class IngredientSerializer(IngredientSimpleSerializer):
|
||||
@@ -688,6 +784,30 @@ class StepRecipeSerializer(WritableNestedModelSerializer):
|
||||
)
|
||||
|
||||
|
||||
class UnitConversionSerializer(WritableNestedModelSerializer, OpenDataModelMixin):
|
||||
name = serializers.SerializerMethodField('get_conversion_name')
|
||||
base_unit = UnitSerializer()
|
||||
converted_unit = UnitSerializer()
|
||||
food = FoodSerializer(allow_null=True, required=False)
|
||||
base_amount = CustomDecimalField()
|
||||
converted_amount = CustomDecimalField()
|
||||
|
||||
def get_conversion_name(self, obj):
|
||||
text = f'{round(obj.base_amount)} {obj.base_unit} '
|
||||
if obj.food:
|
||||
text += f' {obj.food}'
|
||||
return text + f' = {round(obj.converted_amount)} {obj.converted_unit}'
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['space'] = self.context['request'].space
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
|
||||
class Meta:
|
||||
model = UnitConversion
|
||||
fields = ('id', 'name', 'base_amount', 'base_unit', 'converted_amount', 'converted_unit', 'food', 'open_data_slug')
|
||||
|
||||
|
||||
class NutritionInformationSerializer(serializers.ModelSerializer):
|
||||
carbohydrates = CustomDecimalField()
|
||||
fats = CustomDecimalField()
|
||||
@@ -738,21 +858,28 @@ class RecipeOverviewSerializer(RecipeBaseSerializer):
|
||||
|
||||
class RecipeSerializer(RecipeBaseSerializer):
|
||||
nutrition = NutritionInformationSerializer(allow_null=True, required=False)
|
||||
properties = PropertySerializer(many=True, required=False)
|
||||
steps = StepSerializer(many=True)
|
||||
keywords = KeywordSerializer(many=True)
|
||||
shared = UserSerializer(many=True, required=False)
|
||||
rating = CustomDecimalField(required=False, allow_null=True, read_only=True)
|
||||
last_cooked = serializers.DateTimeField(required=False, allow_null=True, read_only=True)
|
||||
food_properties = serializers.SerializerMethodField('get_food_properties')
|
||||
|
||||
def get_food_properties(self, obj):
|
||||
fph = FoodPropertyHelper(obj.space) # initialize with object space since recipes might be viewed anonymously
|
||||
return fph.calculate_recipe_properties(obj)
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = (
|
||||
'id', 'name', 'description', 'image', 'keywords', 'steps', 'working_time',
|
||||
'waiting_time', 'created_by', 'created_at', 'updated_at', 'source_url',
|
||||
'internal', 'show_ingredient_overview', 'nutrition', 'servings', 'file_path', 'servings_text', 'rating', 'last_cooked',
|
||||
'internal', 'show_ingredient_overview', 'nutrition', 'properties', 'food_properties', 'servings', 'file_path', 'servings_text', 'rating',
|
||||
'last_cooked',
|
||||
'private', 'shared',
|
||||
)
|
||||
read_only_fields = ['image', 'created_by', 'created_at']
|
||||
read_only_fields = ['image', 'created_by', 'created_at', 'food_properties']
|
||||
|
||||
def validate(self, data):
|
||||
above_limit, msg = above_space_limit(self.context['request'].space)
|
||||
@@ -1089,13 +1216,19 @@ class InviteLinkSerializer(WritableNestedModelSerializer):
|
||||
|
||||
if obj.email:
|
||||
try:
|
||||
if InviteLink.objects.filter(space=self.context['request'].space, created_at__gte=datetime.now() - timedelta(hours=4)).count() < 20:
|
||||
message = _('Hello') + '!\n\n' + _('You have been invited by ') + escape(self.context['request'].user.get_user_display_name())
|
||||
message += _(' to join their Tandoor Recipes space ') + escape(self.context['request'].space.name) + '.\n\n'
|
||||
message += _('Click the following link to activate your account: ') + self.context['request'].build_absolute_uri(reverse('view_invite', args=[str(obj.uuid)])) + '\n\n'
|
||||
message += _('If the link does not work use the following code to manually join the space: ') + str(obj.uuid) + '\n\n'
|
||||
if InviteLink.objects.filter(space=self.context['request'].space,
|
||||
created_at__gte=datetime.now() - timedelta(hours=4)).count() < 20:
|
||||
message = _('Hello') + '!\n\n' + _('You have been invited by ') + escape(
|
||||
self.context['request'].user.get_user_display_name())
|
||||
message += _(' to join their Tandoor Recipes space ') + escape(
|
||||
self.context['request'].space.name) + '.\n\n'
|
||||
message += _('Click the following link to activate your account: ') + self.context[
|
||||
'request'].build_absolute_uri(reverse('view_invite', args=[str(obj.uuid)])) + '\n\n'
|
||||
message += _('If the link does not work use the following code to manually join the space: ') + str(
|
||||
obj.uuid) + '\n\n'
|
||||
message += _('The invitation is valid until ') + str(obj.valid_until) + '\n\n'
|
||||
message += _('Tandoor Recipes is an Open Source recipe manager. Check it out on GitHub ') + 'https://github.com/vabene1111/recipes/'
|
||||
message += _(
|
||||
'Tandoor Recipes is an Open Source recipe manager. Check it out on GitHub ') + 'https://github.com/vabene1111/recipes/'
|
||||
|
||||
send_mail(
|
||||
_('Tandoor Recipes Invite'),
|
||||
@@ -1204,7 +1337,8 @@ class IngredientExportSerializer(WritableNestedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Ingredient
|
||||
fields = ('food', 'unit', 'amount', 'note', 'order', 'is_header', 'no_amount', 'always_use_plural_unit', 'always_use_plural_food')
|
||||
fields = ('food', 'unit', 'amount', 'note', 'order', 'is_header', 'no_amount', 'always_use_plural_unit',
|
||||
'always_use_plural_food')
|
||||
|
||||
|
||||
class StepExportSerializer(WritableNestedModelSerializer):
|
||||
|
||||
@@ -4,15 +4,17 @@ from functools import wraps
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.postgres.search import SearchVector
|
||||
from django.core.cache import caches
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils import translation
|
||||
from django_scopes import scope, scopes_disabled
|
||||
|
||||
from cookbook.helper.cache_helper import CacheHelper
|
||||
from cookbook.helper.shopping_helper import RecipeShoppingEditor
|
||||
from cookbook.managers import DICTIONARY
|
||||
from cookbook.models import (Food, FoodInheritField, Ingredient, MealPlan, Recipe,
|
||||
ShoppingListEntry, Step, UserPreference, SearchPreference, SearchFields)
|
||||
ShoppingListEntry, Step, UserPreference, SearchPreference, SearchFields, Unit, PropertyType)
|
||||
|
||||
SQLITE = True
|
||||
if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
|
||||
@@ -149,3 +151,15 @@ def auto_add_shopping(sender, instance=None, created=False, weak=False, **kwargs
|
||||
print("MEAL_AUTO_ADD Created SLR")
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
@receiver(post_save, sender=Unit)
|
||||
def clear_unit_cache(sender, instance=None, created=False, **kwargs):
|
||||
if instance:
|
||||
caches['default'].delete(CacheHelper(instance.space).BASE_UNITS_CACHE_KEY)
|
||||
|
||||
|
||||
@receiver(post_save, sender=PropertyType)
|
||||
def clear_property_type_cache(sender, instance=None, created=False, **kwargs):
|
||||
if instance:
|
||||
caches['default'].delete(CacheHelper(instance.space).PROPERTY_TYPE_CACHE_KEY)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{% endblock %}</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<meta name="robots" content="noindex,nofollow"/>
|
||||
|
||||
|
||||
<link rel="shortcut icon" type="image/x-icon" href="{% static 'assets/favicon.svg' %}">
|
||||
@@ -50,7 +50,7 @@
|
||||
<script type="text/javascript">
|
||||
$.fn.select2.defaults.set("theme", "bootstrap");
|
||||
{% if request.user.is_authenticated %}
|
||||
window.ACTIVE_SPACE_ID = '{{request.space.id}}';
|
||||
window.ACTIVE_SPACE_ID = '{{request.space.id}}';
|
||||
{% endif %}
|
||||
</script>
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-{% nav_color request %}"
|
||||
<nav class="navbar navbar-expand-lg {% nav_color request %}"
|
||||
id="id_main_nav"
|
||||
style="{% sticky_nav request %}">
|
||||
|
||||
@@ -118,6 +118,10 @@
|
||||
<a class="nav-link" href="{% url 'view_books' %}"><i
|
||||
class="fas fa-fw fa-book-open"></i> {% trans 'Books' %}</a>
|
||||
</li>
|
||||
{% plugin_main_nav_templates as plugin_main_nav_templates %}
|
||||
{% for pn in plugin_main_nav_templates %}
|
||||
{% include pn %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<ul class="navbar-nav ml-auto">
|
||||
@@ -270,6 +274,33 @@
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<a href="{% url 'list_property_type' %}" class="p-0 p-md-1">
|
||||
<div class="card p-0 no-gutters border-0">
|
||||
<div class="card-body text-center p-0 no-gutters">
|
||||
<i class="fas fa-database fa-2x"></i>
|
||||
</div>
|
||||
<div class="card-body text-break text-center p-0 no-gutters text-muted menu-dropdown-text">
|
||||
{% trans 'Properties' %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row m-0 mt-2 mt-md-0">
|
||||
<div class="col-4">
|
||||
<a href="{% url 'list_unit_conversion' %}" class="p-0 p-md-1">
|
||||
<div class="card p-0 no-gutters border-0">
|
||||
<div class="card-body text-center p-0 no-gutters">
|
||||
<i class="fas fa-exchange-alt fa-2x"></i>
|
||||
</div>
|
||||
<div class="card-body text-break text-center p-0 no-gutters text-muted menu-dropdown-text">
|
||||
{% trans 'Unit Conversions' %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@@ -323,6 +354,12 @@
|
||||
<a class="dropdown-item" href="{% url 'view_space_overview' %}"><i
|
||||
class="fas fa-list"></i> {% trans 'Overview' %}</a>
|
||||
{% endif %}
|
||||
{% plugin_dropdown_nav_templates as plugin_dropdown_nav_templates %}
|
||||
{% for pn in plugin_dropdown_nav_templates %}
|
||||
<div class="dropdown-divider"></div>
|
||||
{% include pn %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item" href="{% url 'docs_markdown' %}"><i
|
||||
class="fab fa-markdown fa-fw"></i> {% trans 'Markdown Guide' %}</a>
|
||||
@@ -349,6 +386,7 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
{% message_of_the_day request as message_of_the_day %}
|
||||
{% if message_of_the_day %}
|
||||
<div class="bg-info" style=" width: 100%; text-align: center!important; color: #ffffff; padding: 8px">
|
||||
@@ -413,7 +451,7 @@
|
||||
localStorage.setItem('STATIC_URL', "{% base_path request 'static_base' %}")
|
||||
localStorage.setItem('DEBUG', "{% is_debug %}")
|
||||
localStorage.setItem('USER_ID', "{{request.user.pk}}")
|
||||
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register("{% url 'service_worker' %}", {scope: "{% base_path request 'base' %}" + '/'}).then(function (reg) {
|
||||
|
||||
@@ -16,7 +16,7 @@ from cookbook.helper.mdx_attributes import MarkdownFormatExtension
|
||||
from cookbook.helper.mdx_urlize import UrlizeExtension
|
||||
from cookbook.models import Space, get_model_name
|
||||
from recipes import settings
|
||||
from recipes.settings import STATIC_URL
|
||||
from recipes.settings import STATIC_URL, PLUGINS
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@@ -132,6 +132,22 @@ def is_debug():
|
||||
def markdown_link():
|
||||
return f"{_('You can use markdown to format this field. See the ')}<a target='_blank' href='{reverse('docs_markdown')}'>{_('docs here')}</a>"
|
||||
|
||||
@register.simple_tag
|
||||
def plugin_dropdown_nav_templates():
|
||||
templates = []
|
||||
for p in PLUGINS:
|
||||
if p['nav_dropdown']:
|
||||
templates.append(p['nav_dropdown'])
|
||||
return templates
|
||||
|
||||
@register.simple_tag
|
||||
def plugin_main_nav_templates():
|
||||
templates = []
|
||||
for p in PLUGINS:
|
||||
if p['nav_main']:
|
||||
templates.append(p['nav_main'])
|
||||
return templates
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def bookmarklet(request):
|
||||
|
||||
@@ -27,7 +27,11 @@ def theme_url(request):
|
||||
def nav_color(request):
|
||||
if not request.user.is_authenticated:
|
||||
return 'primary'
|
||||
return request.user.userpreference.nav_color.lower()
|
||||
|
||||
if request.user.userpreference.nav_color.lower() in ['light', 'warning', 'info', 'success']:
|
||||
return f'navbar-light bg-{request.user.userpreference.nav_color.lower()}'
|
||||
else:
|
||||
return f'navbar-dark bg-{request.user.userpreference.nav_color.lower()}'
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
|
||||
import pytest
|
||||
from django.contrib import auth
|
||||
from django.core.cache import caches
|
||||
from django.urls import reverse
|
||||
from django_scopes import scope, scopes_disabled
|
||||
from pytest_factoryboy import LazyFixture, register
|
||||
@@ -28,7 +29,6 @@ if (Food.node_order_by):
|
||||
else:
|
||||
node_location = 'last-child'
|
||||
|
||||
|
||||
register(FoodFactory, 'obj_1', space=LazyFixture('space_1'))
|
||||
register(FoodFactory, 'obj_2', space=LazyFixture('space_1'))
|
||||
register(FoodFactory, 'obj_3', space=LazyFixture('space_2'))
|
||||
@@ -554,12 +554,13 @@ def test_inherit(request, obj_tree_1, field, inherit, new_val, u1_s1):
|
||||
assert (getattr(obj_tree_1, field) == new_val) == inherit
|
||||
assert (getattr(child, field) == new_val) == inherit
|
||||
|
||||
|
||||
# TODO add test_inherit with child_inherit
|
||||
|
||||
|
||||
@pytest.mark.parametrize("obj_tree_1", [
|
||||
({'has_category': True, 'inherit': False, 'ignore_shopping': True,
|
||||
'substitute_children': True, 'substitute_siblings': True}),
|
||||
'substitute_children': True, 'substitute_siblings': True}),
|
||||
], indirect=['obj_tree_1'])
|
||||
@pytest.mark.parametrize("global_reset", [True, False])
|
||||
@pytest.mark.parametrize("field", ['ignore_shopping', 'substitute_children', 'substitute_siblings', 'supermarket_category'])
|
||||
@@ -599,7 +600,7 @@ def test_reset_inherit_space_fields(obj_tree_1, space_1, global_reset, field):
|
||||
|
||||
@pytest.mark.parametrize("obj_tree_1", [
|
||||
({'has_category': True, 'inherit': False, 'ignore_shopping': True,
|
||||
'substitute_children': True, 'substitute_siblings': True}),
|
||||
'substitute_children': True, 'substitute_siblings': True}),
|
||||
], indirect=['obj_tree_1'])
|
||||
@pytest.mark.parametrize("field", ['ignore_shopping', 'substitute_children', 'substitute_siblings', 'supermarket_category'])
|
||||
def test_reset_inherit_no_food_instances(obj_tree_1, space_1, field):
|
||||
@@ -613,11 +614,9 @@ def test_reset_inherit_no_food_instances(obj_tree_1, space_1, field):
|
||||
parent.reset_inheritance(space=space_1)
|
||||
|
||||
|
||||
def test_onhand(obj_1, u1_s1, u2_s1):
|
||||
assert json.loads(u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)[
|
||||
'food_onhand'] == False
|
||||
assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)[
|
||||
'food_onhand'] == False
|
||||
def test_onhand(obj_1, u1_s1, u2_s1, space_1):
|
||||
assert json.loads(u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] is False
|
||||
assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] is False
|
||||
|
||||
u1_s1.patch(
|
||||
reverse(
|
||||
@@ -627,13 +626,12 @@ def test_onhand(obj_1, u1_s1, u2_s1):
|
||||
{'food_onhand': True},
|
||||
content_type='application/json'
|
||||
)
|
||||
assert json.loads(u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)[
|
||||
'food_onhand'] == True
|
||||
assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)[
|
||||
'food_onhand'] == False
|
||||
assert json.loads(u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] is True
|
||||
assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] is False
|
||||
|
||||
user1 = auth.get_user(u1_s1)
|
||||
user2 = auth.get_user(u2_s1)
|
||||
user1.userpreference.shopping_share.add(user2)
|
||||
assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)[
|
||||
'food_onhand'] == True
|
||||
caches['default'].set(f'shopping_shared_users_{space_1.id}_{user2.id}', None)
|
||||
|
||||
assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] is True
|
||||
|
||||
116
cookbook/tests/api/test_api_property.py
Normal file
116
cookbook/tests/api/test_api_property.py
Normal file
@@ -0,0 +1,116 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from django.contrib import auth
|
||||
from django.urls import reverse
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.models import Food, MealType, PropertyType, Property
|
||||
|
||||
LIST_URL = 'api:property-list'
|
||||
DETAIL_URL = 'api:property-detail'
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def obj_1(space_1, u1_s1):
|
||||
pt = PropertyType.objects.get_or_create(name='test_1', space=space_1)[0]
|
||||
return Property.objects.get_or_create(property_amount=100, property_type=pt, space=space_1)[0]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def obj_2(space_1, u1_s1):
|
||||
pt = PropertyType.objects.get_or_create(name='test_2', space=space_1)[0]
|
||||
return Property.objects.get_or_create(property_amount=100, property_type=pt, space=space_1)[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
['u1_s1', 200],
|
||||
['a1_s1', 200],
|
||||
])
|
||||
def test_list_permission(arg, request):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
assert c.get(reverse(LIST_URL)).status_code == arg[1]
|
||||
|
||||
|
||||
def test_list_space(obj_1, obj_2, u1_s1, u1_s2, space_2):
|
||||
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 2
|
||||
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
|
||||
|
||||
obj_1.space = space_2
|
||||
obj_1.save()
|
||||
|
||||
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 1
|
||||
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
['u1_s1', 200],
|
||||
['a1_s1', 200],
|
||||
['g1_s2', 403],
|
||||
['u1_s2', 404],
|
||||
['a1_s2', 404],
|
||||
])
|
||||
def test_update(arg, request, obj_1):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
r = c.patch(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_1.id}
|
||||
),
|
||||
{'property_amount': 200},
|
||||
content_type='application/json'
|
||||
)
|
||||
response = json.loads(r.content)
|
||||
assert r.status_code == arg[1]
|
||||
if r.status_code == 200:
|
||||
assert response['property_amount'] == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
['u1_s1', 201],
|
||||
['a1_s1', 201],
|
||||
])
|
||||
def test_add(arg, request, u1_s2, space_1):
|
||||
with scopes_disabled():
|
||||
pt = PropertyType.objects.get_or_create(name='test_1', space=space_1)[0]
|
||||
c = request.getfixturevalue(arg[0])
|
||||
r = c.post(
|
||||
reverse(LIST_URL),
|
||||
{'property_amount': 100, 'property_type': {'id': pt.id, 'name': pt.name}},
|
||||
content_type='application/json'
|
||||
)
|
||||
response = json.loads(r.content)
|
||||
assert r.status_code == arg[1]
|
||||
if r.status_code == 201:
|
||||
assert response['property_amount'] == 100
|
||||
r = c.get(reverse(DETAIL_URL, args={response['id']}))
|
||||
assert r.status_code == 200
|
||||
r = u1_s2.get(reverse(DETAIL_URL, args={response['id']}))
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_delete(u1_s1, u1_s2, obj_1):
|
||||
r = u1_s2.delete(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_1.id}
|
||||
)
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
r = u1_s1.delete(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_1.id}
|
||||
)
|
||||
)
|
||||
|
||||
assert r.status_code == 204
|
||||
with scopes_disabled():
|
||||
assert MealType.objects.count() == 0
|
||||
132
cookbook/tests/api/test_api_property_type.py
Normal file
132
cookbook/tests/api/test_api_property_type.py
Normal file
@@ -0,0 +1,132 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from django.contrib import auth
|
||||
from django.urls import reverse
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.models import Food, MealType, PropertyType
|
||||
|
||||
LIST_URL = 'api:propertytype-list'
|
||||
DETAIL_URL = 'api:propertytype-detail'
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def obj_1(space_1, u1_s1):
|
||||
return PropertyType.objects.get_or_create(name='test_1', space=space_1)[0]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def obj_2(space_1, u1_s1):
|
||||
return PropertyType.objects.get_or_create(name='test_2', space=space_1)[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
['u1_s1', 200],
|
||||
['a1_s1', 200],
|
||||
])
|
||||
def test_list_permission(arg, request):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
assert c.get(reverse(LIST_URL)).status_code == arg[1]
|
||||
|
||||
|
||||
def test_list_space(obj_1, obj_2, u1_s1, u1_s2, space_2):
|
||||
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 2
|
||||
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
|
||||
|
||||
obj_1.space = space_2
|
||||
obj_1.save()
|
||||
|
||||
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 1
|
||||
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
['u1_s1', 200],
|
||||
['a1_s1', 200],
|
||||
['g1_s2', 403],
|
||||
['u1_s2', 404],
|
||||
['a1_s2', 404],
|
||||
])
|
||||
def test_update(arg, request, obj_1):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
r = c.patch(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_1.id}
|
||||
),
|
||||
{'name': 'new'},
|
||||
content_type='application/json'
|
||||
)
|
||||
response = json.loads(r.content)
|
||||
assert r.status_code == arg[1]
|
||||
if r.status_code == 200:
|
||||
assert response['name'] == 'new'
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
['u1_s1', 201],
|
||||
['a1_s1', 201],
|
||||
])
|
||||
def test_add(arg, request, u1_s2):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
r = c.post(
|
||||
reverse(LIST_URL),
|
||||
{'name': 'test'},
|
||||
content_type='application/json'
|
||||
)
|
||||
response = json.loads(r.content)
|
||||
assert r.status_code == arg[1]
|
||||
if r.status_code == 201:
|
||||
assert response['name'] == 'test'
|
||||
r = c.get(reverse(DETAIL_URL, args={response['id']}))
|
||||
assert r.status_code == 200
|
||||
r = u1_s2.get(reverse(DETAIL_URL, args={response['id']}))
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_add_duplicate(u1_s1, u1_s2, obj_1):
|
||||
r = u1_s1.post(
|
||||
reverse(LIST_URL),
|
||||
{'name': obj_1.name},
|
||||
content_type='application/json'
|
||||
)
|
||||
response = json.loads(r.content)
|
||||
assert r.status_code == 201
|
||||
assert response['id'] == obj_1.id
|
||||
|
||||
r = u1_s2.post(
|
||||
reverse(LIST_URL),
|
||||
{'name': obj_1.name},
|
||||
content_type='application/json'
|
||||
)
|
||||
response = json.loads(r.content)
|
||||
assert r.status_code == 201
|
||||
assert response['id'] != obj_1.id
|
||||
|
||||
|
||||
def test_delete(u1_s1, u1_s2, obj_1):
|
||||
r = u1_s2.delete(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_1.id}
|
||||
)
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
r = u1_s1.delete(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_1.id}
|
||||
)
|
||||
)
|
||||
|
||||
assert r.status_code == 204
|
||||
with scopes_disabled():
|
||||
assert MealType.objects.count() == 0
|
||||
163
cookbook/tests/api/test_api_unit_conversion.py
Normal file
163
cookbook/tests/api/test_api_unit_conversion.py
Normal file
@@ -0,0 +1,163 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from django.contrib import auth
|
||||
from django.urls import reverse
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.models import Food, MealType, UnitConversion
|
||||
from cookbook.tests.conftest import get_random_food, get_random_unit
|
||||
|
||||
LIST_URL = 'api:unitconversion-list'
|
||||
DETAIL_URL = 'api:unitconversion-detail'
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def obj_1(space_1, u1_s1):
|
||||
return UnitConversion.objects.get_or_create(
|
||||
food=get_random_food(space_1, u1_s1),
|
||||
base_amount=100,
|
||||
base_unit=get_random_unit(space_1, u1_s1),
|
||||
converted_amount=100,
|
||||
converted_unit=get_random_unit(space_1, u1_s1),
|
||||
created_by=auth.get_user(u1_s1),
|
||||
space=space_1
|
||||
)[0]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def obj_2(space_1, u1_s1):
|
||||
return UnitConversion.objects.get_or_create(
|
||||
food=get_random_food(space_1, u1_s1),
|
||||
base_amount=100,
|
||||
base_unit=get_random_unit(space_1, u1_s1),
|
||||
converted_amount=100,
|
||||
converted_unit=get_random_unit(space_1, u1_s1),
|
||||
created_by=auth.get_user(u1_s1),
|
||||
space=space_1
|
||||
)[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
['u1_s1', 200],
|
||||
['a1_s1', 200],
|
||||
])
|
||||
def test_list_permission(arg, request):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
assert c.get(reverse(LIST_URL)).status_code == arg[1]
|
||||
|
||||
|
||||
def test_list_space(obj_1, obj_2, u1_s1, u1_s2, space_2):
|
||||
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 2
|
||||
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
|
||||
|
||||
obj_1.space = space_2
|
||||
obj_1.save()
|
||||
|
||||
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 1
|
||||
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
['u1_s1', 200],
|
||||
['a1_s1', 200],
|
||||
['g1_s2', 403],
|
||||
['u1_s2', 404],
|
||||
['a1_s2', 404],
|
||||
])
|
||||
def test_update(arg, request, obj_1):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
r = c.patch(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_1.id}
|
||||
),
|
||||
{'base_amount': 1000},
|
||||
content_type='application/json'
|
||||
)
|
||||
response = json.loads(r.content)
|
||||
assert r.status_code == arg[1]
|
||||
if r.status_code == 200:
|
||||
assert response['base_amount'] == 1000
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
['u1_s1', 201],
|
||||
['a1_s1', 201],
|
||||
])
|
||||
def test_add(arg, request, u1_s2, space_1, u1_s1):
|
||||
with scopes_disabled():
|
||||
c = request.getfixturevalue(arg[0])
|
||||
random_unit_1 = get_random_unit(space_1, u1_s1)
|
||||
random_unit_2 = get_random_unit(space_1, u1_s1)
|
||||
random_food_1 = get_random_unit(space_1, u1_s1)
|
||||
r = c.post(
|
||||
reverse(LIST_URL),
|
||||
{
|
||||
'food': {'id': random_food_1.id, 'name': random_food_1.name},
|
||||
'base_amount': 100,
|
||||
'base_unit': {'id': random_unit_1.id, 'name': random_unit_1.name},
|
||||
'converted_amount': 100,
|
||||
'converted_unit': {'id': random_unit_2.id, 'name': random_unit_2.name}
|
||||
|
||||
},
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
response = json.loads(r.content)
|
||||
print(response)
|
||||
assert r.status_code == arg[1]
|
||||
if r.status_code == 201:
|
||||
assert response['base_amount'] == 100
|
||||
r = c.get(reverse(DETAIL_URL, args={response['id']}))
|
||||
assert r.status_code == 200
|
||||
r = u1_s2.get(reverse(DETAIL_URL, args={response['id']}))
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# TODO make name in space unique
|
||||
# def test_add_duplicate(u1_s1, u1_s2, obj_1):
|
||||
# r = u1_s1.post(
|
||||
# reverse(LIST_URL),
|
||||
# {'name': obj_1.name},
|
||||
# content_type='application/json'
|
||||
# )
|
||||
# response = json.loads(r.content)
|
||||
# assert r.status_code == 201
|
||||
# assert response['id'] == obj_1.id
|
||||
#
|
||||
# r = u1_s2.post(
|
||||
# reverse(LIST_URL),
|
||||
# {'name': obj_1.name},
|
||||
# content_type='application/json'
|
||||
# )
|
||||
# response = json.loads(r.content)
|
||||
# assert r.status_code == 201
|
||||
# assert response['id'] != obj_1.id
|
||||
|
||||
|
||||
def test_delete(u1_s1, u1_s2, obj_1):
|
||||
r = u1_s2.delete(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_1.id}
|
||||
)
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
r = u1_s1.delete(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_1.id}
|
||||
)
|
||||
)
|
||||
|
||||
assert r.status_code == 204
|
||||
with scopes_disabled():
|
||||
assert MealType.objects.count() == 0
|
||||
@@ -13,6 +13,8 @@ from cookbook.tests.factories import SpaceFactory, UserFactory
|
||||
|
||||
register(SpaceFactory, 'space_1')
|
||||
register(SpaceFactory, 'space_2')
|
||||
|
||||
|
||||
# register(FoodFactory, space=LazyFixture('space_2'))
|
||||
# TODO refactor clients to be factories
|
||||
|
||||
@@ -141,7 +143,7 @@ def validate_recipe(expected, recipe):
|
||||
for k in expected_lists[key]:
|
||||
try:
|
||||
print('comparing ', any([dict_compare(k, i)
|
||||
for i in target_lists[key]]))
|
||||
for i in target_lists[key]]))
|
||||
assert any([dict_compare(k, i) for i in target_lists[key]])
|
||||
except AssertionError:
|
||||
for result in [dict_compare(k, i, details=True) for i in target_lists[key]]:
|
||||
@@ -169,7 +171,6 @@ def dict_compare(d1, d2, details=False):
|
||||
|
||||
|
||||
def transpose(text, number=2):
|
||||
|
||||
# select random token
|
||||
tokens = text.split()
|
||||
positions = list(i for i, e in enumerate(tokens) if len(e) > 1)
|
||||
@@ -212,6 +213,14 @@ def ext_recipe_1_s1(space_1, u1_s1):
|
||||
return r
|
||||
|
||||
|
||||
def get_random_food(space_1, u1_s1):
|
||||
return Food.objects.get_or_create(name=str(uuid.uuid4()), space=space_1)[0]
|
||||
|
||||
|
||||
def get_random_unit(space_1, u1_s1):
|
||||
return Unit.objects.get_or_create(name=str(uuid.uuid4()), space=space_1)[0]
|
||||
|
||||
|
||||
# ---------------------- USER FIXTURES -----------------------
|
||||
# maybe better with factories but this is very explict so ...
|
||||
|
||||
|
||||
129
cookbook/tests/other/test_food_property.py
Normal file
129
cookbook/tests/other/test_food_property.py
Normal file
@@ -0,0 +1,129 @@
|
||||
from django.contrib import auth
|
||||
from django.core.cache import caches
|
||||
from django_scopes import scopes_disabled
|
||||
from decimal import Decimal
|
||||
|
||||
from cookbook.helper.cache_helper import CacheHelper
|
||||
from cookbook.helper.property_helper import FoodPropertyHelper
|
||||
from cookbook.models import Unit, Food, PropertyType, Property, Recipe, Step, UnitConversion, Property
|
||||
|
||||
|
||||
def test_food_property(space_1, space_2, u1_s1):
|
||||
with scopes_disabled():
|
||||
unit_gram = Unit.objects.create(name='gram', base_unit='g', space=space_1)
|
||||
unit_kg = Unit.objects.create(name='kg', base_unit='kg', space=space_1)
|
||||
unit_pcs = Unit.objects.create(name='pcs', base_unit='', space=space_1)
|
||||
unit_floz1 = Unit.objects.create(name='fl. oz 1', base_unit='imperial_fluid_ounce', space=space_1) # US and UK use different volume systems (US vs imperial)
|
||||
unit_floz2 = Unit.objects.create(name='fl. oz 2', base_unit='fluid_ounce', space=space_1)
|
||||
unit_fantasy = Unit.objects.create(name='Fantasy Unit', base_unit='', space=space_1)
|
||||
|
||||
food_1 = Food.objects.create(name='food_1', space=space_1, properties_food_unit=unit_gram, properties_food_amount=100)
|
||||
food_2 = Food.objects.create(name='food_2', space=space_1, properties_food_unit=unit_gram, properties_food_amount=100)
|
||||
|
||||
property_fat = PropertyType.objects.create(name='property_fat', space=space_1)
|
||||
property_calories = PropertyType.objects.create(name='property_calories', space=space_1)
|
||||
property_nuts = PropertyType.objects.create(name='property_nuts', space=space_1)
|
||||
property_price = PropertyType.objects.create(name='property_price', space=space_1)
|
||||
|
||||
food_1_property_fat = Property.objects.create(property_amount=50, property_type=property_fat, space=space_1)
|
||||
food_1_property_nuts = Property.objects.create(property_amount=1, property_type=property_nuts, space=space_1)
|
||||
food_1_property_price = Property.objects.create(property_amount=7.50, property_type=property_price, space=space_1)
|
||||
food_1.properties.add(food_1_property_fat, food_1_property_nuts, food_1_property_price)
|
||||
|
||||
food_2_property_fat = Property.objects.create(property_amount=25, property_type=property_fat, space=space_1)
|
||||
food_2_property_nuts = Property.objects.create(property_amount=0, property_type=property_nuts, space=space_1)
|
||||
food_2_property_price = Property.objects.create(property_amount=2.50, property_type=property_price, space=space_1)
|
||||
food_2.properties.add(food_2_property_fat, food_2_property_nuts, food_2_property_price)
|
||||
|
||||
print('\n----------- TEST PROPERTY - PROPERTY CALCULATION MULTI STEP IDENTICAL UNIT ---------------')
|
||||
recipe_1 = Recipe.objects.create(name='recipe_1', waiting_time=0, working_time=0, space=space_1, created_by=auth.get_user(u1_s1))
|
||||
|
||||
step_1 = Step.objects.create(instruction='instruction_step_1', space=space_1)
|
||||
step_1.ingredients.create(amount=500, unit=unit_gram, food=food_1, space=space_1)
|
||||
step_1.ingredients.create(amount=1000, unit=unit_gram, food=food_2, space=space_1)
|
||||
recipe_1.steps.add(step_1)
|
||||
|
||||
step_2 = Step.objects.create(instruction='instruction_step_1', space=space_1)
|
||||
step_2.ingredients.create(amount=50, unit=unit_gram, food=food_1, space=space_1)
|
||||
recipe_1.steps.add(step_2)
|
||||
|
||||
property_values = FoodPropertyHelper(space_1).calculate_recipe_properties(recipe_1)
|
||||
|
||||
assert property_values[property_fat.id]['name'] == property_fat.name
|
||||
assert abs(property_values[property_fat.id]['total_value'] - Decimal(525)) < 0.0001
|
||||
assert abs(property_values[property_fat.id]['food_values'][food_1.id]['value'] - Decimal(275)) < 0.0001
|
||||
assert abs(property_values[property_fat.id]['food_values'][food_2.id]['value'] - Decimal(250)) < 0.0001
|
||||
|
||||
print('\n----------- TEST PROPERTY - PROPERTY CALCULATION NO POSSIBLE CONVERSION ---------------')
|
||||
recipe_2 = Recipe.objects.create(name='recipe_2', waiting_time=0, working_time=0, space=space_1, created_by=auth.get_user(u1_s1))
|
||||
|
||||
step_1 = Step.objects.create(instruction='instruction_step_1', space=space_1)
|
||||
step_1.ingredients.create(amount=5, unit=unit_pcs, food=food_1, space=space_1)
|
||||
step_1.ingredients.create(amount=10, unit=unit_pcs, food=food_2, space=space_1)
|
||||
recipe_2.steps.add(step_1)
|
||||
|
||||
property_values = FoodPropertyHelper(space_1).calculate_recipe_properties(recipe_2)
|
||||
|
||||
assert property_values[property_fat.id]['name'] == property_fat.name
|
||||
assert abs(property_values[property_fat.id]['total_value']) < 0.0001
|
||||
assert abs(property_values[property_fat.id]['food_values'][food_1.id]['value']) < 0.0001
|
||||
|
||||
print('\n----------- TEST PROPERTY - PROPERTY CALCULATION UNIT CONVERSION ---------------')
|
||||
uc1 = UnitConversion.objects.create(
|
||||
base_amount=100,
|
||||
base_unit=unit_gram,
|
||||
converted_amount=1,
|
||||
converted_unit=unit_pcs,
|
||||
space=space_1,
|
||||
created_by=auth.get_user(u1_s1),
|
||||
)
|
||||
|
||||
property_values = FoodPropertyHelper(space_1).calculate_recipe_properties(recipe_2)
|
||||
|
||||
assert property_values[property_fat.id]['name'] == property_fat.name
|
||||
assert abs(property_values[property_fat.id]['total_value'] - Decimal(500)) < 0.0001
|
||||
assert abs(property_values[property_fat.id]['food_values'][food_1.id]['value'] - Decimal(250)) < 0.0001
|
||||
assert abs(property_values[property_fat.id]['food_values'][food_2.id]['value'] - Decimal(250)) < 0.0001
|
||||
|
||||
print('\n----------- TEST PROPERTY - PROPERTY CALCULATION UNIT CONVERSION MULTIPLE ---------------')
|
||||
|
||||
uc1.delete()
|
||||
uc1 = UnitConversion.objects.create(
|
||||
base_amount=0.1,
|
||||
base_unit=unit_kg,
|
||||
converted_amount=1,
|
||||
converted_unit=unit_pcs,
|
||||
space=space_1,
|
||||
created_by=auth.get_user(u1_s1),
|
||||
)
|
||||
|
||||
property_values = FoodPropertyHelper(space_1).calculate_recipe_properties(recipe_2)
|
||||
|
||||
assert property_values[property_fat.id]['name'] == property_fat.name
|
||||
assert abs(property_values[property_fat.id]['total_value'] - Decimal(500)) < 0.0001
|
||||
assert abs(property_values[property_fat.id]['food_values'][food_1.id]['value'] - Decimal(250)) < 0.0001
|
||||
assert abs(property_values[property_fat.id]['food_values'][food_2.id]['value'] - Decimal(250)) < 0.0001
|
||||
|
||||
print('\n----------- TEST PROPERTY - MISSING FOOD REFERENCE AMOUNT ---------------')
|
||||
food_1.properties_food_unit = None
|
||||
food_1.save()
|
||||
food_2.properties_food_amount = 0
|
||||
food_2.save()
|
||||
|
||||
property_values = FoodPropertyHelper(space_1).calculate_recipe_properties(recipe_1)
|
||||
|
||||
assert property_values[property_fat.id]['name'] == property_fat.name
|
||||
assert property_values[property_fat.id]['total_value'] == 0
|
||||
|
||||
print('\n----------- TEST PROPERTY - SPACE SEPARATION ---------------')
|
||||
|
||||
property_fat.space = space_2
|
||||
property_fat.save()
|
||||
|
||||
caches['default'].delete(CacheHelper(space_1).PROPERTY_TYPE_CACHE_KEY) # clear cache as objects won't change space in reality
|
||||
|
||||
property_values = FoodPropertyHelper(space_1).calculate_recipe_properties(recipe_2)
|
||||
|
||||
assert property_fat.id not in property_values
|
||||
|
||||
|
||||
187
cookbook/tests/other/test_unit_conversion.py
Normal file
187
cookbook/tests/other/test_unit_conversion.py
Normal file
@@ -0,0 +1,187 @@
|
||||
from _decimal import Decimal
|
||||
|
||||
from django.contrib import auth
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.helper.unit_conversion_helper import UnitConversionHelper, ConversionException
|
||||
from cookbook.models import Unit, Food, Ingredient, UnitConversion
|
||||
|
||||
|
||||
def test_base_converter(space_1):
|
||||
uch = UnitConversionHelper(space_1)
|
||||
assert abs(uch.convert_from_to('g', 'kg', 1234) - Decimal(1.234)) < 0.0001
|
||||
assert abs(uch.convert_from_to('kg', 'pound', 2) - Decimal(4.40924)) < 0.00001
|
||||
assert abs(uch.convert_from_to('kg', 'g', 1) - Decimal(1000)) < 0.00001
|
||||
assert abs(uch.convert_from_to('imperial_gallon', 'gallon', 1000) - Decimal(1200.95104)) < 0.00001
|
||||
assert abs(uch.convert_from_to('tbsp', 'ml', 20) - Decimal(295.73549)) < 0.00001
|
||||
|
||||
try:
|
||||
assert uch.convert_from_to('kg', 'tbsp', 2) == 1234
|
||||
assert False
|
||||
except ConversionException:
|
||||
assert True
|
||||
|
||||
try:
|
||||
assert uch.convert_from_to('kg', 'g2', 2) == 1234
|
||||
assert False
|
||||
except ConversionException:
|
||||
assert True
|
||||
|
||||
|
||||
def test_unit_conversions(space_1, space_2, u1_s1):
|
||||
with scopes_disabled():
|
||||
uch = UnitConversionHelper(space_1)
|
||||
uch_space_2 = UnitConversionHelper(space_2)
|
||||
|
||||
unit_gram = Unit.objects.create(name='gram', base_unit='g', space=space_1)
|
||||
unit_kg = Unit.objects.create(name='kg', base_unit='kg', space=space_1)
|
||||
unit_pcs = Unit.objects.create(name='pcs', base_unit='', space=space_1)
|
||||
unit_floz1 = Unit.objects.create(name='fl. oz 1', base_unit='imperial_fluid_ounce', space=space_1) # US and UK use different volume systems (US vs imperial)
|
||||
unit_floz2 = Unit.objects.create(name='fl. oz 2', base_unit='fluid_ounce', space=space_1)
|
||||
unit_fantasy = Unit.objects.create(name='Fantasy Unit', base_unit='', space=space_1)
|
||||
|
||||
food_1 = Food.objects.create(name='Test Food 1', space=space_1)
|
||||
food_2 = Food.objects.create(name='Test Food 2', space=space_1)
|
||||
|
||||
print('\n----------- TEST BASE CONVERSIONS - GRAM ---------------')
|
||||
ingredient_food_1_gram = Ingredient.objects.create(
|
||||
food=food_1,
|
||||
unit=unit_gram,
|
||||
amount=100,
|
||||
space=space_1,
|
||||
)
|
||||
|
||||
conversions = uch.get_conversions(ingredient_food_1_gram)
|
||||
print(conversions)
|
||||
assert len(conversions) == 2
|
||||
assert next(x for x in conversions if x.unit == unit_kg) is not None
|
||||
assert abs(next(x for x in conversions if x.unit == unit_kg).amount - Decimal(0.1)) < 0.0001
|
||||
|
||||
print('\n----------- TEST BASE CONVERSIONS - VOLUMES ---------------')
|
||||
|
||||
ingredient_food_1_floz1 = Ingredient.objects.create(
|
||||
food=food_1,
|
||||
unit=unit_floz1,
|
||||
amount=100,
|
||||
space=space_1,
|
||||
)
|
||||
|
||||
conversions = uch.get_conversions(ingredient_food_1_floz1)
|
||||
assert len(conversions) == 2
|
||||
assert next(x for x in conversions if x.unit == unit_floz2) is not None
|
||||
assert abs(next(x for x in conversions if x.unit == unit_floz2).amount - Decimal(96.07599404038842)) < 0.001 # TODO validate value
|
||||
|
||||
print(conversions)
|
||||
|
||||
unit_pint = Unit.objects.create(name='pint', base_unit='pint', space=space_1)
|
||||
conversions = uch.get_conversions(ingredient_food_1_floz1)
|
||||
assert len(conversions) == 3
|
||||
assert next(x for x in conversions if x.unit == unit_pint) is not None
|
||||
assert abs(next(x for x in conversions if x.unit == unit_pint).amount - Decimal(6.004749627524276)) < 0.001 # TODO validate value
|
||||
|
||||
print(conversions)
|
||||
|
||||
print('\n----------- TEST BASE CUSTOM CONVERSION - TO CUSTOM CONVERSION ---------------')
|
||||
UnitConversion.objects.create(
|
||||
base_amount=1000,
|
||||
base_unit=unit_gram,
|
||||
converted_amount=1337,
|
||||
converted_unit=unit_fantasy,
|
||||
space=space_1,
|
||||
created_by=auth.get_user(u1_s1),
|
||||
)
|
||||
conversions = uch.get_conversions(ingredient_food_1_gram)
|
||||
|
||||
assert len(conversions) == 3
|
||||
assert next(x for x in conversions if x.unit == unit_fantasy) is not None
|
||||
assert abs(next(x for x in conversions if x.unit == unit_fantasy).amount - Decimal('133.700')) < 0.001 # TODO validate value
|
||||
|
||||
print(conversions)
|
||||
|
||||
print('\n----------- TEST CUSTOM CONVERSION - NO PCS ---------------')
|
||||
ingredient_food_1_pcs = Ingredient.objects.create(
|
||||
food=food_1,
|
||||
unit=unit_pcs,
|
||||
amount=5,
|
||||
space=space_1,
|
||||
)
|
||||
|
||||
ingredient_food_2_pcs = Ingredient.objects.create(
|
||||
food=food_2,
|
||||
unit=unit_pcs,
|
||||
amount=5,
|
||||
space=space_1,
|
||||
)
|
||||
|
||||
assert len(uch.get_conversions(ingredient_food_1_pcs)) == 1
|
||||
assert len(uch.get_conversions(ingredient_food_2_pcs)) == 1
|
||||
print(uch.get_conversions(ingredient_food_1_pcs))
|
||||
print(uch.get_conversions(ingredient_food_2_pcs))
|
||||
|
||||
print('\n----------- TEST CUSTOM CONVERSION - PCS TO MULTIPLE BASE ---------------')
|
||||
uc1 = UnitConversion.objects.create(
|
||||
base_amount=1,
|
||||
base_unit=unit_pcs,
|
||||
converted_amount=200,
|
||||
converted_unit=unit_gram,
|
||||
food=food_1,
|
||||
space=space_1,
|
||||
created_by=auth.get_user(u1_s1),
|
||||
)
|
||||
|
||||
conversions = uch.get_conversions(ingredient_food_1_pcs)
|
||||
assert len(conversions) == 3
|
||||
assert abs(next(x for x in conversions if x.unit == unit_gram).amount - Decimal(1000)) < 0.0001
|
||||
assert abs(next(x for x in conversions if x.unit == unit_kg).amount - Decimal(1)) < 0.0001
|
||||
print(conversions)
|
||||
|
||||
assert len(uch.get_conversions(ingredient_food_2_pcs)) == 1
|
||||
print(uch.get_conversions(ingredient_food_2_pcs))
|
||||
|
||||
print('\n----------- TEST CUSTOM CONVERSION - CONVERT MULTI STEP ---------------')
|
||||
|
||||
# TODO add test for multi step conversion ... do I even do or want to support this ?
|
||||
|
||||
print('\n----------- TEST CUSTOM CONVERSION - REVERSE CONVERSION ---------------')
|
||||
uc2 = UnitConversion.objects.create(
|
||||
base_amount=200,
|
||||
base_unit=unit_gram,
|
||||
converted_amount=1,
|
||||
converted_unit=unit_pcs,
|
||||
food=food_2,
|
||||
space=space_1,
|
||||
created_by=auth.get_user(u1_s1),
|
||||
)
|
||||
|
||||
conversions = uch.get_conversions(ingredient_food_1_pcs)
|
||||
assert len(conversions) == 3
|
||||
assert abs(next(x for x in conversions if x.unit == unit_gram).amount - Decimal(1000)) < 0.0001
|
||||
assert abs(next(x for x in conversions if x.unit == unit_kg).amount - Decimal(1)) < 0.0001
|
||||
print(conversions)
|
||||
|
||||
conversions = uch.get_conversions(ingredient_food_2_pcs)
|
||||
assert len(conversions) == 3
|
||||
assert abs(next(x for x in conversions if x.unit == unit_gram).amount - Decimal(1000)) < 0.0001
|
||||
assert abs(next(x for x in conversions if x.unit == unit_kg).amount - Decimal(1)) < 0.0001
|
||||
print(conversions)
|
||||
|
||||
print('\n----------- TEST SPACE SEPARATION ---------------')
|
||||
uc2.space = space_2
|
||||
uc2.save()
|
||||
|
||||
conversions = uch.get_conversions(ingredient_food_2_pcs)
|
||||
assert len(conversions) == 1
|
||||
print(conversions)
|
||||
|
||||
conversions = uch_space_2.get_conversions(ingredient_food_1_gram)
|
||||
assert len(conversions) == 1
|
||||
assert not any(x for x in conversions if x.unit == unit_kg)
|
||||
print(conversions)
|
||||
|
||||
unit_kg_space_2 = Unit.objects.create(name='kg', base_unit='kg', space=space_2)
|
||||
conversions = uch_space_2.get_conversions(ingredient_food_1_gram)
|
||||
assert len(conversions) == 2
|
||||
assert not any(x for x in conversions if x.unit == unit_kg)
|
||||
assert next(x for x in conversions if x.unit == unit_kg_space_2) is not None
|
||||
assert abs(next(x for x in conversions if x.unit == unit_kg_space_2).amount - Decimal(0.1)) < 0.0001
|
||||
print(conversions)
|
||||
@@ -12,9 +12,9 @@ from recipes.version import VERSION_NUMBER
|
||||
from .models import (Automation, Comment, CustomFilter, Food, InviteLink, Keyword, MealPlan, Recipe,
|
||||
RecipeBook, RecipeBookEntry, RecipeImport, ShoppingList, Step, Storage,
|
||||
Supermarket, SupermarketCategory, Sync, SyncLog, Unit, UserFile,
|
||||
get_model_name, UserSpace, Space)
|
||||
get_model_name, UserSpace, Space, PropertyType, UnitConversion)
|
||||
from .views import api, data, delete, edit, import_export, lists, new, telegram, views
|
||||
from .views.api import CustomAuthToken
|
||||
from .views.api import CustomAuthToken, ImportOpenData
|
||||
|
||||
# extend DRF default router class to allow including additional routers
|
||||
class DefaultRouter(routers.DefaultRouter):
|
||||
@@ -40,6 +40,9 @@ router.register(r'meal-type', api.MealTypeViewSet)
|
||||
router.register(r'recipe', api.RecipeViewSet)
|
||||
router.register(r'recipe-book', api.RecipeBookViewSet)
|
||||
router.register(r'recipe-book-entry', api.RecipeBookEntryViewSet)
|
||||
router.register(r'unit-conversion', api.UnitConversionViewSet)
|
||||
router.register(r'food-property-type', api.PropertyTypeViewSet)
|
||||
router.register(r'food-property', api.PropertyViewSet)
|
||||
router.register(r'shopping-list', api.ShoppingListViewSet)
|
||||
router.register(r'shopping-list-entry', api.ShoppingListEntryViewSet)
|
||||
router.register(r'shopping-list-recipe', api.ShoppingListRecipeViewSet)
|
||||
@@ -151,6 +154,7 @@ urlpatterns = [
|
||||
path('api/', include((router.urls, 'api'))),
|
||||
path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
|
||||
path('api-token-auth/', CustomAuthToken.as_view()),
|
||||
path('api-import-open-data/', ImportOpenData.as_view(), name='api_import_open_data'),
|
||||
|
||||
path('offline/', views.offline, name='view_offline'),
|
||||
|
||||
@@ -201,7 +205,7 @@ for m in generic_models:
|
||||
)
|
||||
)
|
||||
|
||||
vue_models = [Food, Keyword, Unit, Supermarket, SupermarketCategory, Automation, UserFile, Step, CustomFilter]
|
||||
vue_models = [Food, Keyword, Unit, Supermarket, SupermarketCategory, Automation, UserFile, Step, CustomFilter, UnitConversion, PropertyType]
|
||||
for m in vue_models:
|
||||
py_name = get_model_name(m)
|
||||
url_name = py_name.replace('_', '-')
|
||||
|
||||
@@ -19,6 +19,7 @@ from annoying.functions import get_object_or_None
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.core.cache import caches
|
||||
from django.core.exceptions import FieldError, ValidationError
|
||||
from django.core.files import File
|
||||
from django.db.models import Case, Count, Exists, OuterRef, ProtectedError, Q, Subquery, Value, When, Avg, Max
|
||||
@@ -44,6 +45,7 @@ from rest_framework.parsers import MultiPartParser
|
||||
from rest_framework.renderers import JSONRenderer, TemplateHTMLRenderer
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import AnonRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ViewSetMixin
|
||||
from treebeard.exceptions import InvalidMoveToDescendant, InvalidPosition, PathOverflow
|
||||
|
||||
@@ -52,10 +54,13 @@ from cookbook.helper import recipe_url_import as helper
|
||||
from cookbook.helper.HelperFunctions import str2bool
|
||||
from cookbook.helper.image_processing import handle_image
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.open_data_importer import OpenDataImporter
|
||||
from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsOwner,
|
||||
CustomIsOwnerReadOnly, CustomIsShared,
|
||||
CustomIsSpaceOwner, CustomIsUser, group_required,
|
||||
is_space_owner, switch_user_active_space, above_space_limit, CustomRecipePermission, CustomUserPermission, CustomTokenHasReadWriteScope, CustomTokenHasScope, has_group_permission)
|
||||
is_space_owner, switch_user_active_space, above_space_limit,
|
||||
CustomRecipePermission, CustomUserPermission,
|
||||
CustomTokenHasReadWriteScope, CustomTokenHasScope, has_group_permission)
|
||||
from cookbook.helper.recipe_search import RecipeFacet, RecipeSearch
|
||||
from cookbook.helper.recipe_url_import import get_from_youtube_scraper, get_images_from_soup, clean_dict
|
||||
from cookbook.helper.scrapers.scrapers import text_scraper
|
||||
@@ -65,7 +70,7 @@ from cookbook.models import (Automation, BookmarkletImport, CookLog, CustomFilte
|
||||
MealType, Recipe, RecipeBook, RecipeBookEntry, ShareLink, ShoppingList,
|
||||
ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
|
||||
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync,
|
||||
SyncLog, Unit, UserFile, UserPreference, UserSpace, ViewLog)
|
||||
SyncLog, Unit, UserFile, UserPreference, UserSpace, ViewLog, UnitConversion, PropertyType, Property)
|
||||
from cookbook.provider.dropbox import Dropbox
|
||||
from cookbook.provider.local import Local
|
||||
from cookbook.provider.nextcloud import Nextcloud
|
||||
@@ -88,7 +93,8 @@ from cookbook.serializer import (AutomationSerializer, BookmarkletImportListSeri
|
||||
SupermarketCategorySerializer, SupermarketSerializer,
|
||||
SyncLogSerializer, SyncSerializer, UnitSerializer,
|
||||
UserFileSerializer, UserSerializer, UserPreferenceSerializer,
|
||||
UserSpaceSerializer, ViewLogSerializer, AccessTokenSerializer, FoodSimpleSerializer, RecipeExportSerializer)
|
||||
UserSpaceSerializer, ViewLogSerializer, AccessTokenSerializer, FoodSimpleSerializer,
|
||||
RecipeExportSerializer, UnitConversionSerializer, PropertyTypeSerializer, PropertySerializer)
|
||||
from cookbook.views.import_export import get_integration
|
||||
from recipes import settings
|
||||
|
||||
@@ -166,14 +172,17 @@ class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin):
|
||||
def get_queryset(self):
|
||||
self.queryset = self.queryset.filter(space=self.request.space).order_by(Lower('name').asc())
|
||||
query = self.request.query_params.get('query', None)
|
||||
fuzzy = self.request.user.searchpreference.lookup or any([self.model.__name__.lower() in x for x in
|
||||
self.request.user.searchpreference.trigram.values_list(
|
||||
'field', flat=True)])
|
||||
if self.request.user.is_authenticated:
|
||||
fuzzy = self.request.user.searchpreference.lookup or any([self.model.__name__.lower() in x for x in
|
||||
self.request.user.searchpreference.trigram.values_list(
|
||||
'field', flat=True)])
|
||||
else:
|
||||
fuzzy = True
|
||||
|
||||
if query is not None and query not in ["''", '']:
|
||||
if fuzzy and (settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']):
|
||||
if any([self.model.__name__.lower() in x for x in
|
||||
self.request.user.searchpreference.unaccent.values_list('field', flat=True)]):
|
||||
if fuzzy and (settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
|
||||
'django.db.backends.postgresql']):
|
||||
if self.request.user.is_authenticated and any([self.model.__name__.lower() in x for x in self.request.user.searchpreference.unaccent.values_list('field', flat=True)]):
|
||||
self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name__unaccent', query))
|
||||
else:
|
||||
self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name', query))
|
||||
@@ -181,14 +190,13 @@ class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin):
|
||||
else:
|
||||
# TODO have this check unaccent search settings or other search preferences?
|
||||
filter = Q(name__icontains=query)
|
||||
if any([self.model.__name__.lower() in x for x in
|
||||
self.request.user.searchpreference.unaccent.values_list('field', flat=True)]):
|
||||
filter |= Q(name__unaccent__icontains=query)
|
||||
if self.request.user.is_authenticated:
|
||||
if any([self.model.__name__.lower() in x for x in self.request.user.searchpreference.unaccent.values_list('field', flat=True)]):
|
||||
filter |= Q(name__unaccent__icontains=query)
|
||||
|
||||
self.queryset = (
|
||||
self.queryset
|
||||
.annotate(starts=Case(When(name__istartswith=query, then=(Value(100))),
|
||||
default=Value(0))) # put exact matches at the top of the result set
|
||||
self.queryset.annotate(starts=Case(When(name__istartswith=query, then=(Value(100))),
|
||||
default=Value(0))) # put exact matches at the top of the result set
|
||||
.filter(filter).order_by('-starts', Lower('name').asc())
|
||||
)
|
||||
|
||||
@@ -243,6 +251,9 @@ class MergeMixin(ViewSetMixin):
|
||||
isTree = False
|
||||
|
||||
try:
|
||||
if isinstance(source, Food):
|
||||
source.properties.remove()
|
||||
|
||||
for link in [field for field in source._meta.get_fields() if issubclass(type(field), ForeignObjectRel)]:
|
||||
linkManager = getattr(source, link.get_accessor_name())
|
||||
related = linkManager.all()
|
||||
@@ -272,6 +283,7 @@ class MergeMixin(ViewSetMixin):
|
||||
source.delete()
|
||||
return Response(content, status=status.HTTP_200_OK)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
content = {'error': True,
|
||||
'msg': _(f'An error occurred attempting to merge {source.name} with {target.name}')}
|
||||
return Response(content, status=status.HTTP_400_BAD_REQUEST)
|
||||
@@ -522,8 +534,20 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
|
||||
pagination_class = DefaultPagination
|
||||
|
||||
def get_queryset(self):
|
||||
self.request._shared_users = [x.id for x in list(self.request.user.get_shopping_share())] + [
|
||||
self.request.user.id]
|
||||
shared_users = []
|
||||
if c := caches['default'].get(
|
||||
f'shopping_shared_users_{self.request.space.id}_{self.request.user.id}', None):
|
||||
shared_users = c
|
||||
else:
|
||||
try:
|
||||
shared_users = [x.id for x in list(self.request.user.get_shopping_share())] + [
|
||||
self.request.user.id]
|
||||
caches['default'].set(
|
||||
f'shopping_shared_users_{self.request.space.id}_{self.request.user.id}',
|
||||
shared_users, timeout=5 * 60)
|
||||
# TODO ugly hack that improves API performance significantly, should be done properly
|
||||
except AttributeError: # Anonymous users (using share links) don't have shared users
|
||||
pass
|
||||
|
||||
self.queryset = super().get_queryset()
|
||||
shopping_status = ShoppingListEntry.objects.filter(space=self.request.space, food=OuterRef('id'),
|
||||
@@ -792,7 +816,32 @@ class RecipeViewSet(viewsets.ModelViewSet):
|
||||
|
||||
if self.detail: # if detail request and not list, private condition is verified by permission class
|
||||
if not share: # filter for space only if not shared
|
||||
self.queryset = self.queryset.filter(space=self.request.space)
|
||||
self.queryset = self.queryset.filter(space=self.request.space).prefetch_related(
|
||||
'keywords',
|
||||
'shared',
|
||||
'properties',
|
||||
'properties__property_type',
|
||||
'steps',
|
||||
'steps__ingredients',
|
||||
'steps__ingredients__step_set',
|
||||
'steps__ingredients__step_set__recipe_set',
|
||||
'steps__ingredients__food',
|
||||
'steps__ingredients__food__properties',
|
||||
'steps__ingredients__food__properties__property_type',
|
||||
'steps__ingredients__food__inherit_fields',
|
||||
'steps__ingredients__food__supermarket_category',
|
||||
'steps__ingredients__food__onhand_users',
|
||||
'steps__ingredients__food__substitute',
|
||||
'steps__ingredients__food__child_inherit_fields',
|
||||
|
||||
'steps__ingredients__unit',
|
||||
'steps__ingredients__unit__unit_conversion_base_relation',
|
||||
'steps__ingredients__unit__unit_conversion_base_relation__base_unit',
|
||||
'steps__ingredients__unit__unit_conversion_converted_relation',
|
||||
'steps__ingredients__unit__unit_conversion_converted_relation__converted_unit',
|
||||
'cooklog_set',
|
||||
).select_related('nutrition')
|
||||
|
||||
return super().get_queryset()
|
||||
|
||||
self.queryset = self.queryset.filter(space=self.request.space).filter(
|
||||
@@ -802,7 +851,7 @@ class RecipeViewSet(viewsets.ModelViewSet):
|
||||
params = {x: self.request.GET.get(x) if len({**self.request.GET}[x]) == 1 else self.request.GET.getlist(x) for x
|
||||
in list(self.request.GET)}
|
||||
search = RecipeSearch(self.request, **params)
|
||||
self.queryset = search.get_queryset(self.queryset).prefetch_related('cooklog_set')
|
||||
self.queryset = search.get_queryset(self.queryset).prefetch_related('keywords', 'cooklog_set')
|
||||
return self.queryset
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
@@ -921,6 +970,41 @@ class RecipeViewSet(viewsets.ModelViewSet):
|
||||
return Response(self.serializer_class(qs, many=True).data)
|
||||
|
||||
|
||||
class UnitConversionViewSet(viewsets.ModelViewSet):
|
||||
queryset = UnitConversion.objects
|
||||
serializer_class = UnitConversionSerializer
|
||||
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
|
||||
query_params = [
|
||||
QueryParam(name='food_id', description='ID of food to filter for', qtype='int'),
|
||||
]
|
||||
schema = QueryParamAutoSchema()
|
||||
|
||||
def get_queryset(self):
|
||||
food_id = self.request.query_params.get('food_id', None)
|
||||
if food_id is not None:
|
||||
self.queryset = self.queryset.filter(food_id=food_id)
|
||||
|
||||
return self.queryset.filter(space=self.request.space)
|
||||
|
||||
|
||||
class PropertyTypeViewSet(viewsets.ModelViewSet):
|
||||
queryset = PropertyType.objects
|
||||
serializer_class = PropertyTypeSerializer
|
||||
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
|
||||
|
||||
def get_queryset(self):
|
||||
return self.queryset.filter(space=self.request.space)
|
||||
|
||||
|
||||
class PropertyViewSet(viewsets.ModelViewSet):
|
||||
queryset = Property.objects
|
||||
serializer_class = PropertySerializer
|
||||
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
|
||||
|
||||
def get_queryset(self):
|
||||
return self.queryset.filter(space=self.request.space)
|
||||
|
||||
|
||||
class ShoppingListRecipeViewSet(viewsets.ModelViewSet):
|
||||
queryset = ShoppingListRecipe.objects
|
||||
serializer_class = ShoppingListRecipeSerializer
|
||||
@@ -1122,10 +1206,13 @@ class CustomAuthToken(ObtainAuthToken):
|
||||
context={'request': request})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
user = serializer.validated_data['user']
|
||||
if token := AccessToken.objects.filter(user=user, expires__gt=timezone.now(), scope__contains='read').filter(scope__contains='write').first():
|
||||
if token := AccessToken.objects.filter(user=user, expires__gt=timezone.now(), scope__contains='read').filter(
|
||||
scope__contains='write').first():
|
||||
access_token = token
|
||||
else:
|
||||
access_token = AccessToken.objects.create(user=user, token=f'tda_{str(uuid.uuid4()).replace("-", "_")}', expires=(timezone.now() + timezone.timedelta(days=365 * 5)), scope='read write app')
|
||||
access_token = AccessToken.objects.create(user=user, token=f'tda_{str(uuid.uuid4()).replace("-", "_")}',
|
||||
expires=(timezone.now() + timezone.timedelta(days=365 * 5)),
|
||||
scope='read write app')
|
||||
return Response({
|
||||
'id': access_token.id,
|
||||
'token': access_token.token,
|
||||
@@ -1153,7 +1240,8 @@ def recipe_from_source(request):
|
||||
serializer = RecipeFromSourceSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
|
||||
if (b_pk := serializer.validated_data.get('bookmarklet', None)) and (bookmarklet := BookmarkletImport.objects.filter(pk=b_pk).first()):
|
||||
if (b_pk := serializer.validated_data.get('bookmarklet', None)) and (
|
||||
bookmarklet := BookmarkletImport.objects.filter(pk=b_pk).first()):
|
||||
serializer.validated_data['url'] = bookmarklet.url
|
||||
serializer.validated_data['data'] = bookmarklet.html
|
||||
bookmarklet.delete()
|
||||
@@ -1175,14 +1263,23 @@ def recipe_from_source(request):
|
||||
# 'recipe_html': '',
|
||||
'recipe_images': [],
|
||||
}, status=status.HTTP_200_OK)
|
||||
if re.match('^(.)*/view/recipe/[0-9]+/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', url):
|
||||
recipe_json = requests.get(url.replace('/view/recipe/', '/api/recipe/').replace(re.split('/view/recipe/[0-9]+', url)[1], '') + '?share=' + re.split('/view/recipe/[0-9]+', url)[1].replace('/', '')).json()
|
||||
if re.match(
|
||||
'^(.)*/view/recipe/[0-9]+/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
|
||||
url):
|
||||
recipe_json = requests.get(
|
||||
url.replace('/view/recipe/', '/api/recipe/').replace(re.split('/view/recipe/[0-9]+', url)[1],
|
||||
'') + '?share=' +
|
||||
re.split('/view/recipe/[0-9]+', url)[1].replace('/', '')).json()
|
||||
recipe_json = clean_dict(recipe_json, 'id')
|
||||
serialized_recipe = RecipeExportSerializer(data=recipe_json, context={'request': request})
|
||||
if serialized_recipe.is_valid():
|
||||
recipe = serialized_recipe.save()
|
||||
recipe.image = File(handle_image(request, File(io.BytesIO(requests.get(recipe_json['image']).content), name='image'), filetype=pathlib.Path(recipe_json['image']).suffix),
|
||||
name=f'{uuid.uuid4()}_{recipe.pk}{pathlib.Path(recipe_json["image"]).suffix}')
|
||||
if validators.url(recipe_json['image'], public=True):
|
||||
recipe.image = File(handle_image(request,
|
||||
File(io.BytesIO(requests.get(recipe_json['image']).content),
|
||||
name='image'),
|
||||
filetype=pathlib.Path(recipe_json['image']).suffix),
|
||||
name=f'{uuid.uuid4()}_{recipe.pk}{pathlib.Path(recipe_json["image"]).suffix}')
|
||||
recipe.save()
|
||||
return Response({
|
||||
'link': request.build_absolute_uri(reverse('view_recipe', args={recipe.pk}))
|
||||
@@ -1211,8 +1308,12 @@ def recipe_from_source(request):
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
try:
|
||||
json.loads(data)
|
||||
data = "<script type='application/ld+json'>" + data + "</script>"
|
||||
data_json = json.loads(data)
|
||||
if '@context' not in data_json:
|
||||
data_json['@context'] = 'https://schema.org'
|
||||
if '@type' not in data_json:
|
||||
data_json['@type'] = 'Recipe'
|
||||
data = "<script type='application/ld+json'>" + json.dumps(data_json) + "</script>"
|
||||
except JSONDecodeError:
|
||||
pass
|
||||
scrape = text_scraper(text=data, url=url)
|
||||
@@ -1323,11 +1424,44 @@ def import_files(request):
|
||||
|
||||
return Response({'import_id': il.pk}, status=status.HTTP_200_OK)
|
||||
except NotImplementedError:
|
||||
return Response({'error': True, 'msg': _('Importing is not implemented for this provider')}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response({'error': True, 'msg': _('Importing is not implemented for this provider')},
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
return Response({'error': True, 'msg': form.errors}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class ImportOpenData(APIView):
|
||||
permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope]
|
||||
|
||||
def get(self, request, format=None):
|
||||
response = requests.get('https://raw.githubusercontent.com/TandoorRecipes/open-tandoor-data/main/build/meta.json')
|
||||
metadata = json.loads(response.content)
|
||||
return Response(metadata)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
# TODO validate data
|
||||
print(request.data)
|
||||
selected_version = request.data['selected_version']
|
||||
selected_datatypes = request.data['selected_datatypes']
|
||||
update_existing = str2bool(request.data['update_existing'])
|
||||
use_metric = str2bool(request.data['use_metric'])
|
||||
|
||||
response = requests.get(f'https://raw.githubusercontent.com/TandoorRecipes/open-tandoor-data/main/build/{selected_version}.json') # TODO catch 404, timeout, ...
|
||||
data = json.loads(response.content)
|
||||
|
||||
response_obj = {}
|
||||
|
||||
data_importer = OpenDataImporter(request, data, update_existing=update_existing, use_metric=use_metric)
|
||||
response_obj['unit'] = len(data_importer.import_units())
|
||||
response_obj['category'] = len(data_importer.import_category())
|
||||
response_obj['property'] = len(data_importer.import_property())
|
||||
response_obj['store'] = len(data_importer.import_supermarket())
|
||||
response_obj['food'] = len(data_importer.import_food())
|
||||
response_obj['conversion'] = len(data_importer.import_conversion())
|
||||
|
||||
return Response(response_obj)
|
||||
|
||||
|
||||
def get_recipe_provider(recipe):
|
||||
if recipe.storage.method == Storage.DROPBOX:
|
||||
return Dropbox
|
||||
|
||||
@@ -228,3 +228,33 @@ def step(request):
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@group_required('user')
|
||||
def unit_conversion(request):
|
||||
# model-name is the models.js name of the model, probably ALL-CAPS
|
||||
return render(
|
||||
request,
|
||||
'generic/model_template.html',
|
||||
{
|
||||
"title": _("Unit Conversions"),
|
||||
"config": {
|
||||
'model': "UNIT_CONVERSION", # *REQUIRED* name of the model in models.js
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@group_required('user')
|
||||
def property_type(request):
|
||||
# model-name is the models.js name of the model, probably ALL-CAPS
|
||||
return render(
|
||||
request,
|
||||
'generic/model_template.html',
|
||||
{
|
||||
"title": _("Property Types"),
|
||||
"config": {
|
||||
'model': "PROPERTY_TYPE", # *REQUIRED* name of the model in models.js
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import update_session_auth_hash
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.forms import PasswordChangeForm
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -18,11 +15,9 @@ from django.urls import reverse, reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from django_scopes import scopes_disabled
|
||||
from oauth2_provider.models import AccessToken
|
||||
|
||||
from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, ShoppingPreferenceForm,
|
||||
SpaceCreateForm, SpaceJoinForm, User,
|
||||
UserCreateForm, UserNameForm, UserPreference, UserPreferenceForm)
|
||||
from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, SpaceCreateForm, SpaceJoinForm, User,
|
||||
UserCreateForm, UserPreference)
|
||||
from cookbook.helper.permission_helper import group_required, has_group_permission, share_link_valid, switch_user_active_space
|
||||
from cookbook.models import (Comment, CookLog, InviteLink, SearchFields, SearchPreference, ShareLink,
|
||||
Space, ViewLog, UserSpace)
|
||||
|
||||
@@ -239,6 +239,9 @@ RecetteTek exports are `.rtk` files which can simply be uploaded to tandoor to i
|
||||
## Rezeptsuite.de
|
||||
Rezeptsuite.de exports are `.xml` files which can simply be uploaded to tandoor to import all your recipes.
|
||||
|
||||
It appears that Reptsuite, depending on the client, might export a `.zip` file containing a `.cml` file.
|
||||
If this happens just unzip the zip file and change `.cml` to `.xml` to import your recipes.
|
||||
|
||||
## Melarecipes
|
||||
|
||||
Melarecipes provides multiple export formats but only the `MelaRecipes` format can export the complete collection.
|
||||
|
||||
131
docs/install/truenas_portainer.md
Normal file
131
docs/install/truenas_portainer.md
Normal file
@@ -0,0 +1,131 @@
|
||||
!!! info "Community Contributed"
|
||||
This guide was contributed by the community and is neither officially supported, nor updated or tested.
|
||||
|
||||
This guide is to assist those installing Tandoor Recipes on Truenas Core using Docker and or Portainer
|
||||
|
||||
Docker install instructions adapted from [PhasedLogix IT Services's guide](https://getmethegeek.com/blog/2021-01-07-add-docker-capabilities-to-truenas-core/). Portainer install instructions adopted from the [Portainer Official Documentation](https://docs.portainer.io/start/install-ce/server/docker/linux). Tandoor installation on Portainer provided by users `Szeraax` and `TransatlanticFoe` on Discord (Thank you two!)
|
||||
|
||||
## **Instructions**
|
||||
|
||||
Basic guide to setup Docker and Portainer TrueNAS Core.
|
||||
|
||||
### 1. Login to TrueNAS through your browser
|
||||
- Go to the Virtual Machines Menu
|
||||

|
||||
- Click next to dedicate resources to the VM (see below image of authors setup, you may need to change resources to fit your needs)
|
||||

|
||||
- Hit next to go to disk setup
|
||||
-You want to create a new disk, here are the settings you should use
|
||||
-Disk Type: AHCI
|
||||
-Zvol location: tank/vm (Or wherever you have your VM memory located at)
|
||||
-Size: Atleast 30 gigs
|
||||

|
||||
-Hit next to go to network interface (The defaults are fine but make sure you select the right network adapter)
|
||||
-Hit next to go to installation
|
||||
-Navigate to your ubuntu ISO file (The original author and this author used Ubuntu Server. This OS uses less resources than some other OS's and can be ran Headless with either VNC or SSH access. You can use other OS's, but this guide was written with Ubuntu Server)
|
||||
-Hit next, then submit, you have made the virtual machine!
|
||||
-Open the virtual machine then hit VNC to open ubuntu
|
||||

|
||||
-Once its up choose your language and go through the installer
|
||||
-Once you are done with the setup we want to SSH into the ubuntu VM to setup docker
|
||||
-Open powershell and type SSH "user"@(ip) (replace "user" with the user you setup in the OS installation)
|
||||
-Enter your Password if requested
|
||||
-Close the VNC Console
|
||||
-Go back into the SSH console and get ready to type some commands. Type these commands in order:
|
||||
`sudo apt update`
|
||||
`sudo apt install apt-transport-https ca-certificates curl software-properties-common`
|
||||
`y` (If prompted with a question)
|
||||
`curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -`
|
||||
`sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu focal stable"`
|
||||
`sudo apt update`
|
||||
`apt-cache policy docker-ce`
|
||||
-To make it so you don’t have to use sudo for every docker command run this command
|
||||
`sudo usermod -aG docker ${USER}`
|
||||
`su - ${USER}`
|
||||
|
||||
### 2. Install Portainer
|
||||
!!! Note: By default, Portainer Server will expose the UI over port 9443 and expose a TCP tunnel server over port 8000. The latter is optional and is only required if you plan to use the Edge compute features with Edge agents.
|
||||
|
||||
-First, create the volume that Portainer Server will use to store its database:
|
||||
`docker volume create portainer_data`
|
||||
-Then, download and install the Portainer Server container:
|
||||
`docker run -d -p 8000:8000 -p 9443:9443 --name portainer --restart=always -v /var/run/docker.sock:/var/run/docker.sock -v portainer_data:/data portainer/portainer-ce:latest`
|
||||
-Portainer Server has now been installed. You can check to see whether the Portainer Server container has started by running `docker ps`
|
||||
-Now that the installation is complete, you can log into your Portainer Server instance by opening a web browser and going to:
|
||||
`https://localhost:9443`
|
||||
-Replace `localhost` with the relevant IP address or FQDN if needed, and adjust the port if you changed it earlier.
|
||||
-You will be presented with the initial setup page for Portainer Server.
|
||||
-Create your first user
|
||||
-Your first user will be an administrator. The username defaults to admin but you can change it if you prefer. The password must be at least 12 characters long and meet the listed password requirements.
|
||||
-Connect Portainer to your environments.
|
||||
-Once the admin user has been created, the "Environment Wizard" will automatically launch. The wizard will help get you started with Portainer.
|
||||
-Select "Get Started" to use the Enviroment Portainer is running in
|
||||

|
||||
|
||||
### 3. Install Tandoor Recipies VIA Portainer Web Editor
|
||||
-From the menu select Stacks, click Add stack, give the stack a descriptive name then select Web editor.
|
||||

|
||||
-Use the below code and input it into the Web Editor:
|
||||
|
||||
`version: "3"
|
||||
services:
|
||||
db_recipes:
|
||||
restart: always
|
||||
image: postgres:11-alpine
|
||||
volumes:
|
||||
- ./postgresql:/var/lib/postgresql/data
|
||||
env_file:
|
||||
- stack.env
|
||||
|
||||
web_recipes:
|
||||
# image: vabene1111/recipes:latest
|
||||
image: vabene1111/recipes:beta
|
||||
env_file:
|
||||
- stack.env
|
||||
volumes:
|
||||
- staticfiles:/opt/recipes/staticfiles
|
||||
- nginx_config:/opt/recipes/nginx/conf.d
|
||||
- ./mediafiles:/opt/recipes/mediafiles
|
||||
depends_on:
|
||||
- db_recipes
|
||||
|
||||
nginx_recipes:
|
||||
image: nginx:mainline-alpine
|
||||
restart: always
|
||||
ports:
|
||||
- 12008:80
|
||||
env_file:
|
||||
- stack.env
|
||||
depends_on:
|
||||
- web_recipes
|
||||
volumes:
|
||||
- nginx_config:/etc/nginx/conf.d:ro
|
||||
- staticfiles:/static
|
||||
- ./mediafiles:/media
|
||||
|
||||
volumes:
|
||||
nginx_config:
|
||||
staticfiles:`
|
||||
|
||||
-Download the .env template from [HERE](https://raw.githubusercontent.com/vabene1111/recipes/develop/.env.template) and load this file by pressing the "Load Variables from .env File" button:
|
||||

|
||||
|
||||
-You will need to change the following variables:
|
||||
-`SECRET_KEY` needs to be replaced with a new key. This can be generated from websites like [Djecrety](https://djecrety.ir/)
|
||||
-`TIMEZONE` needs to be replaced with the appropriate code for your timezone. Accepted values can be found at [TimezoneDB](https://timezonedb.com/time-zones)
|
||||
-`POSTGRES_USER` and `POSTGRES_PASSWORD` needs to be replaced with your username and password from [PostgreSQL](https://www.postgresql.org/) !!!NOTE Do not sign in using social media. You need to sign up using Email and Password.
|
||||
-After those veriables are changed, you may press the `Deploy the Stack` button at the bottom of the page. This will create the needed containers to run Tandoor Recipes.
|
||||
|
||||
### 4. Login and Setup your new server!
|
||||
- You need to access your Tandoor Server through its Webpage: `https://localhost:xxxx` replacing `localhost` with the IP of the VM running Docker and `xxxx` with the port you chose in the Web Editor for `nginx_recipes` above. In this case, `12008`.
|
||||
!!! While the containers are starting and doing whatever they need to do, you might still get HTTP errors e.g. 500 or 502. Just be patient and try again in a moment
|
||||
-You will now need to set up the Tandoor Server through the WebGUI.
|
||||
@@ -33,6 +33,7 @@ nav:
|
||||
- Synology: install/synology.md
|
||||
- Kubernetes: install/kubernetes.md
|
||||
- KubeSail or PiBox: install/kubesail.md
|
||||
- TrueNAS Portainer: install/truenas_portainer.md
|
||||
- WSL: install/wsl.md
|
||||
- Manual: install/manual.md
|
||||
- Other setups: install/other.md
|
||||
@@ -40,6 +41,7 @@ nav:
|
||||
- Templating: features/templating.md
|
||||
- Shopping: features/shopping.md
|
||||
- Authentication: features/authentication.md
|
||||
- Automation: features/automation.md
|
||||
- Storages and Sync: features/external_recipes.md
|
||||
- Import/Export: features/import_export.md
|
||||
- System:
|
||||
|
||||
@@ -11,6 +11,7 @@ https://docs.djangoproject.com/en/2.0/ref/settings/
|
||||
"""
|
||||
import ast
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
@@ -147,6 +148,8 @@ try:
|
||||
'base_url': plugin_class.base_url,
|
||||
'bundle_name': plugin_class.bundle_name if hasattr(plugin_class, 'bundle_name') else '',
|
||||
'api_router_name': plugin_class.api_router_name if hasattr(plugin_class, 'api_router_name') else '',
|
||||
'nav_main': plugin_class.nav_main if hasattr(plugin_class, 'nav_main') else '',
|
||||
'nav_dropdown': plugin_class.nav_dropdown if hasattr(plugin_class, 'nav_dropdown') else '',
|
||||
}
|
||||
PLUGINS.append(plugin_config)
|
||||
except Exception:
|
||||
@@ -448,6 +451,7 @@ LANGUAGES = [
|
||||
('hu', _('Hungarian')),
|
||||
('it', _('Italian')),
|
||||
('lv', _('Latvian')),
|
||||
('nb', _('Norwegian ')),
|
||||
('pl', _('Polish')),
|
||||
('ru', _('Russian')),
|
||||
('es', _('Spanish')),
|
||||
@@ -517,3 +521,5 @@ EMAIL_USE_SSL = bool(int(os.getenv('EMAIL_USE_SSL', False)))
|
||||
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'webmaster@localhost')
|
||||
ACCOUNT_EMAIL_SUBJECT_PREFIX = os.getenv(
|
||||
'ACCOUNT_EMAIL_SUBJECT_PREFIX', '[Tandoor Recipes] ') # allauth sender prefix
|
||||
|
||||
mimetypes.add_type("text/javascript", ".js", True)
|
||||
@@ -1,5 +1,5 @@
|
||||
Django==4.1.9
|
||||
cryptography==39.0.1
|
||||
cryptography==41.0.0
|
||||
django-annoying==0.10.6
|
||||
django-autocomplete-light==3.9.4
|
||||
django-cleanup==7.0.0
|
||||
|
||||
@@ -31,18 +31,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image and misc properties -->
|
||||
<!-- Image and misc -->
|
||||
<div class="row pt-2">
|
||||
<div class="col-md-6" style="max-height: 50vh; min-height: 30vh">
|
||||
<input id="id_file_upload" ref="file_upload" type="file" hidden
|
||||
@change="uploadImage($event.target.files[0])"/>
|
||||
|
||||
<div
|
||||
class="h-100 w-100 border border-primary rounded"
|
||||
style="border-width: 2px !important; border-style: dashed !important"
|
||||
@drop.prevent="uploadImage($event.dataTransfer.files[0])"
|
||||
@dragover.prevent
|
||||
@click="$refs.file_upload.click()"
|
||||
class="h-100 w-100 border border-primary rounded"
|
||||
style="border-width: 2px !important; border-style: dashed !important"
|
||||
@drop.prevent="uploadImage($event.dataTransfer.files[0])"
|
||||
@dragover.prevent
|
||||
@click="$refs.file_upload.click()"
|
||||
>
|
||||
<i class="far fa-image fa-10x text-primary"
|
||||
style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%)"
|
||||
@@ -71,27 +71,27 @@
|
||||
<br/>
|
||||
<label for="id_name"> {{ $t("Keywords") }}</label>
|
||||
<multiselect
|
||||
v-model="recipe.keywords"
|
||||
:options="keywords"
|
||||
:close-on-select="false"
|
||||
:clear-on-select="true"
|
||||
:hide-selected="true"
|
||||
:preserve-search="true"
|
||||
:internal-search="false"
|
||||
:limit="options_limit"
|
||||
:placeholder="$t('select_keyword')"
|
||||
:tag-placeholder="$t('add_keyword')"
|
||||
:select-label="$t('Select')"
|
||||
:selected-label="$t('Selected')"
|
||||
:deselect-label="$t('remove_selection')"
|
||||
:taggable="true"
|
||||
@tag="addKeyword"
|
||||
label="label"
|
||||
track-by="id"
|
||||
id="id_keywords"
|
||||
:multiple="true"
|
||||
:loading="keywords_loading"
|
||||
@search-change="searchKeywords"
|
||||
v-model="recipe.keywords"
|
||||
:options="keywords"
|
||||
:close-on-select="false"
|
||||
:clear-on-select="true"
|
||||
:hide-selected="true"
|
||||
:preserve-search="true"
|
||||
:internal-search="false"
|
||||
:limit="options_limit"
|
||||
:placeholder="$t('select_keyword')"
|
||||
:tag-placeholder="$t('add_keyword')"
|
||||
:select-label="$t('Select')"
|
||||
:selected-label="$t('Selected')"
|
||||
:deselect-label="$t('remove_selection')"
|
||||
:taggable="true"
|
||||
@tag="addKeyword"
|
||||
label="label"
|
||||
track-by="id"
|
||||
id="id_keywords"
|
||||
:multiple="true"
|
||||
:loading="keywords_loading"
|
||||
@search-change="searchKeywords"
|
||||
>
|
||||
<template v-slot:noOptions>{{ $t("empty_list") }}</template>
|
||||
</multiselect>
|
||||
@@ -99,65 +99,53 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nutrition -->
|
||||
<div class="row pt-2">
|
||||
<div class="col-md-12">
|
||||
<div class="card border-grey">
|
||||
<div class="card-header" style="display: table">
|
||||
<div class="row">
|
||||
<div class="col-md-9 d-table">
|
||||
<h5 class="d-table-cell align-middle">{{ $t("Nutrition") }}</h5>
|
||||
<div class="card mt-2 mb-2">
|
||||
<div class="card-body pr-2 pl-2 pr-md-5 pl-md-5 pt-3 pb-3">
|
||||
<h6>{{ $t('Properties') }} <small class="text-muted"> {{$t('per_serving')}}</small></h6>
|
||||
|
||||
<div class="alert alert-info" role="alert">
|
||||
{{ $t('recipe_property_info')}}
|
||||
</div>
|
||||
|
||||
<div class="d-flex mt-2" v-for="p in recipe.properties" v-bind:key="p.id">
|
||||
<div class="flex-fill w-50">
|
||||
<generic-multiselect
|
||||
@change="p.property_type = $event.val"
|
||||
:initial_single_selection="p.property_type"
|
||||
:label="'name'"
|
||||
:model="Models.PROPERTY_TYPE"
|
||||
:limit="25"
|
||||
:multiple="false"
|
||||
></generic-multiselect>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="addNutrition()"
|
||||
v-if="recipe.nutrition === null"
|
||||
v-b-tooltip.hover
|
||||
v-bind:title="$t('Add_nutrition_recipe')"
|
||||
class="btn btn-sm btn-success shadow-none float-right"
|
||||
>
|
||||
<i class="fas fa-plus-circle"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeNutrition()"
|
||||
v-if="recipe.nutrition !== null"
|
||||
v-b-tooltip.hover
|
||||
v-bind:title="$t('Remove_nutrition_recipe')"
|
||||
class="btn btn-sm btn-danger shadow-none float-right"
|
||||
>
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
<div class="flex-fill w-50">
|
||||
<div class="input-group">
|
||||
<input type="number" class="form-control" v-model="p.property_amount">
|
||||
<div class="input-group-append">
|
||||
<span class="input-group-text" v-if="p.property_type !== null && p.property_type.unit !== ''">{{ p.property_type.unit }}</span>
|
||||
<button class="btn btn-danger" @click="deleteProperty(p)"><i class="fa fa-trash fa-fw"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-row mt-2">
|
||||
<div class="flex-column w-25 offset-4">
|
||||
<button class="btn btn-success btn-block" @click="addProperty()"><i class="fa fa-plus"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b-collapse id="id_nutrition_collapse" class="mt-2" v-model="nutrition_visible">
|
||||
<div class="card-body" v-if="recipe.nutrition !== null">
|
||||
<b-alert show>
|
||||
There is currently only very basic support for tracking nutritional information. A
|
||||
<a href="https://github.com/vabene1111/recipes/issues/896" target="_blank"
|
||||
rel="noreferrer nofollow">big update</a> is planned to improve on this in many
|
||||
different areas.
|
||||
</b-alert>
|
||||
|
||||
<label for="id_name"> {{ $t(energy()) }}</label>
|
||||
|
||||
<input class="form-control" id="id_calories" v-model="recipe.nutrition.calories"/>
|
||||
|
||||
<label for="id_name"> {{ $t("Carbohydrates") }}</label>
|
||||
<input class="form-control" id="id_carbohydrates"
|
||||
v-model="recipe.nutrition.carbohydrates"/>
|
||||
|
||||
<label for="id_name"> {{ $t("Fats") }}</label>
|
||||
<input class="form-control" id="id_fats" v-model="recipe.nutrition.fats"/>
|
||||
|
||||
<label for="id_name"> {{ $t("Proteins") }}</label>
|
||||
<input class="form-control" id="id_proteins" v-model="recipe.nutrition.proteins"/>
|
||||
</div>
|
||||
</b-collapse>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row pt-2">
|
||||
<div class="col-md-12">
|
||||
|
||||
|
||||
<b-card-header header-tag="header" class="p-1" role="tab">
|
||||
<b-button squared block v-b-toggle.additional_collapse class="text-left"
|
||||
variant="outline-primary">{{ $t("additional_options") }}
|
||||
@@ -202,14 +190,14 @@
|
||||
<br/>
|
||||
<label> {{ $t("Share") }}</label>
|
||||
<generic-multiselect
|
||||
@change="recipe.shared = $event.val"
|
||||
parent_variable="recipe.shared"
|
||||
:initial_selection="recipe.shared"
|
||||
:label="'display_name'"
|
||||
:model="Models.USER_NAME"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
v-bind:placeholder="$t('Share')"
|
||||
:limit="25"
|
||||
@change="recipe.shared = $event.val"
|
||||
parent_variable="recipe.shared"
|
||||
:initial_selection="recipe.shared"
|
||||
:label="'display_name'"
|
||||
:model="Models.USER_NAME"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
v-bind:placeholder="$t('Share')"
|
||||
:limit="25"
|
||||
></generic-multiselect>
|
||||
|
||||
|
||||
@@ -240,7 +228,7 @@
|
||||
|
||||
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuLink">
|
||||
<button class="dropdown-item" @click="removeStep(step)"><i
|
||||
class="fa fa-trash fa-fw"></i> {{ $t("Delete") }}
|
||||
class="fa fa-trash fa-fw"></i> {{ $t("Delete") }}
|
||||
</button>
|
||||
|
||||
<button class="dropdown-item" @click="moveStep(step, step_index - 1)"
|
||||
@@ -303,11 +291,11 @@
|
||||
<i class="fas fa-plus-circle"></i> {{ $t("File") }}
|
||||
</b-button>
|
||||
<b-button
|
||||
pill
|
||||
variant="primary"
|
||||
size="sm"
|
||||
class="ml-1 mb-1 mb-md-0"
|
||||
@click="
|
||||
pill
|
||||
variant="primary"
|
||||
size="sm"
|
||||
class="ml-1 mb-1 mb-md-0"
|
||||
@click="
|
||||
paste_step = step
|
||||
$bvModal.show('id_modal_paste_ingredients')
|
||||
"
|
||||
@@ -330,31 +318,31 @@
|
||||
<label :for="'id_step_' + step.id + '_file'">{{ $t("File") }}</label>
|
||||
<b-input-group>
|
||||
<multiselect
|
||||
ref="file"
|
||||
v-model="step.file"
|
||||
:options="files"
|
||||
:close-on-select="true"
|
||||
:clear-on-select="true"
|
||||
:allow-empty="true"
|
||||
:preserve-search="true"
|
||||
:placeholder="$t('select_file')"
|
||||
:select-label="$t('Select')"
|
||||
:selected-label="$t('Selected')"
|
||||
:deselect-label="$t('remove_selection')"
|
||||
:id="'id_step_' + step.id + '_file'"
|
||||
label="name"
|
||||
track-by="name"
|
||||
:multiple="false"
|
||||
:loading="files_loading"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
@search-change="searchFiles"
|
||||
ref="file"
|
||||
v-model="step.file"
|
||||
:options="files"
|
||||
:close-on-select="true"
|
||||
:clear-on-select="true"
|
||||
:allow-empty="true"
|
||||
:preserve-search="true"
|
||||
:placeholder="$t('select_file')"
|
||||
:select-label="$t('Select')"
|
||||
:selected-label="$t('Selected')"
|
||||
:deselect-label="$t('remove_selection')"
|
||||
:id="'id_step_' + step.id + '_file'"
|
||||
label="name"
|
||||
track-by="name"
|
||||
:multiple="false"
|
||||
:loading="files_loading"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
@search-change="searchFiles"
|
||||
>
|
||||
<template v-slot:noOptions>{{ $t("empty_list") }}</template>
|
||||
</multiselect>
|
||||
<b-input-group-append>
|
||||
<b-button
|
||||
variant="primary"
|
||||
@click="
|
||||
variant="primary"
|
||||
@click="
|
||||
step_for_file_create = step
|
||||
show_file_create = true
|
||||
"
|
||||
@@ -370,24 +358,24 @@
|
||||
<div class="col-md-12">
|
||||
<label :for="'id_step_' + step.id + '_recipe'">{{ $t("Recipe") }}</label>
|
||||
<multiselect
|
||||
ref="step_recipe"
|
||||
v-model="step.step_recipe"
|
||||
:options="recipes.map((recipe) => recipe.id)"
|
||||
:close-on-select="true"
|
||||
:clear-on-select="true"
|
||||
:allow-empty="true"
|
||||
:preserve-search="true"
|
||||
:internal-search="false"
|
||||
:limit="options_limit"
|
||||
:placeholder="$t('select_recipe')"
|
||||
:select-label="$t('Select')"
|
||||
:selected-label="$t('Selected')"
|
||||
:deselect-label="$t('remove_selection')"
|
||||
:id="'id_step_' + step.id + '_recipe'"
|
||||
:custom-label="(opt) => recipes.find((x) => x.id === opt).name"
|
||||
:multiple="false"
|
||||
:loading="recipes_loading"
|
||||
@search-change="searchRecipes"
|
||||
ref="step_recipe"
|
||||
v-model="step.step_recipe"
|
||||
:options="recipes.map((recipe) => recipe.id)"
|
||||
:close-on-select="true"
|
||||
:clear-on-select="true"
|
||||
:allow-empty="true"
|
||||
:preserve-search="true"
|
||||
:internal-search="false"
|
||||
:limit="options_limit"
|
||||
:placeholder="$t('select_recipe')"
|
||||
:select-label="$t('Select')"
|
||||
:selected-label="$t('Selected')"
|
||||
:deselect-label="$t('remove_selection')"
|
||||
:id="'id_step_' + step.id + '_recipe'"
|
||||
:custom-label="(opt) => recipes.find((x) => x.id === opt).name"
|
||||
:multiple="false"
|
||||
:loading="recipes_loading"
|
||||
@search-change="searchRecipes"
|
||||
>
|
||||
<template v-slot:noOptions>{{ $t("empty_list") }}</template>
|
||||
</multiselect>
|
||||
@@ -427,12 +415,12 @@
|
||||
<div class="col-lg-2 col-md-6 small-padding"
|
||||
v-if="!ingredient.is_header">
|
||||
<input
|
||||
class="form-control"
|
||||
v-model="ingredient.amount"
|
||||
type="number"
|
||||
step="any"
|
||||
v-if="!ingredient.no_amount"
|
||||
:id="`amount_${step_index}_${index}`"
|
||||
class="form-control"
|
||||
v-model="ingredient.amount"
|
||||
type="number"
|
||||
step="any"
|
||||
v-if="!ingredient.no_amount"
|
||||
:id="`amount_${step_index}_${index}`"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -440,29 +428,29 @@
|
||||
v-if="!ingredient.is_header">
|
||||
<!-- search set to false to allow API to drive results & order -->
|
||||
<multiselect
|
||||
v-if="!ingredient.no_amount"
|
||||
ref="unit"
|
||||
v-model="ingredient.unit"
|
||||
:options="units"
|
||||
:close-on-select="true"
|
||||
:clear-on-select="true"
|
||||
:allow-empty="true"
|
||||
:preserve-search="true"
|
||||
:internal-search="false"
|
||||
:limit="options_limit"
|
||||
:placeholder="$t('select_unit')"
|
||||
:tag-placeholder="$t('Create')"
|
||||
:select-label="$t('Select')"
|
||||
:selected-label="$t('Selected')"
|
||||
:deselect-label="$t('remove_selection')"
|
||||
:taggable="true"
|
||||
@tag="addUnitType"
|
||||
:id="`unit_${step_index}_${index}`"
|
||||
label="name"
|
||||
track-by="name"
|
||||
:multiple="false"
|
||||
:loading="units_loading"
|
||||
@search-change="searchUnits"
|
||||
v-if="!ingredient.no_amount"
|
||||
ref="unit"
|
||||
v-model="ingredient.unit"
|
||||
:options="units"
|
||||
:close-on-select="true"
|
||||
:clear-on-select="true"
|
||||
:allow-empty="true"
|
||||
:preserve-search="true"
|
||||
:internal-search="false"
|
||||
:limit="options_limit"
|
||||
:placeholder="$t('select_unit')"
|
||||
:tag-placeholder="$t('Create')"
|
||||
:select-label="$t('Select')"
|
||||
:selected-label="$t('Selected')"
|
||||
:deselect-label="$t('remove_selection')"
|
||||
:taggable="true"
|
||||
@tag="addUnitType"
|
||||
:id="`unit_${step_index}_${index}`"
|
||||
label="name"
|
||||
track-by="name"
|
||||
:multiple="false"
|
||||
:loading="units_loading"
|
||||
@search-change="searchUnits"
|
||||
>
|
||||
<template v-slot:noOptions>{{
|
||||
$t("empty_list")
|
||||
@@ -475,28 +463,28 @@
|
||||
<!-- search set to false to allow API to drive results & order -->
|
||||
|
||||
<multiselect
|
||||
ref="food"
|
||||
v-model="ingredient.food"
|
||||
:options="foods"
|
||||
:close-on-select="true"
|
||||
:clear-on-select="true"
|
||||
:allow-empty="true"
|
||||
:preserve-search="true"
|
||||
:internal-search="false"
|
||||
:limit="options_limit"
|
||||
:placeholder="$t('select_food')"
|
||||
:tag-placeholder="$t('Create')"
|
||||
:select-label="$t('Select')"
|
||||
:selected-label="$t('Selected')"
|
||||
:deselect-label="$t('remove_selection')"
|
||||
:taggable="true"
|
||||
@tag="addFoodType"
|
||||
:id="`ingredient_${step_index}_${index}`"
|
||||
label="name"
|
||||
track-by="name"
|
||||
:multiple="false"
|
||||
:loading="foods_loading"
|
||||
@search-change="searchFoods"
|
||||
ref="food"
|
||||
v-model="ingredient.food"
|
||||
:options="foods"
|
||||
:close-on-select="true"
|
||||
:clear-on-select="true"
|
||||
:allow-empty="true"
|
||||
:preserve-search="true"
|
||||
:internal-search="false"
|
||||
:limit="options_limit"
|
||||
:placeholder="$t('select_food')"
|
||||
:tag-placeholder="$t('Create')"
|
||||
:select-label="$t('Select')"
|
||||
:selected-label="$t('Selected')"
|
||||
:deselect-label="$t('remove_selection')"
|
||||
:taggable="true"
|
||||
@tag="addFoodType"
|
||||
:id="`ingredient_${step_index}_${index}`"
|
||||
label="name"
|
||||
track-by="name"
|
||||
:multiple="false"
|
||||
:loading="foods_loading"
|
||||
@search-change="searchFoods"
|
||||
>
|
||||
<template v-slot:noOptions>{{
|
||||
$t("empty_list")
|
||||
@@ -507,11 +495,11 @@
|
||||
<div class="small-padding"
|
||||
v-bind:class="{ 'col-lg-4 col-md-6': !ingredient.is_header, 'col-lg-12 col-md-12': ingredient.is_header }">
|
||||
<input
|
||||
class="form-control"
|
||||
maxlength="256"
|
||||
v-model="ingredient.note"
|
||||
v-bind:placeholder="$t('Note')"
|
||||
v-on:keydown.tab="
|
||||
class="form-control"
|
||||
maxlength="256"
|
||||
v-model="ingredient.note"
|
||||
v-bind:placeholder="$t('Note')"
|
||||
v-on:keydown.tab="
|
||||
(event) => {
|
||||
if (step.ingredients.indexOf(ingredient) === step.ingredients.length - 1) {
|
||||
event.preventDefault()
|
||||
@@ -525,13 +513,13 @@
|
||||
|
||||
<div class="flex-grow-0 small-padding">
|
||||
<a
|
||||
class="btn shadow-none btn-lg pr-1 pl-0 pr-md-2 pl-md-2"
|
||||
href="#"
|
||||
role="button"
|
||||
id="dropdownMenuLink2"
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
class="btn shadow-none btn-lg pr-1 pl-0 pr-md-2 pl-md-2"
|
||||
href="#"
|
||||
role="button"
|
||||
id="dropdownMenuLink2"
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<i class="fas fa-ellipsis-v text-muted"></i>
|
||||
</a>
|
||||
@@ -573,35 +561,35 @@
|
||||
</button>
|
||||
|
||||
|
||||
<button type="button" class="dropdown-item"
|
||||
v-if="!ingredient.always_use_plural_unit"
|
||||
@click="ingredient.always_use_plural_unit = true">
|
||||
<i class="fas fa-filter fa-fw"></i>
|
||||
{{ $t("Use_Plural_Unit_Always") }}
|
||||
</button>
|
||||
<button type="button" class="dropdown-item"
|
||||
v-if="!ingredient.always_use_plural_unit"
|
||||
@click="ingredient.always_use_plural_unit = true">
|
||||
<i class="fas fa-filter fa-fw"></i>
|
||||
{{ $t("Use_Plural_Unit_Always") }}
|
||||
</button>
|
||||
|
||||
<button type="button" class="dropdown-item"
|
||||
v-if="ingredient.always_use_plural_unit"
|
||||
@click="ingredient.always_use_plural_unit = false">
|
||||
<i class="fas fa-filter fa-fw"></i>
|
||||
{{ $t("Use_Plural_Unit_Simple") }}
|
||||
</button>
|
||||
<button type="button" class="dropdown-item"
|
||||
v-if="ingredient.always_use_plural_unit"
|
||||
@click="ingredient.always_use_plural_unit = false">
|
||||
<i class="fas fa-filter fa-fw"></i>
|
||||
{{ $t("Use_Plural_Unit_Simple") }}
|
||||
</button>
|
||||
|
||||
<button type="button" class="dropdown-item"
|
||||
v-if="!ingredient.always_use_plural_food"
|
||||
@click="ingredient.always_use_plural_food = true">
|
||||
<i class="fas fa-filter fa-fw"></i>
|
||||
{{ $t("Use_Plural_Food_Always") }}
|
||||
</button>
|
||||
<button type="button" class="dropdown-item"
|
||||
v-if="!ingredient.always_use_plural_food"
|
||||
@click="ingredient.always_use_plural_food = true">
|
||||
<i class="fas fa-filter fa-fw"></i>
|
||||
{{ $t("Use_Plural_Food_Always") }}
|
||||
</button>
|
||||
|
||||
<button type="button" class="dropdown-item"
|
||||
v-if="ingredient.always_use_plural_food"
|
||||
@click="ingredient.always_use_plural_food = false">
|
||||
<i class="fas fa-filter fa-fw"></i>
|
||||
{{ $t("Use_Plural_Food_Simple") }}
|
||||
</button>
|
||||
|
||||
<button type="button" class="dropdown-item"
|
||||
v-if="ingredient.always_use_plural_food"
|
||||
@click="ingredient.always_use_plural_food = false">
|
||||
<i class="fas fa-filter fa-fw"></i>
|
||||
{{ $t("Use_Plural_Food_Simple") }}
|
||||
</button>
|
||||
|
||||
|
||||
<button type="button" class="dropdown-item"
|
||||
@click="copyTemplateReference(index, ingredient)">
|
||||
<i class="fas fa-code"></i>
|
||||
@@ -665,7 +653,7 @@
|
||||
</button>
|
||||
|
||||
<button type="button" v-b-modal:id_modal_sort class="btn btn-warning shadow-none"><i
|
||||
class="fas fa-sort-amount-down-alt fa-lg"></i></button>
|
||||
class="fas fa-sort-amount-down-alt fa-lg"></i></button>
|
||||
</b-button-group>
|
||||
</div>
|
||||
</div>
|
||||
@@ -697,7 +685,7 @@
|
||||
<div class="col-3 col-md-6 mb-1 mb-md-0 pr-2 pl-2">
|
||||
<a :href="resolveDjangoUrl('delete_recipe', recipe.id)"
|
||||
class="d-block d-md-none btn btn-block btn-danger shadow-none btn-sm"><i
|
||||
class="fa fa-trash fa-lg"></i></a>
|
||||
class="fa fa-trash fa-lg"></i></a>
|
||||
<a :href="resolveDjangoUrl('delete_recipe', recipe.id)"
|
||||
class="d-none d-md-block btn btn-block btn-danger shadow-none btn-sm">{{ $t("Delete") }}</a>
|
||||
</div>
|
||||
@@ -752,11 +740,11 @@
|
||||
|
||||
<!-- modal for pasting list of ingredients -->
|
||||
<b-modal
|
||||
id="id_modal_paste_ingredients"
|
||||
v-bind:title="$t('ingredient_list')"
|
||||
@ok="appendIngredients(paste_step)"
|
||||
@cancel="paste_ingredients = paste_step = undefined"
|
||||
@close="paste_ingredients = paste_step = undefined"
|
||||
id="id_modal_paste_ingredients"
|
||||
v-bind:title="$t('ingredient_list')"
|
||||
@ok="appendIngredients(paste_step)"
|
||||
@cancel="paste_ingredients = paste_step = undefined"
|
||||
@close="paste_ingredients = paste_step = undefined"
|
||||
>
|
||||
<b-form-textarea id="paste_ingredients" v-model="paste_ingredients"
|
||||
:placeholder="$t('paste_ingredients_placeholder')" rows="10"></b-form-textarea>
|
||||
@@ -1121,6 +1109,14 @@ export default {
|
||||
let new_keyword = {label: tag, name: tag}
|
||||
this.recipe.keywords.push(new_keyword)
|
||||
},
|
||||
addProperty: function () {
|
||||
this.recipe.properties.push(
|
||||
{'property_amount': 0, 'property_type': null}
|
||||
)
|
||||
},
|
||||
deleteProperty: function (recipe_property) {
|
||||
this.recipe.properties = this.recipe.properties.filter(p => p.id !== recipe_property.id)
|
||||
},
|
||||
searchKeywords: function (query) {
|
||||
let apiFactory = new ApiApiFactory()
|
||||
|
||||
|
||||
@@ -1,158 +1,6 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<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)"/>
|
||||
<div class="row">
|
||||
<div class="col-12" style="text-align: center">
|
||||
<h3>{{ recipe.name }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="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="col-12" style="text-align: center">
|
||||
<i>{{ recipe.description }}</i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center">
|
||||
<keywords-component :recipe="recipe"></keywords-component>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
<div class="row align-items-center">
|
||||
<div class="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>
|
||||
</div>
|
||||
<div class="my-auto mr-1">
|
||||
<span class="text-primary"><b>{{ $t("Preparation") }}</b></span><br/>
|
||||
{{ working_time }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="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>
|
||||
</div>
|
||||
<div class="my-auto mr-1">
|
||||
<span class="text-primary"><b>{{ $t("Waiting") }}</b></span><br/>
|
||||
{{ waiting_time }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="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>
|
||||
</div>
|
||||
<div class="my-auto mr-1">
|
||||
<CustomInputSpinButton v-model.number="servings"/>
|
||||
</div>
|
||||
<div class="my-auto mr-1">
|
||||
<span class="text-primary">
|
||||
<b>
|
||||
<template v-if="recipe.servings_text === ''">{{ $t("Servings") }}</template>
|
||||
<template v-else>{{ recipe.servings_text }}</template>
|
||||
</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="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}"></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"
|
||||
v-if="recipe && ingredient_count > 0 && (recipe.show_ingredient_overview || recipe.steps.length < 2)">
|
||||
<ingredients-card
|
||||
:recipe="recipe.id"
|
||||
:steps="recipe.steps"
|
||||
:ingredient_factor="ingredient_factor"
|
||||
:servings="servings"
|
||||
:header="true"
|
||||
id="ingredient_container"
|
||||
@checked-state-changed="updateIngredientCheckedState"
|
||||
@change-servings="servings = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="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')"
|
||||
v-if="recipe.image !== null" @load="onImgLoad"
|
||||
:style="{ 'max-height': ingredient_height }"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="!recipe.internal">
|
||||
<div v-if="recipe.file_path.includes('.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')">
|
||||
<ImageViewer :recipe="recipe"></ImageViewer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-for="(s, index) in recipe.steps" v-bind:key="s.id" style="margin-top: 1vh">
|
||||
<step-component
|
||||
:recipe="recipe"
|
||||
:step="s"
|
||||
:ingredient_factor="ingredient_factor"
|
||||
:index="index"
|
||||
:start_time="start_time"
|
||||
@update-start-time="updateStartTime"
|
||||
@checked-state-changed="updateIngredientCheckedState"
|
||||
></step-component>
|
||||
</div>
|
||||
|
||||
<div 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="col-lg-6 offset-lg-3 col-12">
|
||||
<Nutrition-component :recipe="recipe" id="nutrition_container"
|
||||
:ingredient_factor="ingredient_factor"></Nutrition-component>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<add-recipe-to-book :recipe="recipe"></add-recipe-to-book>
|
||||
|
||||
<div class="row text-center d-print-none" style="margin-top: 3vh; margin-bottom: 3vh"
|
||||
v-if="share_uid !== 'None' && !loading">
|
||||
<div class="col col-md-12">
|
||||
<import-tandoor></import-tandoor> <br/>
|
||||
<a :href="resolveDjangoUrl('view_report_share_abuse', share_uid)" class="mt-3">{{ $t("Report Abuse") }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="app" v-if="recipe_id !== undefined">
|
||||
<recipe-view-component :recipe_id="recipe_id"></recipe-view-component>
|
||||
|
||||
<bottom-navigation-bar></bottom-navigation-bar>
|
||||
</div>
|
||||
@@ -163,192 +11,31 @@ import Vue from "vue"
|
||||
import {BootstrapVue} from "bootstrap-vue"
|
||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||
|
||||
import {apiLoadRecipe} from "@/utils/api"
|
||||
|
||||
import RecipeContextMenu from "@/components/RecipeContextMenu"
|
||||
import {ResolveUrlMixin, ToastMixin, calculateHourMinuteSplit} from "@/utils/utils"
|
||||
|
||||
import PdfViewer from "@/components/PdfViewer"
|
||||
import ImageViewer from "@/components/ImageViewer"
|
||||
|
||||
import moment from "moment"
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import AddRecipeToBook from "@/components/Modals/AddRecipeToBook"
|
||||
import RecipeRating from "@/components/RecipeRating"
|
||||
import LastCooked from "@/components/LastCooked"
|
||||
import IngredientsCard from "@/components/IngredientsCard"
|
||||
import StepComponent from "@/components/StepComponent"
|
||||
import KeywordsComponent from "@/components/KeywordsComponent"
|
||||
import NutritionComponent from "@/components/NutritionComponent"
|
||||
import RecipeSwitcher from "@/components/Buttons/RecipeSwitcher"
|
||||
import CustomInputSpinButton from "@/components/CustomInputSpinButton"
|
||||
import {ApiApiFactory} from "@/utils/openapi/api";
|
||||
import ImportTandoor from "@/components/Modals/ImportTandoor.vue";
|
||||
import RecipeViewComponent from "@/components/RecipeViewComponent.vue";
|
||||
import BottomNavigationBar from "@/components/BottomNavigationBar.vue";
|
||||
|
||||
Vue.prototype.moment = moment
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
export default {
|
||||
name: "RecipeView",
|
||||
mixins: [ResolveUrlMixin, ToastMixin],
|
||||
mixins: [],
|
||||
components: {
|
||||
ImportTandoor,
|
||||
LastCooked,
|
||||
RecipeRating,
|
||||
PdfViewer,
|
||||
ImageViewer,
|
||||
IngredientsCard,
|
||||
StepComponent,
|
||||
RecipeContextMenu,
|
||||
NutritionComponent,
|
||||
KeywordsComponent,
|
||||
LoadingSpinner,
|
||||
AddRecipeToBook,
|
||||
RecipeSwitcher,
|
||||
CustomInputSpinButton,
|
||||
BottomNavigationBar,
|
||||
},
|
||||
computed: {
|
||||
ingredient_factor: function () {
|
||||
return this.servings / this.recipe.servings
|
||||
},
|
||||
ingredient_count() {
|
||||
return this.recipe?.steps.map((x) => x.ingredients).flat().length
|
||||
},
|
||||
working_time: function () {
|
||||
return calculateHourMinuteSplit(this.recipe.working_time)
|
||||
},
|
||||
waiting_time: function () {
|
||||
return calculateHourMinuteSplit(this.recipe.waiting_time)
|
||||
},
|
||||
RecipeViewComponent,
|
||||
BottomNavigationBar
|
||||
},
|
||||
computed: {},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
recipe: undefined,
|
||||
rootrecipe: undefined,
|
||||
servings: 1,
|
||||
servings_cache: {},
|
||||
start_time: "",
|
||||
share_uid: window.SHARE_UID,
|
||||
wake_lock: null,
|
||||
ingredient_height: '250',
|
||||
recipe_id: window.RECIPE_ID
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
servings(newVal, oldVal) {
|
||||
this.servings_cache[this.recipe.id] = this.servings
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.loadRecipe(window.RECIPE_ID)
|
||||
this.$i18n.locale = window.CUSTOM_LOCALE
|
||||
this.requestWakeLock()
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.destroyWakeLock()
|
||||
},
|
||||
methods: {
|
||||
requestWakeLock: async function () {
|
||||
if ('wakeLock' in navigator) {
|
||||
try {
|
||||
this.wake_lock = await navigator.wakeLock.request('screen')
|
||||
document.addEventListener('visibilitychange', this.visibilityChange)
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
},
|
||||
handleResize: function () {
|
||||
if (document.getElementById('nutrition_container') !== null) {
|
||||
this.ingredient_height = document.getElementById('ingredient_container').clientHeight - document.getElementById('nutrition_container').clientHeight
|
||||
} else {
|
||||
this.ingredient_height = document.getElementById('ingredient_container').clientHeight
|
||||
}
|
||||
},
|
||||
destroyWakeLock: function () {
|
||||
if (this.wake_lock != null) {
|
||||
this.wake_lock.release()
|
||||
.then(() => {
|
||||
this.wake_lock = null
|
||||
});
|
||||
}
|
||||
|
||||
document.removeEventListener('visibilitychange', this.visibilityChange)
|
||||
},
|
||||
visibilityChange: async function () {
|
||||
if (this.wake_lock != null && document.visibilityState === 'visible') {
|
||||
await this.requestWakeLock()
|
||||
}
|
||||
},
|
||||
loadRecipe: function (recipe_id) {
|
||||
apiLoadRecipe(recipe_id).then((recipe) => {
|
||||
let total_time = 0
|
||||
for (let step of recipe.steps) {
|
||||
for (let ingredient of step.ingredients) {
|
||||
this.$set(ingredient, "checked", false)
|
||||
}
|
||||
|
||||
step.time_offset = total_time
|
||||
total_time += step.time
|
||||
}
|
||||
|
||||
// set start time only if there are any steps with timers (otherwise no timers are rendered)
|
||||
if (total_time > 0) {
|
||||
this.start_time = moment().format("yyyy-MM-DDTHH:mm")
|
||||
}
|
||||
|
||||
|
||||
if (recipe.image === null) this.printReady()
|
||||
|
||||
this.recipe = this.rootrecipe = recipe
|
||||
this.servings = this.servings_cache[this.rootrecipe.id] = recipe.servings
|
||||
this.loading = false
|
||||
|
||||
setTimeout(() => {
|
||||
this.handleResize()
|
||||
}, 100)
|
||||
})
|
||||
},
|
||||
updateStartTime: function (e) {
|
||||
this.start_time = e
|
||||
},
|
||||
updateIngredientCheckedState: function (e) {
|
||||
for (let step of this.recipe.steps) {
|
||||
for (let ingredient of step.ingredients) {
|
||||
if (ingredient.id === e.id) {
|
||||
this.$set(ingredient, "checked", !ingredient.checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
quickSwitch: function (e) {
|
||||
if (e === -1) {
|
||||
this.recipe = this.rootrecipe
|
||||
this.servings = this.servings_cache[this.rootrecipe?.id ?? 1]
|
||||
} else {
|
||||
this.recipe = e
|
||||
this.servings = this.servings_cache?.[e.id] ?? e.servings
|
||||
}
|
||||
},
|
||||
printReady: function () {
|
||||
const template = document.createElement("template");
|
||||
template.id = "printReady";
|
||||
document.body.appendChild(template);
|
||||
},
|
||||
onImgLoad: function () {
|
||||
this.printReady()
|
||||
},
|
||||
},
|
||||
methods: {},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#app > div > div {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -51,14 +51,14 @@
|
||||
<td>{{ us.user.username }}</td>
|
||||
<td>
|
||||
<generic-multiselect
|
||||
class="input-group-text m-0 p-0"
|
||||
@change="us.groups = $event.val; updateUserSpace(us)"
|
||||
label="name"
|
||||
:initial_selection="us.groups"
|
||||
:model="Models.GROUP"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
:limit="10"
|
||||
:multiple="true"
|
||||
class="input-group-text m-0 p-0"
|
||||
@change="us.groups = $event.val; updateUserSpace(us)"
|
||||
label="name"
|
||||
:initial_selection="us.groups"
|
||||
:model="Models.GROUP"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
:limit="10"
|
||||
:multiple="true"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
@@ -90,14 +90,14 @@
|
||||
<td>{{ il.email }}</td>
|
||||
<td>
|
||||
<generic-multiselect
|
||||
class="input-group-text m-0 p-0"
|
||||
@change="il.group = $event.val;"
|
||||
label="name"
|
||||
:initial_single_selection="il.group"
|
||||
:model="Models.GROUP"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
:limit="10"
|
||||
:multiple="false"
|
||||
class="input-group-text m-0 p-0"
|
||||
@change="il.group = $event.val;"
|
||||
label="name"
|
||||
:initial_single_selection="il.group"
|
||||
:model="Models.GROUP"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
:limit="10"
|
||||
:multiple="false"
|
||||
/>
|
||||
</td>
|
||||
<td><input type="date" v-model="il.valid_until" class="form-control"></td>
|
||||
@@ -164,6 +164,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h4>{{ $t('Open_Data_Import') }}</h4>
|
||||
<open-data-import-component></open-data-import-component>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col col-12">
|
||||
<h4 class="mt-2"><i class="fas fa-trash"></i> {{ $t('Delete') }}</h4>
|
||||
@@ -198,6 +207,7 @@ import GenericMultiselect from "@/components/GenericMultiselect";
|
||||
import GenericModalForm from "@/components/Modals/GenericModalForm";
|
||||
import axios from "axios";
|
||||
import VueClipboard from 'vue-clipboard2'
|
||||
import OpenDataImportComponent from "@/components/OpenDataImportComponent.vue";
|
||||
|
||||
Vue.use(VueClipboard)
|
||||
|
||||
@@ -206,7 +216,7 @@ Vue.use(BootstrapVue)
|
||||
export default {
|
||||
name: "SpaceManageView",
|
||||
mixins: [ResolveUrlMixin, ToastMixin, ApiMixin],
|
||||
components: {GenericMultiselect, GenericModalForm},
|
||||
components: {GenericMultiselect, GenericModalForm, OpenDataImportComponent},
|
||||
data() {
|
||||
return {
|
||||
ACTIVE_SPACE_ID: window.ACTIVE_SPACE_ID,
|
||||
@@ -239,7 +249,7 @@ export default {
|
||||
if (link) {
|
||||
content = localStorage.BASE_PATH + this.resolveDjangoUrl('view_invite', inviteLink.uuid)
|
||||
}
|
||||
this.$copyText(content)
|
||||
this.$copyText(content)
|
||||
},
|
||||
loadInviteLinks: function () {
|
||||
let apiFactory = new ApiApiFactory()
|
||||
|
||||
@@ -1,103 +1,41 @@
|
||||
<template>
|
||||
|
||||
<div id="app">
|
||||
<div class="row" v-if="food">
|
||||
<div class="col-12">
|
||||
<h2>{{ food.name }}</h2>
|
||||
|
||||
<beta-warning></beta-warning>
|
||||
|
||||
<div v-if="metadata !== undefined">
|
||||
{{ $t('Data_Import_Info') }}
|
||||
|
||||
|
||||
<select class="form-control" v-model="selected_version">
|
||||
<option v-for="v in metadata.versions" v-bind:key="v">{{ v }}</option>
|
||||
</select>
|
||||
|
||||
<b-checkbox v-model="update_existing" class="mt-1">{{ $t('Update_Existing_Data') }}</b-checkbox>
|
||||
<b-checkbox v-model="use_metric" class="mt-1">{{ $t('Use_Metric') }}</b-checkbox>
|
||||
|
||||
|
||||
<div v-if="selected_version !== undefined" class="mt-3">
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th>{{ $t('Datatype') }}</th>
|
||||
<th>{{ $t('Number of Objects') }}</th>
|
||||
<th>{{ $t('Imported') }}</th>
|
||||
</tr>
|
||||
<tr v-for="d in metadata.datatypes" v-bind:key="d">
|
||||
<td>{{ $t(d.charAt(0).toUpperCase() + d.slice(1)) }}</td>
|
||||
<td>{{ metadata[selected_version][d] }}</td>
|
||||
<td>
|
||||
<template v-if="import_count !== undefined">{{ import_count[d] }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<button class="btn btn-success" @click="doImport">{{ $t('Import') }}</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<b-form v-if="food">
|
||||
<b-form-group :label="$t('Name')" description="">
|
||||
<b-form-input v-model="food.name"></b-form-input>
|
||||
</b-form-group>
|
||||
<b-form-group :label="$t('Plural')" description="">
|
||||
<b-form-input v-model="food.plural_name"></b-form-input>
|
||||
</b-form-group>
|
||||
|
||||
|
||||
<b-form-group :label="$t('Recipe')" :description="$t('food_recipe_help')">
|
||||
<generic-multiselect
|
||||
@change="food.recipe = $event.val;"
|
||||
:model="Models.RECIPE"
|
||||
:initial_selection="food.recipe"
|
||||
label="name"
|
||||
:multiple="false"
|
||||
:placeholder="$t('Recipe')"
|
||||
></generic-multiselect>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :description="$t('OnHand_help')">
|
||||
<b-form-checkbox v-model="food.food_onhand">{{ $t('OnHand') }}</b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :description="$t('ignore_shopping_help')">
|
||||
<b-form-checkbox v-model="food.ignore_shopping">{{ $t('Ignore_Shopping') }}</b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :label="$t('Shopping_Category')" :description="$t('shopping_category_help')">
|
||||
<generic-multiselect
|
||||
@change="food.supermarket_category = $event.val;"
|
||||
:model="Models.SHOPPING_CATEGORY"
|
||||
:initial_selection="food.supermarket_category"
|
||||
label="name"
|
||||
:multiple="false"
|
||||
:placeholder="$t('Shopping_Category')"
|
||||
></generic-multiselect>
|
||||
</b-form-group>
|
||||
|
||||
<hr/>
|
||||
<!-- todo add conditions if false disable dont hide -->
|
||||
<b-form-group :label="$t('Substitutes')" :description="$t('substitute_help')">
|
||||
<generic-multiselect
|
||||
@change="food.substitute = $event.val;"
|
||||
:model="Models.FOOD"
|
||||
:initial_selection="food.substitute"
|
||||
label="name"
|
||||
:multiple="false"
|
||||
:placeholder="$t('Substitutes')"
|
||||
></generic-multiselect>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :description="$t('substitute_siblings_help')">
|
||||
<b-form-checkbox v-model="food.substitute_siblings">{{ $t('substitute_siblings') }}</b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :label="$t('InheritFields')" :description="$t('InheritFields_help')">
|
||||
<generic-multiselect
|
||||
@change="food.inherit_fields = $event.val;"
|
||||
:model="Models.FOOD_INHERIT_FIELDS"
|
||||
:initial_selection="food.inherit_fields"
|
||||
label="name"
|
||||
:multiple="false"
|
||||
:placeholder="$t('InheritFields')"
|
||||
></generic-multiselect>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :label="$t('ChildInheritFields')" :description="$t('ChildInheritFields_help')">
|
||||
<generic-multiselect
|
||||
@change="food.child_inherit_fields = $event.val;"
|
||||
:model="Models.FOOD_INHERIT_FIELDS"
|
||||
:initial_selection="food.child_inherit_fields"
|
||||
label="name"
|
||||
:multiple="false"
|
||||
:placeholder="$t('ChildInheritFields')"
|
||||
></generic-multiselect>
|
||||
</b-form-group>
|
||||
|
||||
<!-- TODO change to a button -->
|
||||
<b-form-group :description="$t('reset_children_help')">
|
||||
<b-form-checkbox v-model="food.reset_inherit">{{ $t('reset_children') }}</b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
<b-button variant="primary" @click="updateFood">{{ $t('Save') }}</b-button>
|
||||
</b-form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -107,10 +45,9 @@ import Vue from "vue"
|
||||
import {BootstrapVue} from "bootstrap-vue"
|
||||
|
||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||
import {ApiApiFactory} from "@/utils/openapi/api";
|
||||
import RecipeCard from "@/components/RecipeCard.vue";
|
||||
import GenericMultiselect from "@/components/GenericMultiselect.vue";
|
||||
import {ApiMixin, StandardToasts} from "@/utils/utils";
|
||||
import {ApiMixin, resolveDjangoUrl, StandardToasts} from "@/utils/utils";
|
||||
import axios from "axios";
|
||||
import BetaWarning from "@/components/BetaWarning.vue";
|
||||
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
@@ -119,33 +56,39 @@ Vue.use(BootstrapVue)
|
||||
export default {
|
||||
name: "TestView",
|
||||
mixins: [ApiMixin],
|
||||
components: {
|
||||
GenericMultiselect
|
||||
},
|
||||
components: {BetaWarning},
|
||||
data() {
|
||||
return {
|
||||
food: undefined,
|
||||
|
||||
metadata: undefined,
|
||||
selected_version: undefined,
|
||||
update_existing: true,
|
||||
use_metric: true,
|
||||
import_count: undefined,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$i18n.locale = window.CUSTOM_LOCALE
|
||||
let apiClient = new ApiApiFactory()
|
||||
apiClient.retrieveFood('1').then((r) => {
|
||||
this.food = r.data
|
||||
})
|
||||
|
||||
axios.get(resolveDjangoUrl('api_import_open_data')).then(r => {
|
||||
this.metadata = r.data
|
||||
}).catch(err => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
updateFood: function () {
|
||||
let apiClient = new ApiApiFactory()
|
||||
apiClient.updateFood(this.food.id, this.food).then((r) => {
|
||||
this.food = r.data
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_UPDATE)
|
||||
doImport: function () {
|
||||
axios.post(resolveDjangoUrl('api_import_open_data'), {
|
||||
'selected_version': this.selected_version,
|
||||
'selected_datatypes': this.metadata.datatypes,
|
||||
'update_existing': this.update_existing,
|
||||
'use_metric': this.use_metric,
|
||||
}).then(r => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_CREATE)
|
||||
this.import_count = r.data
|
||||
}).catch(err => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_CREATE, err)
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
155
vue/src/apps/TestView/TestViewBackup.vue
Normal file
155
vue/src/apps/TestView/TestViewBackup.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
|
||||
<div id="app">
|
||||
<div class="row" v-if="food">
|
||||
<div class="col-12">
|
||||
<h2>{{ food.name }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<b-form v-if="food">
|
||||
<b-form-group :label="$t('Name')" description="">
|
||||
<b-form-input v-model="food.name"></b-form-input>
|
||||
</b-form-group>
|
||||
<b-form-group :label="$t('Plural')" description="">
|
||||
<b-form-input v-model="food.plural_name"></b-form-input>
|
||||
</b-form-group>
|
||||
|
||||
|
||||
<b-form-group :label="$t('Recipe')" :description="$t('food_recipe_help')">
|
||||
<generic-multiselect
|
||||
@change="food.recipe = $event.val;"
|
||||
:model="Models.RECIPE"
|
||||
:initial_selection="food.recipe"
|
||||
label="name"
|
||||
:multiple="false"
|
||||
:placeholder="$t('Recipe')"
|
||||
></generic-multiselect>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :description="$t('OnHand_help')">
|
||||
<b-form-checkbox v-model="food.food_onhand">{{ $t('OnHand') }}</b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :description="$t('ignore_shopping_help')">
|
||||
<b-form-checkbox v-model="food.ignore_shopping">{{ $t('Ignore_Shopping') }}</b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :label="$t('Shopping_Category')" :description="$t('shopping_category_help')">
|
||||
<generic-multiselect
|
||||
@change="food.supermarket_category = $event.val;"
|
||||
:model="Models.SHOPPING_CATEGORY"
|
||||
:initial_selection="food.supermarket_category"
|
||||
label="name"
|
||||
:multiple="false"
|
||||
:placeholder="$t('Shopping_Category')"
|
||||
></generic-multiselect>
|
||||
</b-form-group>
|
||||
|
||||
<hr/>
|
||||
<!-- todo add conditions if false disable dont hide -->
|
||||
<b-form-group :label="$t('Substitutes')" :description="$t('substitute_help')">
|
||||
<generic-multiselect
|
||||
@change="food.substitute = $event.val;"
|
||||
:model="Models.FOOD"
|
||||
:initial_selection="food.substitute"
|
||||
label="name"
|
||||
:multiple="false"
|
||||
:placeholder="$t('Substitutes')"
|
||||
></generic-multiselect>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :description="$t('substitute_siblings_help')">
|
||||
<b-form-checkbox v-model="food.substitute_siblings">{{ $t('substitute_siblings') }}</b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :label="$t('InheritFields')" :description="$t('InheritFields_help')">
|
||||
<generic-multiselect
|
||||
@change="food.inherit_fields = $event.val;"
|
||||
:model="Models.FOOD_INHERIT_FIELDS"
|
||||
:initial_selection="food.inherit_fields"
|
||||
label="name"
|
||||
:multiple="false"
|
||||
:placeholder="$t('InheritFields')"
|
||||
></generic-multiselect>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :label="$t('ChildInheritFields')" :description="$t('ChildInheritFields_help')">
|
||||
<generic-multiselect
|
||||
@change="food.child_inherit_fields = $event.val;"
|
||||
:model="Models.FOOD_INHERIT_FIELDS"
|
||||
:initial_selection="food.child_inherit_fields"
|
||||
label="name"
|
||||
:multiple="false"
|
||||
:placeholder="$t('ChildInheritFields')"
|
||||
></generic-multiselect>
|
||||
</b-form-group>
|
||||
|
||||
<!-- TODO change to a button -->
|
||||
<b-form-group :description="$t('reset_children_help')">
|
||||
<b-form-checkbox v-model="food.reset_inherit">{{ $t('reset_children') }}</b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
<b-button variant="primary" @click="updateFood">{{ $t('Save') }}</b-button>
|
||||
</b-form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
import Vue from "vue"
|
||||
import {BootstrapVue} from "bootstrap-vue"
|
||||
|
||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||
import {ApiApiFactory} from "@/utils/openapi/api";
|
||||
import RecipeCard from "@/components/RecipeCard.vue";
|
||||
import GenericMultiselect from "@/components/GenericMultiselect.vue";
|
||||
import {ApiMixin, StandardToasts} from "@/utils/utils";
|
||||
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
|
||||
export default {
|
||||
name: "TestView",
|
||||
mixins: [ApiMixin],
|
||||
components: {
|
||||
GenericMultiselect
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
food: undefined,
|
||||
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$i18n.locale = window.CUSTOM_LOCALE
|
||||
let apiClient = new ApiApiFactory()
|
||||
apiClient.retrieveFood('1').then((r) => {
|
||||
this.food = r.data
|
||||
})
|
||||
|
||||
},
|
||||
methods: {
|
||||
updateFood: function () {
|
||||
let apiClient = new ApiApiFactory()
|
||||
apiClient.updateFood(this.food.id, this.food).then((r) => {
|
||||
this.food = r.data
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_UPDATE)
|
||||
}).catch(err => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<!-- bottom button nav -->
|
||||
<div class="fixed-bottom p-1 pt-2 pl-2 pr-2 border-top text-center d-lg-none" style="background: white">
|
||||
<div class="fixed-bottom p-1 pt-2 pl-2 pr-2 border-top text-center d-lg-none d-print-none" style="background: white">
|
||||
<div class="d-flex flex-row justify-content-around">
|
||||
<div class="flex-column" v-if="show_button_1">
|
||||
<slot name="button_1">
|
||||
|
||||
412
vue/src/components/FoodEditor.vue
Normal file
412
vue/src/components/FoodEditor.vue
Normal file
@@ -0,0 +1,412 @@
|
||||
<template>
|
||||
|
||||
<div>
|
||||
|
||||
<b-modal :id="id" size="xl" @hidden="cancelAction" :body-class="`pr-3 pl-3`">
|
||||
|
||||
<template v-slot:modal-title>
|
||||
<div class="row" v-if="food">
|
||||
<div class="col-12">
|
||||
<h2>{{ food.name }} <small class="text-muted" v-if="food.plural_name">{{
|
||||
food.plural_name
|
||||
}}</small>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<b-tabs content-class="mt-3" v-if="food">
|
||||
<b-tab title="General" active>
|
||||
<b-form>
|
||||
<b-form-group :label="$t('Name')" description="">
|
||||
<b-form-input v-model="food.name"></b-form-input>
|
||||
</b-form-group>
|
||||
<b-form-group :label="$t('Plural')" description="">
|
||||
<b-form-input v-model="food.plural_name"></b-form-input>
|
||||
</b-form-group>
|
||||
|
||||
<!-- Food properties -->
|
||||
|
||||
<h5><i class="fas fa-database"></i> {{ $t('Properties') }}</h5>
|
||||
|
||||
<b-form-group :label="$t('Properties Food Amount')" description=""> <!-- TODO localize -->
|
||||
<b-form-input v-model="food.properties_food_amount"></b-form-input>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :label="$t('Properties Food Unit')" description=""> <!-- TODO localize -->
|
||||
<generic-multiselect
|
||||
@change="food.properties_food_unit = $event.val;"
|
||||
:model="Models.UNIT"
|
||||
:initial_single_selection="food.properties_food_unit"
|
||||
label="name"
|
||||
:multiple="false"
|
||||
:placeholder="$t('Unit')"
|
||||
></generic-multiselect>
|
||||
</b-form-group>
|
||||
|
||||
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th> {{ $t('Property Amount') }}</th> <!-- TODO localize -->
|
||||
<th> {{ $t('Property Type') }}</th> <!-- TODO localize -->
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr v-for="fp in food.properties" v-bind:key="fp.id">
|
||||
<td><input v-model="fp.property_amount" type="number"> <span
|
||||
v-if="fp.property_type">{{ fp.property_type.unit }}</span></td>
|
||||
<td>
|
||||
<generic-multiselect
|
||||
@change="fp.property_type = $event.val"
|
||||
:initial_single_selection="fp.property_type"
|
||||
label="name" :model="Models.PROPERTY_TYPE"
|
||||
:multiple="false"/>
|
||||
</td>
|
||||
<td> / <span>{{ food.properties_food_amount }} <span
|
||||
v-if="food.properties_food_unit !== null">{{
|
||||
food.properties_food_unit.name
|
||||
}}</span></span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-danger btn-small" @click="deleteProperty(fp)"><i
|
||||
class="fas fa-trash-alt"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="text-center">
|
||||
<b-button-group>
|
||||
<b-btn class="btn btn-success shadow-none" @click="addProperty()"><i
|
||||
class="fa fa-plus"></i>
|
||||
</b-btn>
|
||||
<b-btn class="btn btn-secondary shadow-none" @click="addAllProperties()"><i
|
||||
class="fa fa-plus"> <i class="ml-1 fas fa-list"></i></i>
|
||||
</b-btn>
|
||||
</b-button-group>
|
||||
|
||||
</div>
|
||||
|
||||
<b-form-group :label="$t('Shopping_Category')" :description="$t('shopping_category_help')">
|
||||
<generic-multiselect
|
||||
@change="food.supermarket_category = $event.val;"
|
||||
:model="Models.SHOPPING_CATEGORY"
|
||||
:initial_single_selection="food.supermarket_category"
|
||||
label="name"
|
||||
:multiple="false"
|
||||
:allow_create="true"
|
||||
:placeholder="$t('Shopping_Category')"
|
||||
></generic-multiselect>
|
||||
</b-form-group>
|
||||
|
||||
|
||||
</b-form>
|
||||
</b-tab>
|
||||
<b-tab title="Conversions" @click="loadUnitConversions" v-if="this.food.id !== undefined">
|
||||
|
||||
<b-row v-for="uc in unit_conversions" :key="uc">
|
||||
<b-col>
|
||||
<span v-if="uc.id">
|
||||
<b-btn class="btn btn-sm" variant="danger" @click="deleteUnitConversion(uc)"><i class="fas fa-trash-alt"></i></b-btn>
|
||||
{{ uc.base_amount }}
|
||||
{{ uc.base_unit.name }}
|
||||
=
|
||||
{{ uc.converted_amount }}
|
||||
{{ uc.converted_unit.name }}
|
||||
</span>
|
||||
<b-form class="mt-1">
|
||||
<b-input-group>
|
||||
<b-input v-model="uc.base_amount" @change="uc.changed = true"></b-input>
|
||||
<b-input-group-append>
|
||||
<generic-multiselect
|
||||
@change="uc.base_unit = $event.val; uc.changed = true"
|
||||
:initial_single_selection="uc.base_unit"
|
||||
label="name" :model="Models.UNIT"
|
||||
:multiple="false"/>
|
||||
</b-input-group-append>
|
||||
|
||||
</b-input-group>
|
||||
|
||||
<b-input-group>
|
||||
<b-input v-model="uc.converted_amount" @change="uc.changed = true"></b-input>
|
||||
<b-input-group-append>
|
||||
<generic-multiselect
|
||||
@change="uc.converted_unit = $event.val; uc.changed = true"
|
||||
:initial_single_selection="uc.converted_unit"
|
||||
label="name" :model="Models.UNIT"
|
||||
:multiple="false"/>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
|
||||
</b-form>
|
||||
</b-col>
|
||||
<hr style="height: 1px"/>
|
||||
</b-row>
|
||||
|
||||
<b-row>
|
||||
<b-col class="text-center">
|
||||
<b-btn variant="success" @click="addUnitConversion"><i class="fa fa-plus"></i></b-btn>
|
||||
</b-col>
|
||||
</b-row>
|
||||
|
||||
</b-tab>
|
||||
<b-tab title="More">
|
||||
<b-form>
|
||||
|
||||
|
||||
<b-form-group :label="$t('Recipe')" :description="$t('food_recipe_help')">
|
||||
<generic-multiselect
|
||||
@change="food.recipe = $event.val;"
|
||||
:model="Models.RECIPE"
|
||||
:initial_single_selection="food.recipe"
|
||||
label="name"
|
||||
:multiple="false"
|
||||
:placeholder="$t('Recipe')"
|
||||
></generic-multiselect>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :description="$t('OnHand_help')">
|
||||
<b-form-checkbox v-model="food.food_onhand">{{ $t('OnHand') }}</b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :description="$t('ignore_shopping_help')">
|
||||
<b-form-checkbox v-model="food.ignore_shopping">{{
|
||||
$t('Ignore_Shopping')
|
||||
}}
|
||||
</b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
<hr/>
|
||||
<!-- todo add conditions if false disable dont hide -->
|
||||
<b-form-group :label="$t('Substitutes')" :description="$t('substitute_help')">
|
||||
<generic-multiselect
|
||||
@change="food.substitute = $event.val;"
|
||||
:model="Models.FOOD"
|
||||
:initial_selection="food.substitute"
|
||||
label="name"
|
||||
:multiple="true"
|
||||
:placeholder="$t('Substitutes')"
|
||||
></generic-multiselect>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :description="$t('substitute_siblings_help')">
|
||||
<b-form-checkbox v-model="food.substitute_siblings">{{
|
||||
$t('substitute_siblings')
|
||||
}}
|
||||
</b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :label="$t('InheritFields')" :description="$t('InheritFields_help')">
|
||||
<generic-multiselect
|
||||
@change="food.inherit_fields = $event.val;"
|
||||
:model="Models.FOOD_INHERIT_FIELDS"
|
||||
:initial_selection="food.inherit_fields"
|
||||
label="name"
|
||||
:multiple="true"
|
||||
:placeholder="$t('InheritFields')"
|
||||
></generic-multiselect>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :label="$t('ChildInheritFields')"
|
||||
:description="$t('ChildInheritFields_help')">
|
||||
<generic-multiselect
|
||||
@change="food.child_inherit_fields = $event.val;"
|
||||
:model="Models.FOOD_INHERIT_FIELDS"
|
||||
:initial_sselection="food.child_inherit_fields"
|
||||
label="name"
|
||||
:multiple="true"
|
||||
:placeholder="$t('ChildInheritFields')"
|
||||
></generic-multiselect>
|
||||
</b-form-group>
|
||||
|
||||
<!-- TODO change to a button -->
|
||||
<b-form-group :description="$t('reset_children_help')">
|
||||
<b-form-checkbox v-model="food.reset_inherit">{{
|
||||
$t('reset_children')
|
||||
}}
|
||||
</b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
|
||||
</b-form>
|
||||
</b-tab>
|
||||
</b-tabs>
|
||||
</div>
|
||||
|
||||
<template v-slot:modal-footer>
|
||||
<b-button variant="primary" @click="updateFood">{{ $t('Save') }}</b-button>
|
||||
</template>
|
||||
</b-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
import Vue from "vue"
|
||||
import {BootstrapVue} from "bootstrap-vue"
|
||||
|
||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||
import {ApiApiFactory} from "@/utils/openapi/api";
|
||||
import GenericMultiselect from "@/components/GenericMultiselect.vue";
|
||||
import {ApiMixin, formFunctions, getForm, StandardToasts} from "@/utils/utils";
|
||||
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
|
||||
export default {
|
||||
name: "FoodEditor",
|
||||
mixins: [ApiMixin],
|
||||
components: {
|
||||
GenericMultiselect
|
||||
},
|
||||
props: {
|
||||
id: {type: String, default: 'id_food_edit_modal_modal'},
|
||||
show: {required: true, type: Boolean, default: false},
|
||||
item1: {
|
||||
type: Object,
|
||||
default: undefined
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
show: function () {
|
||||
if (this.show) {
|
||||
this.$bvModal.show(this.id)
|
||||
} else {
|
||||
this.$bvModal.hide(this.id)
|
||||
}
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
food: undefined,
|
||||
unit_conversions: []
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$bvModal.show(this.id)
|
||||
this.$i18n.locale = window.CUSTOM_LOCALE
|
||||
let apiClient = new ApiApiFactory()
|
||||
let pf
|
||||
if (this.item1.id !== undefined) {
|
||||
pf = apiClient.retrieveFood(this.item1.id).then((r) => {
|
||||
this.food = r.data
|
||||
this.food.properties_food_unit = {name: 'g'}
|
||||
}).catch(err => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
|
||||
})
|
||||
} else {
|
||||
this.food = {
|
||||
name: "",
|
||||
plural_name: "",
|
||||
description: "",
|
||||
shopping: false,
|
||||
recipe: null,
|
||||
properties: [],
|
||||
properties_food_amount: 100,
|
||||
properties_food_unit: {name: 'g'},
|
||||
food_onhand: false,
|
||||
supermarket_category: null,
|
||||
parent: null,
|
||||
numchild: 0,
|
||||
inherit_fields: [],
|
||||
ignore_shopping: false,
|
||||
substitute: [],
|
||||
substitute_siblings: false,
|
||||
substitute_children: false,
|
||||
substitute_onhand: false,
|
||||
child_inherit_fields: [],
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateFood: function () {
|
||||
let apiClient = new ApiApiFactory()
|
||||
if (this.food.id !== undefined) {
|
||||
apiClient.updateFood(this.food.id, this.food).then((r) => {
|
||||
this.food = r.data
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_UPDATE)
|
||||
}).catch(err => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
|
||||
})
|
||||
} else {
|
||||
apiClient.createFood(this.food).then((r) => {
|
||||
this.food = r.data
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_CREATE)
|
||||
}).catch(err => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
|
||||
})
|
||||
}
|
||||
|
||||
this.unit_conversions.forEach(uc => {
|
||||
if (uc.changed === true) {
|
||||
if (uc.id === undefined) {
|
||||
apiClient.createUnitConversion(uc).then(r => {
|
||||
uc = r.data
|
||||
}).catch(err => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_CREATE, err, true)
|
||||
})
|
||||
} else {
|
||||
apiClient.updateUnitConversion(uc.id, uc).then(r => {
|
||||
uc = r.data
|
||||
}).catch(err => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err, true)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
addProperty: function () {
|
||||
this.food.properties.push({property_type: null, property_amount: 0})
|
||||
},
|
||||
addAllProperties: function () {
|
||||
let apiClient = new ApiApiFactory()
|
||||
apiClient.listPropertyTypes().then(r => {
|
||||
r.data.forEach(x => {
|
||||
this.food.properties.push({property_type: x, property_amount: 0})
|
||||
})
|
||||
}).catch(err => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
|
||||
})
|
||||
},
|
||||
deleteProperty: function (p) {
|
||||
this.food.properties = this.food.properties.filter(x => x !== p)
|
||||
},
|
||||
cancelAction: function () {
|
||||
this.$emit("hidden", "")
|
||||
},
|
||||
loadUnitConversions: function () {
|
||||
let apiClient = new ApiApiFactory()
|
||||
apiClient.listUnitConversions(this.food.id).then(r => {
|
||||
this.unit_conversions = r.data
|
||||
})
|
||||
},
|
||||
addUnitConversion: function () {
|
||||
this.unit_conversions.push(
|
||||
{
|
||||
food: this.food,
|
||||
base_amount: 1,
|
||||
base_unit: null,
|
||||
converted_amount: 0,
|
||||
converted_unit: null,
|
||||
}
|
||||
)
|
||||
},
|
||||
deleteUnitConversion: function (uc) {
|
||||
this.unit_conversions = this.unit_conversions.filter(u => u !== uc)
|
||||
let apiClient = new ApiApiFactory()
|
||||
apiClient.destroyUnitConversion(uc.id).then(r => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_DELETE)
|
||||
}).catch(err => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_DELETE, err)
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
@@ -1,8 +1,14 @@
|
||||
<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">
|
||||
<a :href="`${resolveDjangoUrl('view_search')}?keyword=${k.id}`"><b-badge pill variant="light"
|
||||
class="font-weight-normal">{{ k.label }}</b-badge></a>
|
||||
<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>
|
||||
</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
<b-badge pill variant="light" class="font-weight-normal">{{ k.label }}</b-badge>
|
||||
</template>
|
||||
|
||||
</span>
|
||||
</div>
|
||||
@@ -18,10 +24,11 @@ export default {
|
||||
props: {
|
||||
recipe: Object,
|
||||
limit: Number,
|
||||
enable_keyword_links: {type: Boolean, default: true}
|
||||
},
|
||||
computed: {
|
||||
keyword_splice: function (){
|
||||
if(this.limit){
|
||||
keyword_splice: function () {
|
||||
if (this.limit) {
|
||||
return this.limit
|
||||
}
|
||||
return this.recipe.keywords.lenght
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<span class="pl-1" v-if="recipe.last_cooked !== null">
|
||||
<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)
|
||||
}}</b-badge>
|
||||
|
||||
@@ -1,28 +1,25 @@
|
||||
<template>
|
||||
<div>
|
||||
<template v-if="form_component !== undefined">
|
||||
<b-modal :id="'modal_' + id" @hidden="cancelAction" size="xl">
|
||||
<component :is="form_component"></component>
|
||||
</b-modal>
|
||||
|
||||
<component :is="form_component" :id="'modal_' + id" :show="show" @hidden="cancelAction" :item1="item1"></component>
|
||||
</template>
|
||||
<template v-else>
|
||||
<b-modal :id="'modal_' + id" @hidden="cancelAction" size="lg">
|
||||
<template v-slot:modal-title>
|
||||
<h4 class="d-inline">{{ form.title }}</h4>
|
||||
<help-badge v-if="form.show_help" @show="show_help = true" @hide="show_help = false" :component="`GenericModal${form.title}`" />
|
||||
<help-badge v-if="form.show_help" @show="show_help = true" @hide="show_help = false" :component="`GenericModal${form.title}`"/>
|
||||
</template>
|
||||
<div v-for="(f, i) in form.fields" v-bind:key="i">
|
||||
<p v-if="visibleCondition(f, 'instruction')">{{ f.label }}</p>
|
||||
<lookup-input v-if="visibleCondition(f, 'lookup')" :form="f" :model="listModel(f.list)" @change="storeValue" :help="showHelp && f.help" />
|
||||
<checkbox-input class="mb-3" v-if="visibleCondition(f, 'checkbox')" :label="f.label" :value="f.value" :field="f.field" :help="showHelp && f.help" />
|
||||
<lookup-input v-if="visibleCondition(f, 'lookup')" :form="f" :model="listModel(f.list)" @change="storeValue" :help="showHelp && f.help"/>
|
||||
<checkbox-input class="mb-3" v-if="visibleCondition(f, 'checkbox')" :label="f.label" :value="f.value" :field="f.field" :help="showHelp && f.help"/>
|
||||
<text-input v-if="visibleCondition(f, 'text')" :label="f.label" :value="f.value" :field="f.field" :placeholder="f.placeholder" :help="showHelp && f.help" :subtitle="f.subtitle" :disabled="f.disabled"/>
|
||||
<choice-input v-if="visibleCondition(f, 'choice')" :label="f.label" :value="f.value" :field="f.field" :options="f.options" :placeholder="f.placeholder" />
|
||||
<emoji-input v-if="visibleCondition(f, 'emoji')" :label="f.label" :value="f.value" :field="f.field" @change="storeValue" />
|
||||
<file-input v-if="visibleCondition(f, 'file')" :label="f.label" :value="f.value" :field="f.field" @change="storeValue" />
|
||||
<small-text v-if="visibleCondition(f, 'smalltext')" :value="f.value" />
|
||||
<date-input v-if="visibleCondition(f, 'date')" :label="f.label" :value="f.value" :field="f.field" :help="showHelp && f.help" :subtitle="f.subtitle" />
|
||||
<number-input v-if="visibleCondition(f, 'number')" :label="f.label" :value="f.value" :field="f.field" :placeholder="f.placeholder" :help="showHelp && f.help" :subtitle="f.subtitle" />
|
||||
<choice-input v-if="visibleCondition(f, 'choice')" :label="f.label" :value="f.value" :field="f.field" :options="f.options" :placeholder="f.placeholder"/>
|
||||
<emoji-input v-if="visibleCondition(f, 'emoji')" :label="f.label" :value="f.value" :field="f.field" @change="storeValue"/>
|
||||
<file-input v-if="visibleCondition(f, 'file')" :label="f.label" :value="f.value" :field="f.field" @change="storeValue"/>
|
||||
<small-text v-if="visibleCondition(f, 'smalltext')" :value="f.value"/>
|
||||
<date-input v-if="visibleCondition(f, 'date')" :label="f.label" :value="f.value" :field="f.field" :help="showHelp && f.help" :subtitle="f.subtitle"/>
|
||||
<number-input v-if="visibleCondition(f, 'number')" :label="f.label" :value="f.value" :field="f.field" :placeholder="f.placeholder" :help="showHelp && f.help" :subtitle="f.subtitle"/>
|
||||
</div>
|
||||
<template v-slot:modal-footer>
|
||||
<div class="row w-100">
|
||||
@@ -43,13 +40,13 @@
|
||||
|
||||
<script>
|
||||
import Vue from "vue"
|
||||
import { BootstrapVue } from "bootstrap-vue"
|
||||
import { getForm, formFunctions } from "@/utils/utils"
|
||||
import {BootstrapVue} from "bootstrap-vue"
|
||||
import {getForm, formFunctions} from "@/utils/utils"
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
import { ApiApiFactory } from "@/utils/openapi/api"
|
||||
import { ApiMixin, StandardToasts, ToastMixin, getUserPreference } from "@/utils/utils"
|
||||
import {ApiApiFactory} from "@/utils/openapi/api"
|
||||
import {ApiMixin, StandardToasts, ToastMixin, getUserPreference} from "@/utils/utils"
|
||||
import CheckboxInput from "@/components/Modals/CheckboxInput"
|
||||
import LookupInput from "@/components/Modals/LookupInput"
|
||||
import TextInput from "@/components/Modals/TextInput"
|
||||
@@ -63,10 +60,21 @@ import NumberInput from "@/components/Modals/NumberInput.vue";
|
||||
|
||||
export default {
|
||||
name: "GenericModalForm",
|
||||
components: { FileInput, CheckboxInput, LookupInput, TextInput, EmojiInput, ChoiceInput, SmallText, HelpBadge,DateInput, NumberInput },
|
||||
components: {
|
||||
FileInput,
|
||||
CheckboxInput,
|
||||
LookupInput,
|
||||
TextInput,
|
||||
EmojiInput,
|
||||
ChoiceInput,
|
||||
SmallText,
|
||||
HelpBadge,
|
||||
DateInput,
|
||||
NumberInput
|
||||
},
|
||||
mixins: [ApiMixin, ToastMixin],
|
||||
props: {
|
||||
model: { required: true, type: Object },
|
||||
model: {required: true, type: Object},
|
||||
action: {
|
||||
type: Object,
|
||||
default() {
|
||||
@@ -103,7 +111,7 @@ export default {
|
||||
this.id = Math.random()
|
||||
this.$root.$on("change", this.storeValue) // bootstrap modal placed at document so have to listen at root of component
|
||||
|
||||
if (this.models !== null){
|
||||
if (this.models !== null) {
|
||||
this.Models = this.models // override models definition file with prop
|
||||
}
|
||||
},
|
||||
@@ -128,9 +136,9 @@ export default {
|
||||
form_component() {
|
||||
// TODO this leads webpack to create one .js file for each component in this folder because at runtime any one of them could be requested
|
||||
// TODO this is not necessarily bad but maybe there are better options to do this
|
||||
if (this.form.component !== undefined){
|
||||
if (this.form.component !== undefined) {
|
||||
return () => import(/* webpackChunkName: "header-component" */ `@/components/${this.form.component}`)
|
||||
}else{
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
@@ -198,16 +206,16 @@ export default {
|
||||
return form
|
||||
},
|
||||
delete: function () {
|
||||
this.genericAPI(this.model, this.Actions.DELETE, { id: this.item1.id })
|
||||
this.genericAPI(this.model, this.Actions.DELETE, {id: this.item1.id})
|
||||
.then((result) => {
|
||||
this.$emit("finish-action")
|
||||
StandardToasts.makeStandardToast(this,StandardToasts.SUCCESS_DELETE)
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_DELETE)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.response.status === 403){
|
||||
StandardToasts.makeStandardToast(this,StandardToasts.FAIL_DELETE_PROTECTED, err)
|
||||
}else {
|
||||
StandardToasts.makeStandardToast(this,StandardToasts.FAIL_DELETE, err)
|
||||
if (err.response.status === 403) {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_DELETE_PROTECTED, err)
|
||||
} else {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_DELETE, err)
|
||||
}
|
||||
this.$emit("finish-action", "cancel")
|
||||
})
|
||||
@@ -217,22 +225,22 @@ export default {
|
||||
// if there is no item id assume it's a new item
|
||||
this.genericAPI(this.model, this.Actions.CREATE, this.form_data)
|
||||
.then((result) => {
|
||||
this.$emit("finish-action", { item: result.data })
|
||||
StandardToasts.makeStandardToast(this,StandardToasts.SUCCESS_CREATE)
|
||||
this.$emit("finish-action", {item: result.data})
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_CREATE)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err)
|
||||
StandardToasts.makeStandardToast(this,StandardToasts.FAIL_CREATE, err, true)
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_CREATE)
|
||||
this.$emit("finish-action", "cancel")
|
||||
})
|
||||
} else {
|
||||
this.genericAPI(this.model, this.Actions.UPDATE, this.form_data)
|
||||
.then((result) => {
|
||||
this.$emit("finish-action", { item: result.data })
|
||||
StandardToasts.makeStandardToast(this,StandardToasts.SUCCESS_UPDATE)
|
||||
this.$emit("finish-action", {item: result.data})
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_UPDATE)
|
||||
})
|
||||
.catch((err) => {
|
||||
StandardToasts.makeStandardToast(this,StandardToasts.FAIL_UPDATE, err, true)
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
|
||||
this.$emit("finish-action", "cancel")
|
||||
})
|
||||
}
|
||||
@@ -248,13 +256,13 @@ export default {
|
||||
this.$emit("finish-action", "cancel")
|
||||
return
|
||||
}
|
||||
this.genericAPI(this.model, this.Actions.MOVE, { source: this.item1.id, target: this.form_data.target.id })
|
||||
this.genericAPI(this.model, this.Actions.MOVE, {source: this.item1.id, target: this.form_data.target.id})
|
||||
.then((result) => {
|
||||
this.$emit("finish-action", { target: this.form_data.target.id })
|
||||
StandardToasts.makeStandardToast(this,StandardToasts.SUCCESS_MOVE)
|
||||
this.$emit("finish-action", {target: this.form_data.target.id})
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_MOVE)
|
||||
})
|
||||
.catch((err) => {
|
||||
StandardToasts.makeStandardToast(this,StandardToasts.FAIL_MOVE, err)
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_MOVE, err)
|
||||
this.$emit("finish-action", "cancel")
|
||||
})
|
||||
},
|
||||
@@ -274,11 +282,14 @@ export default {
|
||||
target: this.form_data.target.id,
|
||||
})
|
||||
.then((result) => {
|
||||
this.$emit("finish-action", { target: this.form_data.target.id, target_object: this.form_data.target }) //TODO temporary workaround to not change other apis
|
||||
StandardToasts.makeStandardToast(this,StandardToasts.SUCCESS_MERGE)
|
||||
this.$emit("finish-action", {
|
||||
target: this.form_data.target.id,
|
||||
target_object: this.form_data.target
|
||||
}) //TODO temporary workaround to not change other apis
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_MERGE)
|
||||
})
|
||||
.catch((err) => {
|
||||
StandardToasts.makeStandardToast(this,StandardToasts.FAIL_MERGE, err)
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_MERGE, err)
|
||||
this.$emit("finish-action", "cancel")
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-form-group v-bind:label="label" class="mb-3">
|
||||
<b-form-input v-model="new_value" type="text" :placeholder="placeholder"></b-form-input>
|
||||
<b-form-input v-model="new_value" type="text" :placeholder="placeholder" :disabled="disabled"></b-form-input>
|
||||
<em v-if="help" class="small text-muted">{{ help }}</em>
|
||||
<small v-if="subtitle" class="text-muted">{{ subtitle }}</small>
|
||||
</b-form-group>
|
||||
@@ -18,6 +18,7 @@ export default {
|
||||
placeholder: { type: String, default: "You Should Add Placeholder Text" },
|
||||
help: { type: String, default: undefined },
|
||||
subtitle: { type: String, default: undefined },
|
||||
disabled: { type: Boolean, default: false }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
94
vue/src/components/OpenDataImportComponent.vue
Normal file
94
vue/src/components/OpenDataImportComponent.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div>
|
||||
<beta-warning></beta-warning>
|
||||
|
||||
<div v-if="metadata !== undefined">
|
||||
{{ $t('Data_Import_Info') }}
|
||||
<a href="https://github.com/TandoorRecipes/open-tandoor-data" target="_blank" rel="noreferrer nofollow">{{$t('Learn_More')}}</a>
|
||||
|
||||
|
||||
<select class="form-control" v-model="selected_version">
|
||||
<option v-for="v in metadata.versions" v-bind:key="v">{{ v }}</option>
|
||||
</select>
|
||||
|
||||
<b-checkbox v-model="update_existing" class="mt-1">{{ $t('Update_Existing_Data') }}</b-checkbox>
|
||||
<b-checkbox v-model="use_metric" class="mt-1">{{ $t('Use_Metric') }}</b-checkbox>
|
||||
|
||||
|
||||
<div v-if="selected_version !== undefined" class="mt-3">
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th>{{ $t('Datatype') }}</th>
|
||||
<th>{{ $t('Number of Objects') }}</th>
|
||||
<th>{{ $t('Imported') }}</th>
|
||||
</tr>
|
||||
<tr v-for="d in metadata.datatypes" v-bind:key="d">
|
||||
<td>{{ $t(d.charAt(0).toUpperCase() + d.slice(1)) }}</td>
|
||||
<td>{{ metadata[selected_version][d] }}</td>
|
||||
<td>
|
||||
<template v-if="import_count !== undefined">{{ import_count[d] }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<button class="btn btn-success" @click="doImport">{{ $t('Import') }}</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from "vue"
|
||||
import {BootstrapVue} from "bootstrap-vue"
|
||||
|
||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||
import {ApiMixin, resolveDjangoUrl, StandardToasts} from "@/utils/utils";
|
||||
import axios from "axios";
|
||||
import BetaWarning from "@/components/BetaWarning.vue";
|
||||
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
|
||||
export default {
|
||||
name: "OpenDataImportComponent",
|
||||
mixins: [ApiMixin],
|
||||
components: {BetaWarning},
|
||||
data() {
|
||||
return {
|
||||
metadata: undefined,
|
||||
selected_version: undefined,
|
||||
update_existing: true,
|
||||
use_metric: true,
|
||||
import_count: undefined,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$i18n.locale = window.CUSTOM_LOCALE
|
||||
|
||||
axios.get(resolveDjangoUrl('api_import_open_data')).then(r => {
|
||||
this.metadata = r.data
|
||||
}).catch(err => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
doImport: function () {
|
||||
axios.post(resolveDjangoUrl('api_import_open_data'), {
|
||||
'selected_version': this.selected_version,
|
||||
'selected_datatypes': this.metadata.datatypes,
|
||||
'update_existing': this.update_existing,
|
||||
'use_metric': this.use_metric,
|
||||
}).then(r => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_CREATE)
|
||||
this.import_count = r.data
|
||||
}).catch(err => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_CREATE, err)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
195
vue/src/components/PropertyViewComponent.vue
Normal file
195
vue/src/components/PropertyViewComponent.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div>
|
||||
|
||||
|
||||
<div class="card p-4 pb-2" v-if="recipe !== undefined && property_list.length > 0">
|
||||
<b-row>
|
||||
<b-col>
|
||||
<h5><i class="fas fa-database"></i> {{ $t('Properties') }}</h5>
|
||||
</b-col>
|
||||
<b-col class="text-right">
|
||||
<span v-if="!show_total">{{ $t('per_serving') }} </span>
|
||||
<span v-if="show_total">{{ $t('total') }} </span>
|
||||
|
||||
<a href="#" @click="show_total = !show_total">
|
||||
<i class="fas fa-toggle-on" v-if="!show_total"></i>
|
||||
<i class="fas fa-toggle-off" v-if="show_total"></i>
|
||||
</a>
|
||||
|
||||
<div v-if="hasRecipeProperties && hasFoodProperties">
|
||||
<span v-if="!show_recipe_properties">{{ $t('Food') }} </span>
|
||||
<span v-if="show_recipe_properties">{{ $t('Recipe') }} </span>
|
||||
|
||||
<a href="#" @click="show_recipe_properties = !show_recipe_properties">
|
||||
<i class="fas fa-toggle-on" v-if="!show_recipe_properties"></i>
|
||||
<i class="fas fa-toggle-off" v-if="show_recipe_properties"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</b-col>
|
||||
</b-row>
|
||||
|
||||
|
||||
<table class="table table-bordered table-sm">
|
||||
|
||||
<tr v-for="p in property_list" v-bind:key="`id_${p.id}`">
|
||||
<td>
|
||||
|
||||
{{ p.icon }} {{ p.name }}
|
||||
</td>
|
||||
<td class="text-right">{{ get_amount(p.property_amount) }}</td>
|
||||
<td class=""> {{ p.unit }}</td>
|
||||
|
||||
<td class="align-middle text-center" v-if="!show_recipe_properties">
|
||||
<a href="#" @click="selected_property = p">
|
||||
<i v-if="p.missing_value" class="text-warning fas fa-exclamation-triangle"></i>
|
||||
<i v-if="!p.missing_value" class="text-muted fas fa-info-circle"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<b-modal id="id_modal_property_overview" :title="selected_property.name" v-model="show_modal" v-if="selected_property !== undefined"
|
||||
@hidden="selected_property = undefined">
|
||||
<template v-if="selected_property !== undefined">
|
||||
{{ selected_property.description }}
|
||||
<table class="table table-bordered">
|
||||
<tr v-for="f in selected_property.food_values"
|
||||
v-bind:key="`id_${selected_property.id}_food_${f.id}`">
|
||||
<td><a href="#" @click="openFoodEditModal(f)">{{ f.food }}</a></td>
|
||||
<td>{{ f.value }} {{ selected_property.unit }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
</b-modal>
|
||||
|
||||
<generic-modal-form
|
||||
:model="Models.FOOD"
|
||||
:action="Actions.UPDATE"
|
||||
:item1="selected_food"
|
||||
:show="show_food_edit_modal"
|
||||
@hidden="foodEditorHidden"
|
||||
>
|
||||
</generic-modal-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {ApiMixin, StandardToasts} from "@/utils/utils";
|
||||
import GenericModalForm from "@/components/Modals/GenericModalForm.vue";
|
||||
import {ApiApiFactory} from "@/utils/openapi/api";
|
||||
|
||||
export default {
|
||||
name: "PropertyViewComponent",
|
||||
mixins: [ApiMixin],
|
||||
components: {GenericModalForm},
|
||||
props: {
|
||||
recipe: Object,
|
||||
servings: Number,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selected_property: undefined,
|
||||
selected_food: undefined,
|
||||
show_food_edit_modal: false,
|
||||
show_total: false,
|
||||
show_recipe_properties: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show_modal: function () {
|
||||
return this.selected_property !== undefined
|
||||
},
|
||||
hasRecipeProperties: function () {
|
||||
return this.recipe.properties.length !== 0
|
||||
},
|
||||
hasFoodProperties: function () {
|
||||
let has_food_properties = false
|
||||
for (const [key, fp] of Object.entries(this.recipe.food_properties)) {
|
||||
if (fp.total_value !== 0) {
|
||||
has_food_properties = true
|
||||
}
|
||||
}
|
||||
return has_food_properties
|
||||
},
|
||||
property_list: function () {
|
||||
let pt_list = []
|
||||
if (this.show_recipe_properties) {
|
||||
this.recipe.properties.forEach(rp => {
|
||||
pt_list.push(
|
||||
{
|
||||
'id': rp.property_type.id,
|
||||
'name': rp.property_type.name,
|
||||
'description': rp.property_type.description,
|
||||
'icon': rp.property_type.icon,
|
||||
'food_values': [],
|
||||
'property_amount': rp.property_amount,
|
||||
'missing_value': false,
|
||||
'unit': rp.property_type.unit,
|
||||
}
|
||||
)
|
||||
})
|
||||
} else {
|
||||
for (const [key, fp] of Object.entries(this.recipe.food_properties)) {
|
||||
pt_list.push(
|
||||
{
|
||||
'id': fp.id,
|
||||
'name': fp.name,
|
||||
'description': fp.description,
|
||||
'icon': fp.icon,
|
||||
'food_values': fp.food_values,
|
||||
'property_amount': fp.total_value,
|
||||
'missing_value': fp.missing_value,
|
||||
'unit': fp.unit,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
return pt_list
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.hasRecipeProperties && !this.hasFoodProperties) {
|
||||
this.show_recipe_properties = true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
get_amount: function (amount) {
|
||||
if (this.show_total) {
|
||||
return (amount * (this.servings / this.recipe.servings)).toLocaleString(window.CUSTOM_LOCALE, {
|
||||
'maximumFractionDigits': 2,
|
||||
'minimumFractionDigits': 2
|
||||
})
|
||||
} else {
|
||||
return (amount / this.recipe.servings).toLocaleString(window.CUSTOM_LOCALE, {
|
||||
'maximumFractionDigits': 2,
|
||||
'minimumFractionDigits': 2
|
||||
})
|
||||
}
|
||||
},
|
||||
openFoodEditModal: function (food) {
|
||||
console.log(food)
|
||||
let apiClient = ApiApiFactory()
|
||||
apiClient.retrieveFood(food.id).then(r => {
|
||||
this.selected_food = r.data;
|
||||
this.show_food_edit_modal = true
|
||||
}).catch(err => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
|
||||
})
|
||||
},
|
||||
foodEditorHidden: function () {
|
||||
this.show_food_edit_modal = false;
|
||||
this.$emit("foodUpdated", "")
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -21,7 +21,7 @@
|
||||
<template v-else>
|
||||
<b-card no-body v-hover v-if="recipe" style="height: 100%">
|
||||
|
||||
<a :href="this.recipe.id !== undefined ? resolveDjangoUrl('view_recipe', this.recipe.id) : null">
|
||||
<a :href="recipe_link">
|
||||
<div class="content">
|
||||
<div class="content-overlay" v-if="recipe.description !== null && recipe.description !== ''"></div>
|
||||
<b-card-img-lazy style="height: 15vh; object-fit: cover" class="" :src="recipe_image"
|
||||
@@ -35,12 +35,12 @@
|
||||
|
||||
<div class="card-img-overlay d-flex flex-column justify-content-left float-left text-left pt-2" style="width:40%"
|
||||
v-if="recipe.working_time !== 0 || recipe.waiting_time !== 0">
|
||||
<b-badge pill variant="light" class="mt-1 font-weight-normal" v-if="recipe.working_time !== 0">
|
||||
<b-badge pill variant="light" class="mt-1 font-weight-normal" v-if="recipe.working_time !== 0 && recipe.working_time !== undefined">
|
||||
<i
|
||||
class="fa fa-clock"></i> {{ working_time }}
|
||||
</b-badge>
|
||||
<b-badge pill variant="secondary" class="mt-1 font-weight-normal"
|
||||
v-if="recipe.waiting_time !== 0">
|
||||
v-if="recipe.waiting_time !== 0 && recipe.waiting_time !== undefined">
|
||||
<i class="fa fa-pause"></i> {{ waiting_time }}
|
||||
</b-badge>
|
||||
</div>
|
||||
@@ -50,7 +50,7 @@
|
||||
<b-card-body class="p-2 pl-3 pr-3">
|
||||
<div class="d-flex flex-row">
|
||||
<div class="flex-grow-1">
|
||||
<a :href="this.recipe.id !== undefined ? resolveDjangoUrl('view_recipe', this.recipe.id) : null" class="text-body font-weight-bold two-row-text">
|
||||
<a :href="recipe_link" class="text-body font-weight-bold two-row-text">
|
||||
<template v-if="recipe !== null">{{ recipe.name }}</template>
|
||||
<template v-else>{{ meal_plan.title }}</template>
|
||||
</a>
|
||||
@@ -58,7 +58,7 @@
|
||||
<div class="justify-content-end">
|
||||
<recipe-context-menu :recipe="recipe" class="justify-content-end float-right align-items-end pr-0"
|
||||
:disabled_options="context_disabled_options"
|
||||
v-if="recipe !== null"></recipe-context-menu>
|
||||
v-if="recipe !== null && show_context_menu"></recipe-context-menu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
|
||||
<p class="mt-1 mb-1">
|
||||
<last-cooked :recipe="recipe"></last-cooked>
|
||||
<keywords-component :recipe="recipe" :limit="3"
|
||||
<keywords-component :recipe="recipe" :limit="3" :enable_keyword_links="enable_keyword_links"
|
||||
style="margin-top: 4px; position: relative; z-index: 3;"></keywords-component>
|
||||
</p>
|
||||
<transition name="fade" mode="in-out">
|
||||
@@ -89,7 +89,7 @@
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<b-badge pill variant="info" v-if="!recipe.internal">{{ $t("External") }}</b-badge>
|
||||
<b-badge pill variant="info" v-if="recipe.internal !== undefined && !recipe.internal">{{ $t("External") }}</b-badge>
|
||||
</template>
|
||||
|
||||
</b-card-text>
|
||||
@@ -152,6 +152,8 @@ export default {
|
||||
detailed: {type: Boolean, default: true},
|
||||
show_context_menu: {type: Boolean, default: true},
|
||||
context_disabled_options: Object,
|
||||
open_recipe_on_click: {type: Boolean, default: true},
|
||||
enable_keyword_links: {type: Boolean, default: true},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -178,6 +180,13 @@ export default {
|
||||
waiting_time: function () {
|
||||
return calculateHourMinuteSplit(this.recipe.waiting_time)
|
||||
},
|
||||
recipe_link: function (){
|
||||
if(this.open_recipe_on_click){
|
||||
return this.recipe.id !== undefined ? resolveDjangoUrl('view_recipe', this.recipe.id) : null
|
||||
} else {
|
||||
return "#"
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
directives: {
|
||||
|
||||
358
vue/src/components/RecipeViewComponent.vue
Normal file
358
vue/src/components/RecipeViewComponent.vue
Normal file
@@ -0,0 +1,358 @@
|
||||
<template>
|
||||
|
||||
<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="col-12" style="text-align: center">
|
||||
<h3>{{ recipe.name }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="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="col-12" style="text-align: center">
|
||||
<i>{{ recipe.description }}</i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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="d-flex">
|
||||
<div class="my-auto mr-1">
|
||||
<i class="fas fa-fw fa-user-clock fa-2x text-primary"></i>
|
||||
</div>
|
||||
<div class="my-auto mr-1">
|
||||
<span class="text-primary"><b>{{ $t("Preparation") }}</b></span><br/>
|
||||
{{ working_time }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="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>
|
||||
</div>
|
||||
<div class="my-auto mr-1">
|
||||
<span class="text-primary"><b>{{ $t("Waiting") }}</b></span><br/>
|
||||
{{ waiting_time }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="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>
|
||||
</div>
|
||||
<div class="my-auto mr-1">
|
||||
<CustomInputSpinButton v-model.number="servings"/>
|
||||
</div>
|
||||
<div class="my-auto mr-1">
|
||||
<span class="text-primary">
|
||||
<b>
|
||||
<template v-if="recipe.servings_text === ''">{{ $t("Servings") }}</template>
|
||||
<template v-else>{{ recipe.servings_text }}</template>
|
||||
</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="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"
|
||||
v-if="recipe && ingredient_count > 0 && (recipe.show_ingredient_overview || recipe.steps.length < 2)">
|
||||
<ingredients-card
|
||||
:recipe="recipe.id"
|
||||
:steps="recipe.steps"
|
||||
:ingredient_factor="ingredient_factor"
|
||||
:servings="servings"
|
||||
:header="true"
|
||||
id="ingredient_container"
|
||||
@checked-state-changed="updateIngredientCheckedState"
|
||||
@change-servings="servings = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="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')"
|
||||
v-if="recipe.image !== null" @load="onImgLoad"
|
||||
:style="{ 'max-height': ingredient_height }"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="!recipe.internal">
|
||||
<div v-if="recipe.file_path.includes('.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')">
|
||||
<ImageViewer :recipe="recipe"></ImageViewer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-for="(s, index) in recipe.steps" v-bind:key="s.id" style="margin-top: 1vh">
|
||||
<step-component
|
||||
:recipe="recipe"
|
||||
:step="s"
|
||||
:ingredient_factor="ingredient_factor"
|
||||
:index="index"
|
||||
:start_time="start_time"
|
||||
@update-start-time="updateStartTime"
|
||||
@checked-state-changed="updateIngredientCheckedState"
|
||||
></step-component>
|
||||
</div>
|
||||
|
||||
<div 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="col-lg-6 offset-lg-3 col-12">
|
||||
<property-view-component :recipe="recipe" :servings="servings" @foodUpdated="loadRecipe(recipe.id)"></property-view-component>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<add-recipe-to-book :recipe="recipe"></add-recipe-to-book>
|
||||
|
||||
<div class="row text-center d-print-none" style="margin-top: 3vh; margin-bottom: 3vh"
|
||||
v-if="share_uid !== 'None' && !loading">
|
||||
<div class="col col-md-12">
|
||||
<import-tandoor></import-tandoor> <br/>
|
||||
<a :href="resolveDjangoUrl('view_report_share_abuse', share_uid)" class="mt-3">{{ $t("Report Abuse") }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from "vue"
|
||||
import {BootstrapVue} from "bootstrap-vue"
|
||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||
|
||||
import {apiLoadRecipe} from "@/utils/api"
|
||||
|
||||
import RecipeContextMenu from "@/components/RecipeContextMenu"
|
||||
import {ResolveUrlMixin, ToastMixin, calculateHourMinuteSplit} from "@/utils/utils"
|
||||
|
||||
import PdfViewer from "@/components/PdfViewer"
|
||||
import ImageViewer from "@/components/ImageViewer"
|
||||
|
||||
import moment from "moment"
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import AddRecipeToBook from "@/components/Modals/AddRecipeToBook"
|
||||
import RecipeRating from "@/components/RecipeRating"
|
||||
import LastCooked from "@/components/LastCooked"
|
||||
import IngredientsCard from "@/components/IngredientsCard"
|
||||
import StepComponent from "@/components/StepComponent"
|
||||
import KeywordsComponent from "@/components/KeywordsComponent"
|
||||
import RecipeSwitcher from "@/components/Buttons/RecipeSwitcher"
|
||||
import CustomInputSpinButton from "@/components/CustomInputSpinButton"
|
||||
import {ApiApiFactory} from "@/utils/openapi/api";
|
||||
import ImportTandoor from "@/components/Modals/ImportTandoor.vue";
|
||||
import PropertyViewComponent from "@/components/PropertyViewComponent.vue";
|
||||
|
||||
Vue.prototype.moment = moment
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
export default {
|
||||
name: "RecipeView",
|
||||
mixins: [ResolveUrlMixin, ToastMixin],
|
||||
components: {
|
||||
ImportTandoor,
|
||||
LastCooked,
|
||||
RecipeRating,
|
||||
PdfViewer,
|
||||
ImageViewer,
|
||||
IngredientsCard,
|
||||
StepComponent,
|
||||
RecipeContextMenu,
|
||||
KeywordsComponent,
|
||||
LoadingSpinner,
|
||||
AddRecipeToBook,
|
||||
RecipeSwitcher,
|
||||
CustomInputSpinButton,
|
||||
PropertyViewComponent,
|
||||
},
|
||||
computed: {
|
||||
ingredient_factor: function () {
|
||||
return this.servings / this.recipe.servings
|
||||
},
|
||||
ingredient_count() {
|
||||
return this.recipe?.steps.map((x) => x.ingredients).flat().length
|
||||
},
|
||||
working_time: function () {
|
||||
return calculateHourMinuteSplit(this.recipe.working_time)
|
||||
},
|
||||
waiting_time: function () {
|
||||
return calculateHourMinuteSplit(this.recipe.waiting_time)
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
recipe: undefined,
|
||||
rootrecipe: undefined,
|
||||
servings: 1,
|
||||
servings_cache: {},
|
||||
start_time: "",
|
||||
share_uid: window.SHARE_UID,
|
||||
wake_lock: null,
|
||||
ingredient_height: '250',
|
||||
}
|
||||
},
|
||||
props: {
|
||||
recipe_id: Number,
|
||||
show_context_menu: {type: Boolean, default: true},
|
||||
enable_keyword_links: {type: Boolean, default: true},
|
||||
show_recipe_switcher: {type: Boolean, default: true},
|
||||
//show_comments: {type: Boolean, default: true},
|
||||
},
|
||||
watch: {
|
||||
servings(newVal, oldVal) {
|
||||
this.servings_cache[this.recipe.id] = this.servings
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.loadRecipe(this.recipe_id)
|
||||
this.$i18n.locale = window.CUSTOM_LOCALE
|
||||
this.requestWakeLock()
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.destroyWakeLock()
|
||||
},
|
||||
methods: {
|
||||
requestWakeLock: async function () {
|
||||
if ('wakeLock' in navigator) {
|
||||
try {
|
||||
this.wake_lock = await navigator.wakeLock.request('screen')
|
||||
document.addEventListener('visibilitychange', this.visibilityChange)
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
},
|
||||
handleResize: function () {
|
||||
if (document.getElementById('nutrition_container') !== null) {
|
||||
this.ingredient_height = document.getElementById('ingredient_container').clientHeight - document.getElementById('nutrition_container').clientHeight
|
||||
} else {
|
||||
this.ingredient_height = document.getElementById('ingredient_container').clientHeight
|
||||
}
|
||||
},
|
||||
destroyWakeLock: function () {
|
||||
if (this.wake_lock != null) {
|
||||
this.wake_lock.release()
|
||||
.then(() => {
|
||||
this.wake_lock = null
|
||||
});
|
||||
}
|
||||
|
||||
document.removeEventListener('visibilitychange', this.visibilityChange)
|
||||
},
|
||||
visibilityChange: async function () {
|
||||
if (this.wake_lock != null && document.visibilityState === 'visible') {
|
||||
await this.requestWakeLock()
|
||||
}
|
||||
},
|
||||
loadRecipe: function (recipe_id) {
|
||||
apiLoadRecipe(recipe_id).then((recipe) => {
|
||||
let total_time = 0
|
||||
for (let step of recipe.steps) {
|
||||
for (let ingredient of step.ingredients) {
|
||||
this.$set(ingredient, "checked", false)
|
||||
}
|
||||
|
||||
step.time_offset = total_time
|
||||
total_time += step.time
|
||||
}
|
||||
|
||||
// set start time only if there are any steps with timers (otherwise no timers are rendered)
|
||||
if (total_time > 0) {
|
||||
this.start_time = moment().format("yyyy-MM-DDTHH:mm")
|
||||
}
|
||||
|
||||
|
||||
if (recipe.image === null) this.printReady()
|
||||
|
||||
this.recipe = this.rootrecipe = recipe
|
||||
this.servings = this.servings_cache[this.rootrecipe.id] = recipe.servings
|
||||
this.loading = false
|
||||
|
||||
setTimeout(() => {
|
||||
this.handleResize()
|
||||
}, 100)
|
||||
})
|
||||
},
|
||||
updateStartTime: function (e) {
|
||||
this.start_time = e
|
||||
},
|
||||
updateIngredientCheckedState: function (e) {
|
||||
for (let step of this.recipe.steps) {
|
||||
for (let ingredient of step.ingredients) {
|
||||
if (ingredient.id === e.id) {
|
||||
this.$set(ingredient, "checked", !ingredient.checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
quickSwitch: function (e) {
|
||||
if (e === -1) {
|
||||
this.recipe = this.rootrecipe
|
||||
this.servings = this.servings_cache[this.rootrecipe?.id ?? 1]
|
||||
} else {
|
||||
this.recipe = e
|
||||
this.servings = this.servings_cache?.[e.id] ?? e.servings
|
||||
}
|
||||
},
|
||||
printReady: function () {
|
||||
const template = document.createElement("template");
|
||||
template.id = "printReady";
|
||||
document.body.appendChild(template);
|
||||
},
|
||||
onImgLoad: function () {
|
||||
this.printReady()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#app > div > div {
|
||||
break-inside: avoid;
|
||||
}
|
||||
</style>
|
||||
@@ -31,22 +31,22 @@
|
||||
"Step_start_time": "",
|
||||
"Sort_by_new": "",
|
||||
"Table_of_Contents": "",
|
||||
"Recipes_per_page": "",
|
||||
"Recipes_per_page": "Receptů na stránku",
|
||||
"Show_as_header": "",
|
||||
"Hide_as_header": "",
|
||||
"Add_nutrition_recipe": "",
|
||||
"Remove_nutrition_recipe": "",
|
||||
"Add_nutrition_recipe": "Přidat nutriční hodnoty",
|
||||
"Remove_nutrition_recipe": "Smazat nutriční hodnoty",
|
||||
"Copy_template_reference": "",
|
||||
"Save_and_View": "",
|
||||
"Save_and_View": "Uložit & Zobrazit",
|
||||
"Manage_Books": "",
|
||||
"Meal_Plan": "",
|
||||
"Meal_Plan": "Jídelníček",
|
||||
"Select_Book": "",
|
||||
"Select_File": "",
|
||||
"Select_File": "Vybrat soubor",
|
||||
"Recipe_Image": "",
|
||||
"Import_finished": "",
|
||||
"View_Recipes": "",
|
||||
"Import_finished": "Import dokončen",
|
||||
"View_Recipes": "Zobrazit recepty",
|
||||
"Log_Cooking": "",
|
||||
"New_Recipe": "",
|
||||
"New_Recipe": "Nový recept",
|
||||
"Url_Import": "",
|
||||
"Reset_Search": "",
|
||||
"Recently_Viewed": "",
|
||||
@@ -54,21 +54,21 @@
|
||||
"New_Keyword": "",
|
||||
"Delete_Keyword": "",
|
||||
"Edit_Keyword": "",
|
||||
"Edit_Recipe": "",
|
||||
"Edit_Recipe": "Upravit recept",
|
||||
"Move_Keyword": "",
|
||||
"Merge_Keyword": "",
|
||||
"Hide_Keywords": "",
|
||||
"Hide_Recipes": "",
|
||||
"Move_Up": "",
|
||||
"Move_Down": "",
|
||||
"Step_Name": "",
|
||||
"Move_Up": "Nahoru",
|
||||
"Move_Down": "Dolů",
|
||||
"Step_Name": "Název kroku",
|
||||
"Step_Type": "",
|
||||
"Make_Header": "",
|
||||
"Make_Ingredient": "",
|
||||
"Amount": "",
|
||||
"Enable_Amount": "",
|
||||
"Disable_Amount": "",
|
||||
"Ingredient Editor": "",
|
||||
"Amount": "Množství",
|
||||
"Enable_Amount": "Zobrazit množství",
|
||||
"Disable_Amount": "Skrýt množství",
|
||||
"Ingredient Editor": "Editace ingrediencí",
|
||||
"Description_Replace": "",
|
||||
"Instruction_Replace": "",
|
||||
"Auto_Sort": "",
|
||||
@@ -79,8 +79,8 @@
|
||||
"Add_Step": "",
|
||||
"Keywords": "",
|
||||
"Books": "",
|
||||
"Proteins": "",
|
||||
"Fats": "",
|
||||
"Proteins": "Proteiny",
|
||||
"Fats": "Tuky",
|
||||
"Carbohydrates": "",
|
||||
"Calories": "",
|
||||
"Energy": "",
|
||||
@@ -333,7 +333,7 @@
|
||||
"Foods": "",
|
||||
"Account": "",
|
||||
"Cosmetic": "",
|
||||
"API": "",
|
||||
"API": "API",
|
||||
"enable_expert": "",
|
||||
"expert_mode": "",
|
||||
"simple_mode": "",
|
||||
@@ -353,8 +353,8 @@
|
||||
"Custom Filter": "",
|
||||
"shared_with": "",
|
||||
"sort_by": "",
|
||||
"asc": "",
|
||||
"desc": "",
|
||||
"asc": "Vzestupně",
|
||||
"desc": "Sestupně",
|
||||
"date_viewed": "",
|
||||
"last_cooked": "",
|
||||
"times_cooked": "",
|
||||
@@ -362,29 +362,29 @@
|
||||
"show_sortby": "",
|
||||
"search_rank": "",
|
||||
"make_now": "",
|
||||
"recipe_filter": "",
|
||||
"recipe_filter": "Filtrovat recepty",
|
||||
"book_filter_help": "",
|
||||
"review_shopping": "",
|
||||
"view_recipe": "",
|
||||
"view_recipe": "Zobrazit recept",
|
||||
"copy_to_new": "",
|
||||
"recipe_name": "",
|
||||
"recipe_name": "Název receptu",
|
||||
"paste_ingredients_placeholder": "",
|
||||
"paste_ingredients": "",
|
||||
"ingredient_list": "",
|
||||
"explain": "",
|
||||
"filter": "",
|
||||
"Website": "",
|
||||
"App": "",
|
||||
"filter": "Filtr",
|
||||
"Website": "Web",
|
||||
"App": "Aplikace",
|
||||
"Message": "",
|
||||
"Bookmarklet": "",
|
||||
"Sticky_Nav": "",
|
||||
"Sticky_Nav_Help": "",
|
||||
"Nav_Color": "",
|
||||
"Nav_Color_Help": "",
|
||||
"Use_Kj": "",
|
||||
"Comments_setting": "",
|
||||
"click_image_import": "",
|
||||
"no_more_images_found": "",
|
||||
"Use_Kj": "Používat kJ místo kcal",
|
||||
"Comments_setting": "Zobrazit komentáře",
|
||||
"click_image_import": "Vyberte obrázek, který chcete přiřadit k tomuto receptu",
|
||||
"no_more_images_found": "Žádné další obrázky na zadaném odkazu.",
|
||||
"import_duplicates": "",
|
||||
"paste_json": "",
|
||||
"Click_To_Edit": "",
|
||||
@@ -407,9 +407,9 @@
|
||||
"InheritFields_help": "",
|
||||
"show_ingredient_overview": "",
|
||||
"Ingredient Overview": "",
|
||||
"last_viewed": "",
|
||||
"created_on": "",
|
||||
"updatedon": "",
|
||||
"last_viewed": "Naposledy zobrazeno",
|
||||
"created_on": "Vytvořeno",
|
||||
"updatedon": "Upraveno",
|
||||
"Imported_From": "",
|
||||
"advanced_search_settings": "",
|
||||
"nothing_planned_today": "",
|
||||
@@ -418,13 +418,13 @@
|
||||
"Pinned": "",
|
||||
"Imported": "",
|
||||
"Quick actions": "",
|
||||
"Ratings": "",
|
||||
"Ratings": "Hodnocení",
|
||||
"Internal": "",
|
||||
"Units": "",
|
||||
"Units": "Jednotky",
|
||||
"Manage_Emails": "",
|
||||
"Change_Password": "",
|
||||
"Change_Password": "Změna hesla",
|
||||
"Social_Authentication": "",
|
||||
"Random Recipes": "",
|
||||
"Random Recipes": "Náhodné recepty",
|
||||
"parameter_count": "",
|
||||
"select_keyword": "",
|
||||
"add_keyword": "",
|
||||
@@ -436,10 +436,10 @@
|
||||
"empty_list": "",
|
||||
"Select": "",
|
||||
"Supermarkets": "",
|
||||
"User": "",
|
||||
"Username": "",
|
||||
"First_name": "",
|
||||
"Last_name": "",
|
||||
"User": "Uživatel",
|
||||
"Username": "Uživatelské jméno",
|
||||
"First_name": "Jméno",
|
||||
"Last_name": "Příjmení",
|
||||
"Keyword": "",
|
||||
"Advanced": "",
|
||||
"Page": "",
|
||||
@@ -452,15 +452,15 @@
|
||||
"Create Food": "",
|
||||
"create_food_desc": "",
|
||||
"additional_options": "",
|
||||
"Importer_Help": "",
|
||||
"Documentation": "",
|
||||
"Select_App_To_Import": "",
|
||||
"Import_Supported": "",
|
||||
"Export_Supported": "",
|
||||
"Import_Not_Yet_Supported": "",
|
||||
"Export_Not_Yet_Supported": "",
|
||||
"Import_Result_Info": "",
|
||||
"Recipes_In_Import": "",
|
||||
"Importer_Help": "Nápověda k importu z této aplikace:",
|
||||
"Documentation": "Dokumentace",
|
||||
"Select_App_To_Import": "Vyberte aplikaci, ze které chcete importovat",
|
||||
"Import_Supported": "Import podporován",
|
||||
"Export_Supported": "Export podporován",
|
||||
"Import_Not_Yet_Supported": "Import není zatím podporován",
|
||||
"Export_Not_Yet_Supported": "Export není zatím podporován",
|
||||
"Import_Result_Info": "{imported} z {total} receptů naimportováno",
|
||||
"Recipes_In_Import": "Receptů v importním souboru",
|
||||
"Toggle": "",
|
||||
"Import_Error": "",
|
||||
"Warning_Delete_Supermarket_Category": "",
|
||||
@@ -477,6 +477,6 @@
|
||||
"Use_Plural_Food_Always": "",
|
||||
"Use_Plural_Food_Simple": "",
|
||||
"plural_usage_info": "",
|
||||
"Create Recipe": "",
|
||||
"Import Recipe": ""
|
||||
"Create Recipe": "Vytvořit recept",
|
||||
"Import Recipe": "Importovat recept"
|
||||
}
|
||||
|
||||
@@ -296,7 +296,7 @@
|
||||
"OnHand_help": "Lebensmittel ist \"Vorrätig\" und wird nicht automatisch zur Einkaufsliste hinzugefügt. Der Status \"Vorrätig\" wird mit den Benutzern der Einkaufsliste geteilt.",
|
||||
"shopping_category_help": "Einkaufsläden können nach Produktkategorie entsprechend der Anordnung der Regalreihen sortiert werden.",
|
||||
"Foods": "Lebensmittel",
|
||||
"food_recipe_help": "Wird ein Rezept hier verknüpft, wird diese Verknüpfung in allen anderen Rezepten übernommen, die dieses Lebensmittel beinhaltet",
|
||||
"food_recipe_help": "Wird ein Rezept hier verknüpft, wird diese Verknüpfung in allen anderen Rezepten übernommen, die dieses Lebensmittel beinhalten",
|
||||
"review_shopping": "Überprüfe die Einkaufsliste vor dem Speichern",
|
||||
"view_recipe": "Rezept anschauen",
|
||||
"Planned": "Geplant",
|
||||
@@ -362,7 +362,7 @@
|
||||
"and_down": "& Runter",
|
||||
"enable_expert": "Expertenmodus aktivieren",
|
||||
"filter_name": "Name des Filters",
|
||||
"shared_with": "Geteilt mit",
|
||||
"shared_with": "geteilt mit",
|
||||
"asc": "Aufsteigend",
|
||||
"desc": "Absteigend",
|
||||
"book_filter_help": "Schließt zusätzlich zu den manuell hinzugefügten Rezepten, alle Rezepte die dem Filter entsprechen ein.",
|
||||
@@ -408,7 +408,7 @@
|
||||
"New_Supermarket": "Erstelle einen neuen Supermarkt",
|
||||
"New_Supermarket_Category": "Erstelle eine neue Supermarktkategorie",
|
||||
"warning_space_delete": "Du kannst deinen Space inklusive all deiner Rezepte, Shoppinglisten, Essensplänen und allem anderen, das du erstellt hast löschen. Dieser Schritt kann nicht rückgängig gemacht werden! Bist du sicher, dass du das tun möchtest?",
|
||||
"Copy Link": "Kopiere den Link in die Zwischenablage",
|
||||
"Copy Link": "Link Kopieren",
|
||||
"Users": "Benutzer",
|
||||
"facet_count_info": "Zeige die Anzahl der Rezepte auf den Suchfiltern.",
|
||||
"Copy Token": "Kopiere Token",
|
||||
@@ -460,9 +460,9 @@
|
||||
"Comments_setting": "Kommentare anzeigen",
|
||||
"reset_food_inheritance": "Vererbung zurücksetzen",
|
||||
"food_inherit_info": "Datenfelder des Lebensmittels, die standardmäßig vererbt werden sollen.",
|
||||
"Are_You_Sure": "Bist du dir sicher?",
|
||||
"Are_You_Sure": "Sind sie sicher?",
|
||||
"Plural": "Plural",
|
||||
"plural_short": "pl.",
|
||||
"plural_short": "Plural",
|
||||
"Use_Plural_Unit_Always": "Pluralform der Maßeinheit immer verwenden",
|
||||
"Use_Plural_Unit_Simple": "Pluralform der Maßeinheit dynamisch anpassen",
|
||||
"Use_Plural_Food_Always": "Pluralform des Essens immer verwenden",
|
||||
@@ -481,5 +481,23 @@
|
||||
"Amount": "Menge",
|
||||
"Original_Text": "Originaler Text",
|
||||
"Import Recipe": "Rezept importieren",
|
||||
"Create Recipe": "Rezept erstellen"
|
||||
"Create Recipe": "Rezept erstellen",
|
||||
"recipe_property_info": "Sie können auch Eigenschaften zu Lebensmitteln hinzufügen, um sie automatisch auf der Grundlage Ihres Rezepts zu berechnen!",
|
||||
"per_serving": "pro Portion",
|
||||
"open_data_help_text": "Das Tandoor Open Data Projekt bietet von der Gemeinschaft bereitgestellte Daten für Tandoor. Dieses Feld wird beim Importieren automatisch ausgefüllt und ermöglicht künftige Aktualisierungen.",
|
||||
"Open_Data_Import": "Datenimport öffnen",
|
||||
"Update_Existing_Data": "Vorhandene Daten aktualisieren",
|
||||
"Data_Import_Info": "Verbessern Sie Ihren Space, indem Sie eine von der Community kuratierte Liste von Lebensmitteln, Einheiten und mehr importieren, um Ihre Rezeptsammlung zu verbessern.",
|
||||
"Learn_More": "Mehr erfahren",
|
||||
"Use_Metric": "Metrische Einheiten verwenden",
|
||||
"converted_unit": "Umgerechnete Einheit",
|
||||
"converted_amount": "Umgerechneter Betrag",
|
||||
"base_unit": "Basiseinheit",
|
||||
"base_amount": "Grundbetrag",
|
||||
"Datatype": "Datentyp",
|
||||
"Number of Objects": "Anzahl von Objekten",
|
||||
"Property": "Eigenschaft",
|
||||
"Conversion": "Umrechnung",
|
||||
"Properties": "Eigenschaften",
|
||||
"total": "gesamt"
|
||||
}
|
||||
|
||||
501
vue/src/locales/el.json
Normal file
501
vue/src/locales/el.json
Normal file
@@ -0,0 +1,501 @@
|
||||
{
|
||||
"warning_feature_beta": "Αυτή η λειτουργία βρίσκεται αυτήν τη στιγμή σε κατάσταση BETA (δοκιμαστική). Παρακαλούμε να αναμένετε σφάλματα και πιθανές αλλαγές που μπορεί να προκαλέσουν απώλεια δεδομένων που σχετίζονται με τις διάφορες λειτουργίες στο μέλλον.",
|
||||
"err_fetching_resource": "",
|
||||
"err_creating_resource": "",
|
||||
"err_updating_resource": "",
|
||||
"err_deleting_resource": "",
|
||||
"err_deleting_protected_resource": "",
|
||||
"err_moving_resource": "",
|
||||
"err_merging_resource": "",
|
||||
"success_fetching_resource": "",
|
||||
"success_creating_resource": "",
|
||||
"success_updating_resource": "",
|
||||
"success_deleting_resource": "",
|
||||
"success_moving_resource": "",
|
||||
"success_merging_resource": "",
|
||||
"file_upload_disabled": "",
|
||||
"recipe_property_info": "",
|
||||
"warning_space_delete": "",
|
||||
"food_inherit_info": "",
|
||||
"facet_count_info": "",
|
||||
"step_time_minutes": "",
|
||||
"confirm_delete": "",
|
||||
"import_running": "",
|
||||
"all_fields_optional": "",
|
||||
"convert_internal": "",
|
||||
"show_only_internal": "",
|
||||
"show_split_screen": "",
|
||||
"Log_Recipe_Cooking": "",
|
||||
"External_Recipe_Image": "",
|
||||
"Add_to_Shopping": "",
|
||||
"Add_to_Plan": "",
|
||||
"Step_start_time": "",
|
||||
"Sort_by_new": "",
|
||||
"Table_of_Contents": "",
|
||||
"Recipes_per_page": "",
|
||||
"Show_as_header": "",
|
||||
"Hide_as_header": "",
|
||||
"Add_nutrition_recipe": "",
|
||||
"Remove_nutrition_recipe": "",
|
||||
"Copy_template_reference": "",
|
||||
"per_serving": "",
|
||||
"Save_and_View": "",
|
||||
"Manage_Books": "",
|
||||
"Meal_Plan": "",
|
||||
"Select_Book": "",
|
||||
"Select_File": "",
|
||||
"Recipe_Image": "",
|
||||
"Import_finished": "",
|
||||
"View_Recipes": "",
|
||||
"Log_Cooking": "",
|
||||
"New_Recipe": "",
|
||||
"Url_Import": "",
|
||||
"Reset_Search": "",
|
||||
"Recently_Viewed": "",
|
||||
"Load_More": "",
|
||||
"New_Keyword": "",
|
||||
"Delete_Keyword": "",
|
||||
"Edit_Keyword": "",
|
||||
"Edit_Recipe": "",
|
||||
"Move_Keyword": "",
|
||||
"Merge_Keyword": "",
|
||||
"Hide_Keywords": "",
|
||||
"Hide_Recipes": "",
|
||||
"Move_Up": "",
|
||||
"Move_Down": "",
|
||||
"Step_Name": "",
|
||||
"Step_Type": "",
|
||||
"Make_Header": "",
|
||||
"Make_Ingredient": "",
|
||||
"Amount": "",
|
||||
"Enable_Amount": "",
|
||||
"Disable_Amount": "",
|
||||
"Ingredient Editor": "",
|
||||
"Description_Replace": "",
|
||||
"Instruction_Replace": "",
|
||||
"Auto_Sort": "",
|
||||
"Auto_Sort_Help": "",
|
||||
"Private_Recipe": "",
|
||||
"Private_Recipe_Help": "",
|
||||
"reusable_help_text": "",
|
||||
"open_data_help_text": "",
|
||||
"Open_Data_Slug": "",
|
||||
"Open_Data_Import": "",
|
||||
"Data_Import_Info": "",
|
||||
"Update_Existing_Data": "",
|
||||
"Use_Metric": "",
|
||||
"Learn_More": "",
|
||||
"converted_unit": "",
|
||||
"converted_amount": "",
|
||||
"base_unit": "",
|
||||
"base_amount": "",
|
||||
"Datatype": "",
|
||||
"Number of Objects": "",
|
||||
"Add_Step": "",
|
||||
"Keywords": "Λέξεις κλειδιά",
|
||||
"Books": "Βιβλία",
|
||||
"Proteins": "",
|
||||
"Fats": "",
|
||||
"Carbohydrates": "",
|
||||
"Calories": "",
|
||||
"Energy": "",
|
||||
"Nutrition": "",
|
||||
"Date": "",
|
||||
"Share": "",
|
||||
"Automation": "",
|
||||
"Parameter": "",
|
||||
"Export": "",
|
||||
"Copy": "",
|
||||
"Rating": "",
|
||||
"Close": "",
|
||||
"Cancel": "",
|
||||
"Link": "",
|
||||
"Add": "",
|
||||
"New": "",
|
||||
"Note": "",
|
||||
"Success": "",
|
||||
"Failure": "",
|
||||
"Protected": "",
|
||||
"Ingredients": "",
|
||||
"Supermarket": "",
|
||||
"Categories": "",
|
||||
"Category": "",
|
||||
"Selected": "",
|
||||
"min": "",
|
||||
"Servings": "Μερίδες",
|
||||
"Waiting": "",
|
||||
"Preparation": "",
|
||||
"External": "",
|
||||
"Size": "",
|
||||
"Files": "",
|
||||
"File": "",
|
||||
"Edit": "",
|
||||
"Image": "",
|
||||
"Delete": "",
|
||||
"Open": "",
|
||||
"Ok": "",
|
||||
"Save": "",
|
||||
"Step": "",
|
||||
"Search": "",
|
||||
"Import": "",
|
||||
"Print": "",
|
||||
"Settings": "Ρυθμίσεις",
|
||||
"or": "",
|
||||
"and": "",
|
||||
"Information": "",
|
||||
"Download": "",
|
||||
"Create": "",
|
||||
"Search Settings": "",
|
||||
"View": "",
|
||||
"Recipes": "",
|
||||
"Move": "",
|
||||
"Merge": "",
|
||||
"Parent": "",
|
||||
"Copy Link": "",
|
||||
"Copy Token": "",
|
||||
"delete_confirmation": "",
|
||||
"move_confirmation": "",
|
||||
"merge_confirmation": "",
|
||||
"create_rule": "",
|
||||
"move_selection": "",
|
||||
"merge_selection": "",
|
||||
"Root": "",
|
||||
"Ignore_Shopping": "",
|
||||
"Shopping_Category": "",
|
||||
"Shopping_Categories": "",
|
||||
"Edit_Food": "",
|
||||
"Move_Food": "",
|
||||
"New_Food": "",
|
||||
"Hide_Food": "",
|
||||
"Food_Alias": "",
|
||||
"Unit_Alias": "",
|
||||
"Keyword_Alias": "",
|
||||
"Delete_Food": "",
|
||||
"No_ID": "",
|
||||
"Meal_Plan_Days": "",
|
||||
"merge_title": "",
|
||||
"move_title": "",
|
||||
"Food": "Φαγητό",
|
||||
"Property": "",
|
||||
"Conversion": "",
|
||||
"Original_Text": "",
|
||||
"Recipe_Book": "",
|
||||
"del_confirmation_tree": "",
|
||||
"delete_title": "",
|
||||
"create_title": "",
|
||||
"edit_title": "",
|
||||
"Name": "",
|
||||
"Properties": "",
|
||||
"Type": "",
|
||||
"Description": "",
|
||||
"Recipe": "",
|
||||
"tree_root": "",
|
||||
"Icon": "",
|
||||
"Unit": "",
|
||||
"Decimals": "",
|
||||
"Default_Unit": "",
|
||||
"No_Results": "",
|
||||
"New_Unit": "",
|
||||
"Create_New_Shopping Category": "",
|
||||
"Create_New_Food": "",
|
||||
"Create_New_Keyword": "",
|
||||
"Create_New_Unit": "",
|
||||
"Create_New_Meal_Type": "",
|
||||
"Create_New_Shopping_Category": "",
|
||||
"and_up": "",
|
||||
"and_down": "",
|
||||
"Instructions": "",
|
||||
"Unrated": "",
|
||||
"Automate": "",
|
||||
"Empty": "",
|
||||
"Key_Ctrl": "",
|
||||
"Key_Shift": "",
|
||||
"Time": "",
|
||||
"Text": "",
|
||||
"Shopping_list": "",
|
||||
"Added_by": "",
|
||||
"Added_on": "",
|
||||
"AddToShopping": "",
|
||||
"IngredientInShopping": "",
|
||||
"NotInShopping": "",
|
||||
"OnHand": "",
|
||||
"FoodOnHand": "",
|
||||
"FoodNotOnHand": "",
|
||||
"Undefined": "",
|
||||
"Create_Meal_Plan_Entry": "",
|
||||
"Edit_Meal_Plan_Entry": "",
|
||||
"Title": "",
|
||||
"Week": "",
|
||||
"Month": "",
|
||||
"Year": "",
|
||||
"Planner": "",
|
||||
"Planner_Settings": "",
|
||||
"Period": "",
|
||||
"Plan_Period_To_Show": "",
|
||||
"Periods": "",
|
||||
"Plan_Show_How_Many_Periods": "",
|
||||
"Starting_Day": "",
|
||||
"Meal_Types": "",
|
||||
"Meal_Type": "",
|
||||
"New_Entry": "",
|
||||
"Clone": "",
|
||||
"Drag_Here_To_Delete": "",
|
||||
"Meal_Type_Required": "",
|
||||
"Title_or_Recipe_Required": "",
|
||||
"Color": "",
|
||||
"New_Meal_Type": "",
|
||||
"Use_Fractions": "",
|
||||
"Use_Fractions_Help": "",
|
||||
"AddFoodToShopping": "",
|
||||
"RemoveFoodFromShopping": "",
|
||||
"DeleteShoppingConfirm": "",
|
||||
"IgnoredFood": "",
|
||||
"Add_Servings_to_Shopping": "",
|
||||
"Week_Numbers": "",
|
||||
"Show_Week_Numbers": "",
|
||||
"Export_As_ICal": "",
|
||||
"Export_To_ICal": "",
|
||||
"Cannot_Add_Notes_To_Shopping": "",
|
||||
"Added_To_Shopping_List": "",
|
||||
"Shopping_List_Empty": "",
|
||||
"Next_Period": "",
|
||||
"Previous_Period": "",
|
||||
"Current_Period": "",
|
||||
"Next_Day": "",
|
||||
"Previous_Day": "",
|
||||
"Inherit": "",
|
||||
"InheritFields": "",
|
||||
"FoodInherit": "",
|
||||
"ShowUncategorizedFood": "",
|
||||
"GroupBy": "",
|
||||
"Language": "",
|
||||
"Theme": "",
|
||||
"SupermarketCategoriesOnly": "",
|
||||
"MoveCategory": "",
|
||||
"CountMore": "",
|
||||
"IgnoreThis": "",
|
||||
"DelayFor": "",
|
||||
"Warning": "",
|
||||
"NoCategory": "",
|
||||
"InheritWarning": "",
|
||||
"ShowDelayed": "",
|
||||
"Completed": "",
|
||||
"OfflineAlert": "",
|
||||
"shopping_share": "",
|
||||
"shopping_auto_sync": "",
|
||||
"one_url_per_line": "",
|
||||
"mealplan_autoadd_shopping": "",
|
||||
"mealplan_autoexclude_onhand": "",
|
||||
"mealplan_autoinclude_related": "",
|
||||
"default_delay": "",
|
||||
"plan_share_desc": "",
|
||||
"shopping_share_desc": "",
|
||||
"shopping_auto_sync_desc": "",
|
||||
"mealplan_autoadd_shopping_desc": "",
|
||||
"mealplan_autoexclude_onhand_desc": "",
|
||||
"mealplan_autoinclude_related_desc": "",
|
||||
"default_delay_desc": "",
|
||||
"filter_to_supermarket": "",
|
||||
"Coming_Soon": "",
|
||||
"Auto_Planner": "",
|
||||
"New_Cookbook": "",
|
||||
"Hide_Keyword": "",
|
||||
"Hour": "",
|
||||
"Hours": "",
|
||||
"Day": "",
|
||||
"Days": "",
|
||||
"Second": "",
|
||||
"Seconds": "",
|
||||
"Clear": "",
|
||||
"Users": "",
|
||||
"Invites": "",
|
||||
"err_move_self": "",
|
||||
"nothing": "",
|
||||
"err_merge_self": "",
|
||||
"show_sql": "",
|
||||
"filter_to_supermarket_desc": "",
|
||||
"CategoryName": "",
|
||||
"SupermarketName": "",
|
||||
"CategoryInstruction": "",
|
||||
"shopping_recent_days_desc": "",
|
||||
"shopping_recent_days": "",
|
||||
"download_pdf": "",
|
||||
"download_csv": "",
|
||||
"csv_delim_help": "",
|
||||
"csv_delim_label": "",
|
||||
"SuccessClipboard": "",
|
||||
"copy_to_clipboard": "",
|
||||
"csv_prefix_help": "",
|
||||
"csv_prefix_label": "",
|
||||
"copy_markdown_table": "",
|
||||
"in_shopping": "",
|
||||
"DelayUntil": "",
|
||||
"Pin": "",
|
||||
"Unpin": "",
|
||||
"PinnedConfirmation": "",
|
||||
"UnpinnedConfirmation": "",
|
||||
"mark_complete": "",
|
||||
"QuickEntry": "",
|
||||
"shopping_add_onhand_desc": "",
|
||||
"shopping_add_onhand": "",
|
||||
"related_recipes": "",
|
||||
"today_recipes": "",
|
||||
"sql_debug": "",
|
||||
"remember_search": "",
|
||||
"remember_hours": "",
|
||||
"tree_select": "",
|
||||
"OnHand_help": "",
|
||||
"ignore_shopping_help": "",
|
||||
"shopping_category_help": "",
|
||||
"food_recipe_help": "",
|
||||
"Foods": "",
|
||||
"Account": "",
|
||||
"Cosmetic": "",
|
||||
"API": "",
|
||||
"enable_expert": "",
|
||||
"expert_mode": "",
|
||||
"simple_mode": "",
|
||||
"advanced": "",
|
||||
"fields": "",
|
||||
"show_keywords": "",
|
||||
"show_foods": "",
|
||||
"show_books": "",
|
||||
"show_rating": "",
|
||||
"show_units": "",
|
||||
"show_filters": "",
|
||||
"not": "",
|
||||
"save_filter": "",
|
||||
"filter_name": "",
|
||||
"left_handed": "",
|
||||
"left_handed_help": "",
|
||||
"Custom Filter": "",
|
||||
"shared_with": "",
|
||||
"sort_by": "",
|
||||
"asc": "",
|
||||
"desc": "",
|
||||
"date_viewed": "",
|
||||
"last_cooked": "",
|
||||
"times_cooked": "",
|
||||
"date_created": "",
|
||||
"show_sortby": "",
|
||||
"search_rank": "",
|
||||
"make_now": "",
|
||||
"recipe_filter": "",
|
||||
"book_filter_help": "",
|
||||
"review_shopping": "",
|
||||
"view_recipe": "",
|
||||
"copy_to_new": "",
|
||||
"recipe_name": "",
|
||||
"paste_ingredients_placeholder": "",
|
||||
"paste_ingredients": "",
|
||||
"ingredient_list": "",
|
||||
"explain": "",
|
||||
"filter": "",
|
||||
"Website": "",
|
||||
"App": "",
|
||||
"Message": "",
|
||||
"Bookmarklet": "",
|
||||
"Sticky_Nav": "",
|
||||
"Sticky_Nav_Help": "",
|
||||
"Nav_Color": "",
|
||||
"Nav_Color_Help": "",
|
||||
"Use_Kj": "",
|
||||
"Comments_setting": "",
|
||||
"click_image_import": "",
|
||||
"no_more_images_found": "",
|
||||
"import_duplicates": "",
|
||||
"paste_json": "",
|
||||
"Click_To_Edit": "",
|
||||
"search_no_recipes": "",
|
||||
"search_import_help_text": "",
|
||||
"search_create_help_text": "",
|
||||
"warning_duplicate_filter": "",
|
||||
"reset_children": "",
|
||||
"reset_children_help": "",
|
||||
"reset_food_inheritance": "",
|
||||
"reset_food_inheritance_info": "",
|
||||
"substitute_help": "",
|
||||
"substitute_siblings_help": "",
|
||||
"substitute_children_help": "",
|
||||
"substitute_siblings": "",
|
||||
"substitute_children": "",
|
||||
"SubstituteOnHand": "",
|
||||
"ChildInheritFields": "",
|
||||
"ChildInheritFields_help": "",
|
||||
"InheritFields_help": "",
|
||||
"show_ingredient_overview": "",
|
||||
"Ingredient Overview": "",
|
||||
"last_viewed": "",
|
||||
"created_on": "",
|
||||
"updatedon": "",
|
||||
"Imported_From": "",
|
||||
"advanced_search_settings": "",
|
||||
"nothing_planned_today": "",
|
||||
"no_pinned_recipes": "",
|
||||
"Planned": "",
|
||||
"Pinned": "",
|
||||
"Imported": "",
|
||||
"Quick actions": "",
|
||||
"Ratings": "",
|
||||
"Internal": "",
|
||||
"Units": "",
|
||||
"Manage_Emails": "",
|
||||
"Change_Password": "",
|
||||
"Social_Authentication": "",
|
||||
"Random Recipes": "",
|
||||
"parameter_count": "",
|
||||
"select_keyword": "",
|
||||
"add_keyword": "",
|
||||
"select_file": "",
|
||||
"select_recipe": "",
|
||||
"select_unit": "",
|
||||
"select_food": "",
|
||||
"remove_selection": "",
|
||||
"empty_list": "",
|
||||
"Select": "",
|
||||
"Supermarkets": "",
|
||||
"User": "",
|
||||
"Username": "",
|
||||
"First_name": "",
|
||||
"Last_name": "",
|
||||
"Keyword": "Λέξη κλειδί",
|
||||
"Advanced": "",
|
||||
"Page": "",
|
||||
"Single": "",
|
||||
"Multiple": "",
|
||||
"Reset": "",
|
||||
"Disabled": "",
|
||||
"Disable": "",
|
||||
"Options": "",
|
||||
"Create Food": "",
|
||||
"create_food_desc": "",
|
||||
"additional_options": "",
|
||||
"Importer_Help": "",
|
||||
"Documentation": "",
|
||||
"Select_App_To_Import": "",
|
||||
"Import_Supported": "",
|
||||
"Export_Supported": "",
|
||||
"Import_Not_Yet_Supported": "",
|
||||
"Export_Not_Yet_Supported": "",
|
||||
"Import_Result_Info": "",
|
||||
"Recipes_In_Import": "",
|
||||
"Toggle": "",
|
||||
"total": "",
|
||||
"Import_Error": "",
|
||||
"Warning_Delete_Supermarket_Category": "",
|
||||
"New_Supermarket": "",
|
||||
"New_Supermarket_Category": "",
|
||||
"Are_You_Sure": "",
|
||||
"Valid Until": "",
|
||||
"Split_All_Steps": "",
|
||||
"Combine_All_Steps": "",
|
||||
"Plural": "",
|
||||
"plural_short": "",
|
||||
"Use_Plural_Unit_Always": "",
|
||||
"Use_Plural_Unit_Simple": "",
|
||||
"Use_Plural_Food_Always": "",
|
||||
"Use_Plural_Food_Simple": "",
|
||||
"plural_usage_info": "",
|
||||
"Create Recipe": "",
|
||||
"Import Recipe": ""
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
"success_moving_resource": "Successfully moved a resource!",
|
||||
"success_merging_resource": "Successfully merged a resource!",
|
||||
"file_upload_disabled": "File upload is not enabled for your space.",
|
||||
"recipe_property_info": "You can also add properties to foods to calculate them automatically based on your recipe!",
|
||||
"warning_space_delete": "You can delete your space including all recipes, shopping lists, meal plans and whatever else you have created. This cannot be undone! Are you sure you want to do this ?",
|
||||
"food_inherit_info": "Fields on food that should be inherited by default.",
|
||||
"facet_count_info": "Show recipe counts on search filters.",
|
||||
@@ -37,6 +38,7 @@
|
||||
"Add_nutrition_recipe": "Add nutrition to recipe",
|
||||
"Remove_nutrition_recipe": "Delete nutrition from recipe",
|
||||
"Copy_template_reference": "Copy template reference",
|
||||
"per_serving": "per servings",
|
||||
"Save_and_View": "Save & View",
|
||||
"Manage_Books": "Manage Books",
|
||||
"Meal_Plan": "Meal Plan",
|
||||
@@ -76,6 +78,19 @@
|
||||
"Private_Recipe": "Private Recipe",
|
||||
"Private_Recipe_Help": "Recipe is only shown to you and people its shared with.",
|
||||
"reusable_help_text": "Should the invite link be usable for more than one user.",
|
||||
"open_data_help_text": "The Tandoor Open Data project provides community contributed data for Tandoor. This field is filled automatically when importing it and allows updates in the future.",
|
||||
"Open_Data_Slug": "Open Data Slug",
|
||||
"Open_Data_Import": "Open Data Import",
|
||||
"Data_Import_Info": "Enhance your Space by importing a community curated list of foods, units and more to improve your recipe collection.",
|
||||
"Update_Existing_Data": "Update Existing Data",
|
||||
"Use_Metric": "Use Metric Units",
|
||||
"Learn_More": "Learn More",
|
||||
"converted_unit": "Converted Unit",
|
||||
"converted_amount": "Converted Amount",
|
||||
"base_unit": "Base Unit",
|
||||
"base_amount": "Base Amount",
|
||||
"Datatype": "Datatype",
|
||||
"Number of Objects": "Number of Objects",
|
||||
"Add_Step": "Add Step",
|
||||
"Keywords": "Keywords",
|
||||
"Books": "Books",
|
||||
@@ -161,6 +176,8 @@
|
||||
"merge_title": "Merge {type}",
|
||||
"move_title": "Move {type}",
|
||||
"Food": "Food",
|
||||
"Property": "Property",
|
||||
"Conversion": "Conversion",
|
||||
"Original_Text": "Original Text",
|
||||
"Recipe_Book": "Recipe Book",
|
||||
"del_confirmation_tree": "Are you sure that you want to delete {source} and all of it's children?",
|
||||
@@ -168,6 +185,7 @@
|
||||
"create_title": "New {type}",
|
||||
"edit_title": "Edit {type}",
|
||||
"Name": "Name",
|
||||
"Properties": "Properties",
|
||||
"Type": "Type",
|
||||
"Description": "Description",
|
||||
"Recipe": "Recipe",
|
||||
@@ -462,6 +480,7 @@
|
||||
"Import_Result_Info": "{imported} of {total} recipes were imported",
|
||||
"Recipes_In_Import": "Recipes in your import file",
|
||||
"Toggle": "Toggle",
|
||||
"total": "total",
|
||||
"Import_Error": "An Error occurred during your import. Please expand the Details at the bottom of the page to view it.",
|
||||
"Warning_Delete_Supermarket_Category": "Deleting a supermarket category will also delete all relations to foods. Are you sure?",
|
||||
"New_Supermarket": "Create new supermarket",
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
"Carbohydrates": "Carbohydratos",
|
||||
"Calories": "Calorias",
|
||||
"Energy": "Energia",
|
||||
"Nutrition": "Nutricion",
|
||||
"Nutrition": "Nutrición",
|
||||
"Date": "Fecha",
|
||||
"Share": "Compartir",
|
||||
"Automation": "Automatización",
|
||||
@@ -452,5 +452,6 @@
|
||||
"Auto_Sort": "Ordenar Automáticamente",
|
||||
"Auto_Sort_Help": "Mueva todos los ingredientes al paso que mejor se adapte.",
|
||||
"Unpin": "Desanclar",
|
||||
"Amount": "Cantidad"
|
||||
"Amount": "Cantidad",
|
||||
"PinnedConfirmation": "{recipe} ha sido fijada."
|
||||
}
|
||||
|
||||
@@ -68,83 +68,83 @@
|
||||
"Amount": "Mengde",
|
||||
"Enable_Amount": "Aktiver mengde",
|
||||
"Disable_Amount": "Deaktiver mengde",
|
||||
"Ingredient Editor": "",
|
||||
"Description_Replace": "",
|
||||
"Instruction_Replace": "",
|
||||
"Auto_Sort": "",
|
||||
"Auto_Sort_Help": "",
|
||||
"Private_Recipe": "",
|
||||
"Private_Recipe_Help": "",
|
||||
"reusable_help_text": "",
|
||||
"Add_Step": "",
|
||||
"Keywords": "",
|
||||
"Ingredient Editor": "Ingrediens Behandler",
|
||||
"Description_Replace": "Erstatt beskrivelse",
|
||||
"Instruction_Replace": "Erstatt instruksjoner",
|
||||
"Auto_Sort": "Sorter Automatisk",
|
||||
"Auto_Sort_Help": "Flytt alle ingredienser til det mest passende steget.",
|
||||
"Private_Recipe": "Privat Oppskrift",
|
||||
"Private_Recipe_Help": "Oppskriften er bare vist til deg og dem du har delt den med.",
|
||||
"reusable_help_text": "Burde invitasjonslenken være brukbar for flere enn én bruker.",
|
||||
"Add_Step": "Legg til steg",
|
||||
"Keywords": "Nøkkelord",
|
||||
"Books": "Bøker",
|
||||
"Proteins": "",
|
||||
"Fats": "",
|
||||
"Proteins": "Protein",
|
||||
"Fats": "Fett",
|
||||
"Carbohydrates": "Karbohydrater",
|
||||
"Calories": "",
|
||||
"Energy": "",
|
||||
"Nutrition": "",
|
||||
"Date": "",
|
||||
"Share": "",
|
||||
"Automation": "",
|
||||
"Parameter": "",
|
||||
"Export": "",
|
||||
"Copy": "",
|
||||
"Rating": "Karakter",
|
||||
"Calories": "Kalorier",
|
||||
"Energy": "Energi",
|
||||
"Nutrition": "Næring",
|
||||
"Date": "Dato",
|
||||
"Share": "Del",
|
||||
"Automation": "Automatiser",
|
||||
"Parameter": "Parameter",
|
||||
"Export": "Eksporter",
|
||||
"Copy": "Kopier",
|
||||
"Rating": "Vurdering",
|
||||
"Close": "Lukk",
|
||||
"Cancel": "",
|
||||
"Cancel": "Avbryt",
|
||||
"Link": "Lenke",
|
||||
"Add": "",
|
||||
"New": "",
|
||||
"Note": "",
|
||||
"Success": "",
|
||||
"Failure": "",
|
||||
"Protected": "",
|
||||
"Add": "Legg til",
|
||||
"New": "Ny",
|
||||
"Note": "Merk",
|
||||
"Success": "Vellykket",
|
||||
"Failure": "Feil",
|
||||
"Protected": "Beskyttet",
|
||||
"Ingredients": "Ingredienser",
|
||||
"Supermarket": "Butikk",
|
||||
"Categories": "",
|
||||
"Category": "",
|
||||
"Selected": "",
|
||||
"min": "",
|
||||
"Servings": "",
|
||||
"Waiting": "",
|
||||
"Preparation": "",
|
||||
"External": "",
|
||||
"Size": "",
|
||||
"Files": "",
|
||||
"File": "",
|
||||
"Edit": "",
|
||||
"Image": "",
|
||||
"Delete": "",
|
||||
"Open": "",
|
||||
"Ok": "",
|
||||
"Save": "",
|
||||
"Step": "",
|
||||
"Search": "",
|
||||
"Import": "",
|
||||
"Print": "",
|
||||
"Categories": "Kategorier",
|
||||
"Category": "Kategori",
|
||||
"Selected": "Valgte",
|
||||
"min": "min",
|
||||
"Servings": "Porsjoner",
|
||||
"Waiting": "Venter",
|
||||
"Preparation": "Forberedelse",
|
||||
"External": "Ekstern",
|
||||
"Size": "Størrelse",
|
||||
"Files": "Filer",
|
||||
"File": "Fil",
|
||||
"Edit": "Rediger",
|
||||
"Image": "Bilde",
|
||||
"Delete": "Slett",
|
||||
"Open": "Åpne",
|
||||
"Ok": "Ok",
|
||||
"Save": "Lagre",
|
||||
"Step": "Steg",
|
||||
"Search": "Søk",
|
||||
"Import": "Importer",
|
||||
"Print": "Skriv ut",
|
||||
"Settings": "Innstillinger",
|
||||
"or": "",
|
||||
"and": "",
|
||||
"Information": "",
|
||||
"Download": "",
|
||||
"or": "eller",
|
||||
"and": "og",
|
||||
"Information": "Informasjon",
|
||||
"Download": "Last ned",
|
||||
"Create": "Opprett",
|
||||
"Search Settings": "",
|
||||
"View": "",
|
||||
"Recipes": "",
|
||||
"Move": "",
|
||||
"Merge": "",
|
||||
"Parent": "",
|
||||
"Copy Link": "",
|
||||
"Copy Token": "",
|
||||
"delete_confirmation": "",
|
||||
"move_confirmation": "",
|
||||
"merge_confirmation": "",
|
||||
"create_rule": "",
|
||||
"move_selection": "",
|
||||
"merge_selection": "",
|
||||
"Root": "",
|
||||
"Search Settings": "Søk Instillinger",
|
||||
"View": "Visning",
|
||||
"Recipes": "Oppskrift",
|
||||
"Move": "Flytt",
|
||||
"Merge": "Slå sammen",
|
||||
"Parent": "Forelder",
|
||||
"Copy Link": "Kopier lenke",
|
||||
"Copy Token": "Kopier Token",
|
||||
"delete_confirmation": "Er du sikker på at du vill slette {source}?",
|
||||
"move_confirmation": "Flytt<i>{child}</i> til forelder <i>{parent}</i>",
|
||||
"merge_confirmation": "Erstatt<i>{source}</i> med <i>{target}</i>",
|
||||
"create_rule": "og opprett automasjon",
|
||||
"move_selection": "Velg en forelder {type} å flytte {source} til.",
|
||||
"merge_selection": "Erstatt alle tilfeller av {source} med den valgte {type}.",
|
||||
"Root": "Rot",
|
||||
"Ignore_Shopping": "",
|
||||
"Shopping_Category": "",
|
||||
"Shopping_Categories": "",
|
||||
@@ -284,33 +284,33 @@
|
||||
"Hide_Keyword": "",
|
||||
"Hour": "",
|
||||
"Hours": "",
|
||||
"Day": "",
|
||||
"Days": "",
|
||||
"Day": "Dag",
|
||||
"Days": "Dager",
|
||||
"Second": "",
|
||||
"Seconds": "",
|
||||
"Clear": "",
|
||||
"Users": "",
|
||||
"Invites": "",
|
||||
"err_move_self": "",
|
||||
"nothing": "",
|
||||
"err_merge_self": "",
|
||||
"show_sql": "",
|
||||
"filter_to_supermarket_desc": "",
|
||||
"CategoryName": "",
|
||||
"SupermarketName": "",
|
||||
"CategoryInstruction": "",
|
||||
"nothing": "Ingenting å gjøre",
|
||||
"err_merge_self": "Kan ikke slå sammen linje med seg selv",
|
||||
"show_sql": "Vis SQL",
|
||||
"filter_to_supermarket_desc": "Som standard, filtrerer handlelisten til å kun inkludere kategorier for den valgte butikken.",
|
||||
"CategoryName": "Kategori navn",
|
||||
"SupermarketName": "Butikk Navn",
|
||||
"CategoryInstruction": "Dra kategorier for å endre på rekkefølgen de vises i handlelisten.",
|
||||
"shopping_recent_days_desc": "",
|
||||
"shopping_recent_days": "",
|
||||
"download_pdf": "",
|
||||
"download_csv": "",
|
||||
"download_pdf": "Last ned PDF",
|
||||
"download_csv": "Last ned CSV",
|
||||
"csv_delim_help": "",
|
||||
"csv_delim_label": "",
|
||||
"SuccessClipboard": "",
|
||||
"copy_to_clipboard": "",
|
||||
"csv_prefix_help": "",
|
||||
"csv_prefix_label": "",
|
||||
"copy_markdown_table": "",
|
||||
"in_shopping": "",
|
||||
"copy_to_clipboard": "Kopier til utklippstavle",
|
||||
"csv_prefix_help": "Prefiks for å legge til når du kopierer listen til utklippstavlen.",
|
||||
"csv_prefix_label": "Liste prefiks",
|
||||
"copy_markdown_table": "Kopier som Markdown tabell",
|
||||
"in_shopping": "I handleliste",
|
||||
"DelayUntil": "",
|
||||
"Pin": "",
|
||||
"Unpin": "",
|
||||
@@ -332,37 +332,37 @@
|
||||
"food_recipe_help": "",
|
||||
"Foods": "",
|
||||
"Account": "",
|
||||
"Cosmetic": "",
|
||||
"API": "",
|
||||
"enable_expert": "",
|
||||
"expert_mode": "",
|
||||
"simple_mode": "",
|
||||
"advanced": "",
|
||||
"fields": "",
|
||||
"show_keywords": "",
|
||||
"show_foods": "",
|
||||
"show_books": "",
|
||||
"show_rating": "",
|
||||
"show_units": "",
|
||||
"show_filters": "",
|
||||
"not": "",
|
||||
"save_filter": "",
|
||||
"filter_name": "",
|
||||
"left_handed": "",
|
||||
"left_handed_help": "",
|
||||
"Custom Filter": "",
|
||||
"shared_with": "",
|
||||
"sort_by": "",
|
||||
"asc": "",
|
||||
"desc": "",
|
||||
"date_viewed": "",
|
||||
"last_cooked": "",
|
||||
"times_cooked": "",
|
||||
"date_created": "",
|
||||
"show_sortby": "",
|
||||
"search_rank": "",
|
||||
"make_now": "",
|
||||
"recipe_filter": "",
|
||||
"Cosmetic": "Kosmetisk",
|
||||
"API": "API",
|
||||
"enable_expert": "Aktiver Ekspert Modus",
|
||||
"expert_mode": "Ekspert Modus",
|
||||
"simple_mode": "Enkel Modus",
|
||||
"advanced": "Avansert",
|
||||
"fields": "Felt",
|
||||
"show_keywords": "Vis Nøkkelord",
|
||||
"show_foods": "Vis Mat",
|
||||
"show_books": "Vis bøker",
|
||||
"show_rating": "Vis vurdering",
|
||||
"show_units": "Vis enheter",
|
||||
"show_filters": "Vis filtre",
|
||||
"not": "ikke",
|
||||
"save_filter": "Lagre filtre",
|
||||
"filter_name": "Filtrer Navn",
|
||||
"left_handed": "Venstrehendt Modus",
|
||||
"left_handed_help": "Vil optimalisere bukergrensesnittet for bruk med venstre hånden.",
|
||||
"Custom Filter": "Egendefinert Filter",
|
||||
"shared_with": "Delt med",
|
||||
"sort_by": "Sorter etter",
|
||||
"asc": "Stigende",
|
||||
"desc": "Fallende",
|
||||
"date_viewed": "Sist sett",
|
||||
"last_cooked": "Sist tilberedt",
|
||||
"times_cooked": "Antall ganger tilberedt",
|
||||
"date_created": "Dato laget",
|
||||
"show_sortby": "Vis sorter etter",
|
||||
"search_rank": "Søk etter vurdering",
|
||||
"make_now": "Lag nå",
|
||||
"recipe_filter": "Oppskrift filter",
|
||||
"book_filter_help": "",
|
||||
"review_shopping": "",
|
||||
"view_recipe": "",
|
||||
@@ -373,9 +373,9 @@
|
||||
"ingredient_list": "",
|
||||
"explain": "",
|
||||
"filter": "",
|
||||
"Website": "",
|
||||
"App": "",
|
||||
"Message": "",
|
||||
"Website": "Nettside",
|
||||
"App": "App",
|
||||
"Message": "Melding",
|
||||
"Bookmarklet": "",
|
||||
"Sticky_Nav": "",
|
||||
"Sticky_Nav_Help": "",
|
||||
@@ -420,11 +420,11 @@
|
||||
"Quick actions": "",
|
||||
"Ratings": "",
|
||||
"Internal": "",
|
||||
"Units": "",
|
||||
"Manage_Emails": "",
|
||||
"Change_Password": "",
|
||||
"Units": "Enhet",
|
||||
"Manage_Emails": "Administrer e-poster",
|
||||
"Change_Password": "Endre passord",
|
||||
"Social_Authentication": "",
|
||||
"Random Recipes": "",
|
||||
"Random Recipes": "Tilfeldige oppskrifter",
|
||||
"parameter_count": "",
|
||||
"select_keyword": "",
|
||||
"add_keyword": "",
|
||||
@@ -435,13 +435,13 @@
|
||||
"remove_selection": "",
|
||||
"empty_list": "",
|
||||
"Select": "Velg",
|
||||
"Supermarkets": "",
|
||||
"User": "",
|
||||
"Username": "",
|
||||
"First_name": "",
|
||||
"Last_name": "",
|
||||
"Supermarkets": "Butikker",
|
||||
"User": "Bruker",
|
||||
"Username": "Brukernavn",
|
||||
"First_name": "Fornavn",
|
||||
"Last_name": "Etternavn",
|
||||
"Keyword": "Nøkkelord",
|
||||
"Advanced": "",
|
||||
"Advanced": "Avansert",
|
||||
"Page": "",
|
||||
"Single": "",
|
||||
"Multiple": "",
|
||||
@@ -478,5 +478,19 @@
|
||||
"Use_Plural_Food_Simple": "",
|
||||
"plural_usage_info": "",
|
||||
"Create Recipe": "",
|
||||
"Import Recipe": ""
|
||||
"Import Recipe": "",
|
||||
"per_serving": "Per porsjon",
|
||||
"open_data_help_text": "Tandoor Open Data prosjektet gir fra fellesskapet til Tandoor. Dette feltet fylles ut automatisk når det importeres og tillater oppdateringer i fremtiden.",
|
||||
"Open_Data_Slug": "Åpne data Slug",
|
||||
"Open_Data_Import": "Åpne Data Import",
|
||||
"recipe_property_info": "Du kan også legge til egenskaper til mat for å kalkulere dem automatisk basert på oppskriften!",
|
||||
"Update_Existing_Data": "Oppdater eksisterende data",
|
||||
"Use_Metric": "Bruk metriske enheter",
|
||||
"Learn_More": "Lær mer",
|
||||
"converted_unit": "Konverter enhet",
|
||||
"converted_amount": "Konverter mengde",
|
||||
"base_unit": "Baseenhet",
|
||||
"base_amount": "Basemengde",
|
||||
"Datatype": "Data-type",
|
||||
"Number of Objects": "Antall objekter"
|
||||
}
|
||||
|
||||
@@ -480,5 +480,24 @@
|
||||
"Description_Replace": "Zmień opis",
|
||||
"Instruction_Replace": "Zmień instrukcję",
|
||||
"Import Recipe": "Importuj przepis",
|
||||
"Create Recipe": "Utwórz przepis"
|
||||
"Create Recipe": "Utwórz przepis",
|
||||
"recipe_property_info": "Możesz także dodawać właściwości do żywności, aby przeliczać ją automatycznie na podstawie Twojego przepisu!",
|
||||
"per_serving": "na porcje",
|
||||
"open_data_help_text": "Projekt Tandoor Open Data dostarcza danych przesłanych przez społeczność dla Tandoor. To pole jest wypełniane automatycznie podczas importu i umożliwia aktualizacje w przyszłości.",
|
||||
"Open_Data_Slug": "Open Data Slug",
|
||||
"Open_Data_Import": "Open Data Import",
|
||||
"Data_Import_Info": "Wzbogać swoją Przestrzeń, importując wyselekcjonowaną przez społeczność listę żywności, jednostek i nie tylko, aby ulepszyć swoją kolekcję przepisów.",
|
||||
"Update_Existing_Data": "Zaktualizuj istniejące dane",
|
||||
"Use_Metric": "Użyj jednostek metrycznych",
|
||||
"Learn_More": "Dowiedz się więcej",
|
||||
"converted_unit": "Przeliczona jednostka",
|
||||
"converted_amount": "Przeliczona ilość",
|
||||
"base_unit": "Jednostka podstawowa",
|
||||
"base_amount": "Ilość bazowa",
|
||||
"Datatype": "Typ danych",
|
||||
"Number of Objects": "Ilość obiektów",
|
||||
"Property": "Właściwość",
|
||||
"Conversion": "Konwersja",
|
||||
"Properties": "Właściwości",
|
||||
"total": "łącznie"
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"warning_feature_beta": "Данный функционал находится в сдадии BETA (тестируется). Возможны баги и серьезные изменения функционала в будующем.",
|
||||
"warning_feature_beta": "Данный функционал находится в стадии BETA (тестируется). Возможны баги и серьезные изменения функционала в будущем.",
|
||||
"err_fetching_resource": "Ошибка при загрузке продукта!",
|
||||
"err_creating_resource": "Ошибка при создании продукта!",
|
||||
"err_updating_resource": "Ошибка при редактировании продукта!",
|
||||
"err_deleting_resource": "Ошибка при удалении продукта!",
|
||||
"success_fetching_resource": "Продукт успешно загружен!",
|
||||
"success_creating_resource": "Продукт успешно загружен!",
|
||||
"success_creating_resource": "Продукт успешно создан!",
|
||||
"success_updating_resource": "Продукт успешно обновлен!",
|
||||
"success_deleting_resource": "Продукт успешно удален!",
|
||||
"file_upload_disabled": "Выгрузка файла не активирована в настройках.",
|
||||
@@ -13,7 +13,7 @@
|
||||
"confirm_delete": "Вы уверены, что хотите удалить этот объект?",
|
||||
"import_running": "Идет загрузка, пожалуйста ждите!",
|
||||
"all_fields_optional": "Все поля не обязательны для заполнения.",
|
||||
"convert_internal": "Конвретировать рецепт во внутренний формат",
|
||||
"convert_internal": "Конвертировать рецепт во внутренний формат",
|
||||
"show_only_internal": "Показывать только рецепты во внутреннем формате",
|
||||
"show_split_screen": "Двухколоночный вид",
|
||||
"Log_Recipe_Cooking": "Журнал приготовления",
|
||||
@@ -211,13 +211,13 @@
|
||||
"FoodNotOnHand": "{food} отсутствует в наличии.",
|
||||
"Undefined": "Неизвестно",
|
||||
"AddFoodToShopping": "Добавить {food} в ваш список покупок",
|
||||
"success_moving_resource": "Успешное перемещение ресурса!",
|
||||
"success_merging_resource": "Ресурс успешно присоединен!",
|
||||
"success_moving_resource": "Успешное перемещение продукта!",
|
||||
"success_merging_resource": "Продукт успешно присоединен!",
|
||||
"Shopping_Categories": "Категории покупок",
|
||||
"Search Settings": "Искать настройки",
|
||||
"err_merging_resource": "Произошла ошибка при перемещении ресурса!",
|
||||
"err_merging_resource": "Произошла ошибка при перемещении продукта!",
|
||||
"Remove_nutrition_recipe": "Уберите питательные вещества из рецепта",
|
||||
"err_moving_resource": "Произошла ошибка при перемещении ресурса!",
|
||||
"err_moving_resource": "Произошла ошибка при перемещении продукта!",
|
||||
"NotInShopping": "{food} отсутствует в вашем списке покупок.",
|
||||
"RemoveFoodFromShopping": "Удалить {food} из вашего списка покупок",
|
||||
"ShowDelayed": "Показать отложенные элементы",
|
||||
@@ -345,6 +345,6 @@
|
||||
"GroupBy": "Сгруппировать по",
|
||||
"facet_count_info": "Показывать количество рецептов в фильтрах поиска.",
|
||||
"food_inherit_info": "Поля для продуктов питания, которые должны наследоваться по умолчанию.",
|
||||
"warning_space_delete": "Вы можете удалить свое пространство, включая все рецепты, списки покупок, планы питания и все остальное, что вы создали. Этого нельзя отменить! Ты уверен, что хочешь это сделать?",
|
||||
"warning_space_delete": "Вы можете удалить свое пространство, включая все рецепты, списки покупок, планы питания и все остальное, что вы создали. Этого нельзя отменить! Вы уверены, что хотите это сделать?",
|
||||
"Description_Replace": "Изменить описание"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
3146
vue/yarn.lock
3146
vue/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user