mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2025-12-27 04:00:48 -05:00
Compare commits
121 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
de29b44c0d | ||
|
|
dc4ca81270 | ||
|
|
dd3dc0a058 | ||
|
|
30c6389382 | ||
|
|
45effbbcde | ||
|
|
ffa06ca75e | ||
|
|
903a4c93eb | ||
|
|
a8ae6c86e2 | ||
|
|
976445c1f0 | ||
|
|
9cf1141794 | ||
|
|
b095bee229 | ||
|
|
3c3ecc5342 | ||
|
|
8b50b99977 | ||
|
|
f369b74c94 | ||
|
|
7b11f276a8 | ||
|
|
fe35173ab5 | ||
|
|
4bd879c787 | ||
|
|
fcbc5ed5d0 | ||
|
|
2bdc541183 | ||
|
|
4b08eea39d | ||
|
|
c777cfe5b9 | ||
|
|
e860d0aa83 | ||
|
|
0539e1ea15 | ||
|
|
6030fa1d68 | ||
|
|
2a5cba0178 | ||
|
|
9a77089c6d | ||
|
|
5f79895a97 | ||
|
|
19f5da77b2 | ||
|
|
2cc7278865 | ||
|
|
60f31608b9 | ||
|
|
763f71a05c | ||
|
|
e3921cd6a8 | ||
|
|
54a5c145cc | ||
|
|
86fd0dcab1 | ||
|
|
12da77f037 | ||
|
|
071926aada | ||
|
|
33d048e623 | ||
|
|
274fce5236 | ||
|
|
1046065f46 | ||
|
|
60243ad901 | ||
|
|
d62c49eb2f | ||
|
|
7e3313f48c | ||
|
|
1bb6eb7141 | ||
|
|
89e3e85d1e | ||
|
|
dfde340447 | ||
|
|
44771bde71 | ||
|
|
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 |
@@ -3,6 +3,9 @@
|
||||
DEBUG=0
|
||||
SQL_DEBUG=0
|
||||
DEBUG_TOOLBAR=0
|
||||
# Gunicorn log level for debugging (default value is "info" when unset)
|
||||
# (see https://docs.gunicorn.org/en/stable/settings.html#loglevel for available settings)
|
||||
# GUNICORN_LOG_LEVEL="debug"
|
||||
|
||||
# HTTP port to bind to
|
||||
# TANDOOR_PORT=8080
|
||||
|
||||
120
.github/workflows/build-docker-open-data.yml
vendored
Normal file
120
.github/workflows/build-docker-open-data.yml
vendored
Normal file
@@ -0,0 +1,120 @@
|
||||
name: Build Docker Container with open data plugin installed
|
||||
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
build-container:
|
||||
name: Build ${{ matrix.name }} Container
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository_owner == 'TandoorRecipes'
|
||||
continue-on-error: ${{ matrix.continue-on-error }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
# Standard build config
|
||||
- name: Standard
|
||||
dockerfile: Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
suffix: ""
|
||||
continue-on-error: false
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Get version number
|
||||
id: get_version
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" = refs/tags/* ]]; then
|
||||
echo "VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT
|
||||
elif [[ "$GITHUB_REF" = refs/heads/beta ]]; then
|
||||
echo VERSION=beta >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo VERSION=develop >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Update Version number
|
||||
- name: Update version file
|
||||
uses: DamianReeves/write-file-action@v1.2
|
||||
with:
|
||||
path: recipes/version.py
|
||||
contents: |
|
||||
VERSION_NUMBER = '${{ steps.get_version.outputs.VERSION }}-open-data'
|
||||
BUILD_REF = '${{ github.sha }}'
|
||||
write-mode: overwrite
|
||||
|
||||
# clone open data plugin
|
||||
- name: clone open data plugin repo
|
||||
uses: actions/checkout@master
|
||||
with:
|
||||
repository: TandoorRecipes/open_data_plugin
|
||||
ref: master
|
||||
path: ./recipes/plugins/open_data_plugin
|
||||
|
||||
# Build Vue frontend
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14'
|
||||
cache: yarn
|
||||
cache-dependency-path: vue/yarn.lock
|
||||
- name: Install dependencies
|
||||
working-directory: ./vue
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build dependencies
|
||||
working-directory: ./vue
|
||||
run: yarn build
|
||||
|
||||
- name: Setup Open Data Plugin Links
|
||||
working-directory: ./recipes/plugins/open_data_plugin
|
||||
run: python setup_repo.py
|
||||
|
||||
- name: Build Open Data Frontend
|
||||
working-directory: ./recipes/plugins/open_data_plugin/vue
|
||||
run: yarn build
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
if: github.secret_source == 'Actions'
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
if: github.secret_source == 'Actions'
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
vabene1111/recipes
|
||||
ghcr.io/TandoorRecipes/recipes
|
||||
flavor: |
|
||||
latest=false
|
||||
suffix=${{ matrix.suffix }}
|
||||
tags: |
|
||||
type=raw,value=latest,suffix=-open-data-plugin,enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
type=semver,suffix=-open-data-plugin,pattern={{version}}
|
||||
type=semver,suffix=-open-data-plugin,pattern={{major}}.{{minor}}
|
||||
type=semver,suffix=-open-data-plugin,pattern={{major}}
|
||||
type=ref,suffix=-open-data-plugin,event=branch
|
||||
- name: Build and Push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
pull: true
|
||||
push: ${{ github.secret_source == 'Actions' }}
|
||||
platforms: ${{ matrix.platforms }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
3
boot.sh
3
boot.sh
@@ -4,6 +4,7 @@ source venv/bin/activate
|
||||
TANDOOR_PORT="${TANDOOR_PORT:-8080}"
|
||||
GUNICORN_WORKERS="${GUNICORN_WORKERS:-3}"
|
||||
GUNICORN_THREADS="${GUNICORN_THREADS:-2}"
|
||||
GUNICORN_LOG_LEVEL="${GUNICORN_LOG_LEVEL:-'info'}"
|
||||
NGINX_CONF_FILE=/opt/recipes/nginx/conf.d/Recipes.conf
|
||||
|
||||
display_warning() {
|
||||
@@ -65,4 +66,4 @@ echo "Done"
|
||||
|
||||
chmod -R 755 /opt/recipes/mediafiles
|
||||
|
||||
exec gunicorn -b :$TANDOOR_PORT --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --access-logfile - --error-logfile - --log-level INFO recipes.wsgi
|
||||
exec gunicorn -b :$TANDOOR_PORT --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --access-logfile - --error-logfile - --log-level $GUNICORN_LOG_LEVEL recipes.wsgi
|
||||
|
||||
@@ -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)
|
||||
|
||||
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)
|
||||
@@ -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:
|
||||
|
||||
Binary file not shown.
@@ -8,8 +8,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2022-07-12 19:20+0200\n"
|
||||
"PO-Revision-Date: 2022-10-17 11:33+0000\n"
|
||||
"Last-Translator: Sokratis Potamias <sokratespot@gmail.com>\n"
|
||||
"PO-Revision-Date: 2023-05-31 17:19+0000\n"
|
||||
"Last-Translator: sweeney <sweeneytodd91@protonmail.com>\n"
|
||||
"Language-Team: Greek <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/el/>\n"
|
||||
"Language: el\n"
|
||||
@@ -17,7 +17,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Generator: Weblate 4.10.1\n"
|
||||
"X-Generator: Weblate 4.15\n"
|
||||
|
||||
#: .\cookbook\filters.py:23 .\cookbook\templates\forms\ingredients.html:34
|
||||
#: .\cookbook\templates\stats.html:28
|
||||
@@ -26,7 +26,7 @@ msgstr "Συστατικά"
|
||||
|
||||
#: .\cookbook\forms.py:53
|
||||
msgid "Default unit"
|
||||
msgstr ""
|
||||
msgstr "Προεπιλεγμένη μονάδα μέτρησης"
|
||||
|
||||
#: .\cookbook\forms.py:54
|
||||
msgid "Use fractions"
|
||||
@@ -34,7 +34,7 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\forms.py:55
|
||||
msgid "Use KJ"
|
||||
msgstr ""
|
||||
msgstr "Χρήση KiloJoule(KJ)"
|
||||
|
||||
#: .\cookbook\forms.py:56
|
||||
msgid "Theme"
|
||||
@@ -42,7 +42,7 @@ msgstr "Θέμα"
|
||||
|
||||
#: .\cookbook\forms.py:57
|
||||
msgid "Navbar color"
|
||||
msgstr ""
|
||||
msgstr "Χρώμα μπάρας πλοήγησης"
|
||||
|
||||
#: .\cookbook\forms.py:58
|
||||
msgid "Sticky navbar"
|
||||
@@ -50,19 +50,19 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\forms.py:59
|
||||
msgid "Default page"
|
||||
msgstr ""
|
||||
msgstr "Προεπιλεγμένη σελίδα"
|
||||
|
||||
#: .\cookbook\forms.py:60
|
||||
msgid "Show recent recipes"
|
||||
msgstr ""
|
||||
msgstr "Προβολή πρόσφατων συνταγών"
|
||||
|
||||
#: .\cookbook\forms.py:61
|
||||
msgid "Search style"
|
||||
msgstr ""
|
||||
msgstr "Τρόπος αναζήτησης"
|
||||
|
||||
#: .\cookbook\forms.py:62
|
||||
msgid "Plan sharing"
|
||||
msgstr ""
|
||||
msgstr "Κοινοποίηση προγράμματος"
|
||||
|
||||
#: .\cookbook\forms.py:63
|
||||
msgid "Ingredient decimal places"
|
||||
@@ -70,7 +70,7 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\forms.py:64
|
||||
msgid "Shopping list auto sync period"
|
||||
msgstr ""
|
||||
msgstr "Χρονική περίοδος αυτόματου συγχρονισμού λίστας αγορών"
|
||||
|
||||
#: .\cookbook\forms.py:65 .\cookbook\templates\recipe_view.html:21
|
||||
#: .\cookbook\templates\stats.html:47
|
||||
@@ -79,17 +79,21 @@ msgstr "Σχόλια"
|
||||
|
||||
#: .\cookbook\forms.py:66
|
||||
msgid "Left-handed mode"
|
||||
msgstr ""
|
||||
msgstr "Έκδοση για αριστερόχειρες"
|
||||
|
||||
#: .\cookbook\forms.py:70
|
||||
msgid ""
|
||||
"Color of the top navigation bar. Not all colors work with all themes, just "
|
||||
"try them out!"
|
||||
msgstr ""
|
||||
"Χρώμα της πάνω μπάρας πλοήγησης. Δεν δουλεύουν όλα τα χρώματα με όλα τα "
|
||||
"θέματα, απλά δοκιμάστε τα!"
|
||||
|
||||
#: .\cookbook\forms.py:72
|
||||
msgid "Default Unit to be used when inserting a new ingredient into a recipe."
|
||||
msgstr ""
|
||||
"Προεπιλεγμένη μονάδα μέτρησης που θα χρησιμοποιείται όταν προστίθεται ένα "
|
||||
"υλικό σε μια συνταγή."
|
||||
|
||||
#: .\cookbook\forms.py:74
|
||||
msgid ""
|
||||
@@ -104,18 +108,20 @@ msgstr ""
|
||||
#: .\cookbook\forms.py:77
|
||||
msgid "Users with whom newly created meal plans should be shared by default."
|
||||
msgstr ""
|
||||
"Χρήστες με του οποίους η κοινοποίηση του προγραμματισμού των γευμάτων θα "
|
||||
"γίνεται από προεπιλογή."
|
||||
|
||||
#: .\cookbook\forms.py:78
|
||||
msgid "Users with whom to share shopping lists."
|
||||
msgstr ""
|
||||
msgstr "Χρήστες με του οποίους θα γίνει κοινοποίηση των λιστών αγορών."
|
||||
|
||||
#: .\cookbook\forms.py:80
|
||||
msgid "Show recently viewed recipes on search page."
|
||||
msgstr ""
|
||||
msgstr "Προβολή των προσφάτως προβεβλημένων συνταγών στη σελίδα αναζήτησης."
|
||||
|
||||
#: .\cookbook\forms.py:81
|
||||
msgid "Number of decimals to round ingredients."
|
||||
msgstr ""
|
||||
msgstr "Αριθμός των δεκαδικών στα οποία θα γίνεται στρογγυλοποίηση."
|
||||
|
||||
#: .\cookbook\forms.py:82
|
||||
msgid "If you want to be able to create and see comments underneath recipes."
|
||||
@@ -153,20 +159,20 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\forms.py:128 .\cookbook\forms.py:301
|
||||
msgid "Name"
|
||||
msgstr ""
|
||||
msgstr "Όνομα"
|
||||
|
||||
#: .\cookbook\forms.py:129 .\cookbook\forms.py:302
|
||||
#: .\cookbook\templates\stats.html:24 .\cookbook\views\lists.py:88
|
||||
msgid "Keywords"
|
||||
msgstr ""
|
||||
msgstr "Λέξεις κλειδιά"
|
||||
|
||||
#: .\cookbook\forms.py:130
|
||||
msgid "Preparation time in minutes"
|
||||
msgstr ""
|
||||
msgstr "Χρόνος προετοιμασίας σε λεπτά"
|
||||
|
||||
#: .\cookbook\forms.py:131
|
||||
msgid "Waiting time (cooking/baking) in minutes"
|
||||
msgstr ""
|
||||
msgstr "Χρόνος αναμονής (μαγείρεμα/ ψήσιμο) σε λεπτά"
|
||||
|
||||
#: .\cookbook\forms.py:132 .\cookbook\forms.py:270 .\cookbook\forms.py:303
|
||||
msgid "Path"
|
||||
@@ -188,15 +194,18 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\forms.py:200
|
||||
msgid "Add your comment: "
|
||||
msgstr ""
|
||||
msgstr "Προσθήκη σχολίου: "
|
||||
|
||||
#: .\cookbook\forms.py:215
|
||||
msgid "Leave empty for dropbox and enter app password for nextcloud."
|
||||
msgstr ""
|
||||
"Για dropbox παρακαλώ αφήστε το κενό και πληκτρολογήστε το password για "
|
||||
"nextcloud."
|
||||
|
||||
#: .\cookbook\forms.py:222
|
||||
msgid "Leave empty for nextcloud and enter api token for dropbox."
|
||||
msgstr ""
|
||||
"Για nextcloud αφήστε το κενό και για dropbox πληκτρολογήστε το api token."
|
||||
|
||||
#: .\cookbook\forms.py:231
|
||||
msgid ""
|
||||
@@ -240,7 +249,7 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\forms.py:372
|
||||
msgid "Email address already taken!"
|
||||
msgstr ""
|
||||
msgstr "Αυτή η διεύθυνση email δεν είναι διαθέσιμη!"
|
||||
|
||||
#: .\cookbook\forms.py:380
|
||||
msgid ""
|
||||
@@ -306,7 +315,7 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\forms.py:463
|
||||
msgid "Search Method"
|
||||
msgstr ""
|
||||
msgstr "Μέθοδος αναζήτησης"
|
||||
|
||||
#: .\cookbook\forms.py:464
|
||||
msgid "Fuzzy Lookups"
|
||||
@@ -330,19 +339,24 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\forms.py:469
|
||||
msgid "Full Text"
|
||||
msgstr ""
|
||||
msgstr "Πλήρες κείμενο"
|
||||
|
||||
#: .\cookbook\forms.py:494
|
||||
msgid ""
|
||||
"Users will see all items you add to your shopping list. They must add you "
|
||||
"to see items on their list."
|
||||
msgstr ""
|
||||
"Οι χρήστες θα μπορούν να δουν όλα τα αντικείμενα που προστίθενται στην λίστα "
|
||||
"αγορών σας. Για να δείτε τα αντικείμενα στις λίστα αυτών θα πρέπει να σας "
|
||||
"προστέσουν."
|
||||
|
||||
#: .\cookbook\forms.py:500
|
||||
msgid ""
|
||||
"When adding a meal plan to the shopping list (manually or automatically), "
|
||||
"include all related recipes."
|
||||
msgstr ""
|
||||
"Όταν προστίθεται ένα πρόγραμμα γευμάτων στη λίστα αγορών (χειροκίνητα ή "
|
||||
"αυτόματα), να συμπεριλαμβάνονται όλες οι σχετικές συνταγές."
|
||||
|
||||
#: .\cookbook\forms.py:501
|
||||
msgid ""
|
||||
@@ -353,18 +367,23 @@ msgstr ""
|
||||
#: .\cookbook\forms.py:502
|
||||
msgid "Default number of hours to delay a shopping list entry."
|
||||
msgstr ""
|
||||
"Προεπιλεγμένος αριθμός ωρών για την καθυστέρηση μιας εγγραφής στη λίστα "
|
||||
"αγορών."
|
||||
|
||||
#: .\cookbook\forms.py:503
|
||||
msgid "Filter shopping list to only include supermarket categories."
|
||||
msgstr ""
|
||||
"Φιλτράρισμα λίστας αγορών ώστε να περιλαμβάνει μόνο τις κατηγορίες του "
|
||||
"supermarker."
|
||||
|
||||
#: .\cookbook\forms.py:504
|
||||
msgid "Days of recent shopping list entries to display."
|
||||
msgstr ""
|
||||
msgstr "Αριθμός ημερών για τη προβολή των πρόσφατων εγγραφών της λίστας αγορών."
|
||||
|
||||
#: .\cookbook\forms.py:505
|
||||
msgid "Mark food 'On Hand' when checked off shopping list."
|
||||
msgstr ""
|
||||
"Χαρακτηρισμός ενός τροφίμου ως 'Διαθέσιμο' όταν τσεκαριστεί στη λίστα αγορών."
|
||||
|
||||
#: .\cookbook\forms.py:506
|
||||
msgid "Delimiter to use for CSV exports."
|
||||
@@ -376,27 +395,27 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\forms.py:511
|
||||
msgid "Share Shopping List"
|
||||
msgstr ""
|
||||
msgstr "Κοινοποίηση λίστας αγορών"
|
||||
|
||||
#: .\cookbook\forms.py:512
|
||||
msgid "Autosync"
|
||||
msgstr ""
|
||||
msgstr "Αυτόματος συγχρονισμός"
|
||||
|
||||
#: .\cookbook\forms.py:513
|
||||
msgid "Auto Add Meal Plan"
|
||||
msgstr ""
|
||||
msgstr "Αυτόματη προσθήκη προγραμματισμού γευμάτων"
|
||||
|
||||
#: .\cookbook\forms.py:514
|
||||
msgid "Exclude On Hand"
|
||||
msgstr ""
|
||||
msgstr "Αποκλεισμός διαθέσιμων"
|
||||
|
||||
#: .\cookbook\forms.py:515
|
||||
msgid "Include Related"
|
||||
msgstr ""
|
||||
msgstr "Συμπερίληψη σχετικών"
|
||||
|
||||
#: .\cookbook\forms.py:516
|
||||
msgid "Default Delay Hours"
|
||||
msgstr ""
|
||||
msgstr "Προεπιλεγμένες ώρες καθυστέρησης"
|
||||
|
||||
#: .\cookbook\forms.py:517
|
||||
msgid "Filter to Supermarket"
|
||||
@@ -404,7 +423,7 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\forms.py:518
|
||||
msgid "Recent Days"
|
||||
msgstr ""
|
||||
msgstr "Πρόσφατες ημέρες"
|
||||
|
||||
#: .\cookbook\forms.py:519
|
||||
msgid "CSV Delimiter"
|
||||
@@ -439,11 +458,13 @@ msgid ""
|
||||
"In order to prevent spam, the requested email was not send. Please wait a "
|
||||
"few minutes and try again."
|
||||
msgstr ""
|
||||
"Για να αποκλείσουμε πιθανά spam, το email που ζητήθηκε δεν στάλθηκε. "
|
||||
"Παρακαλώ περιμένετε λίγα λεπτά και δοκιμάστε ξανά."
|
||||
|
||||
#: .\cookbook\helper\permission_helper.py:149
|
||||
#: .\cookbook\helper\permission_helper.py:172 .\cookbook\views\views.py:152
|
||||
msgid "You are not logged in and therefore cannot view this page!"
|
||||
msgstr ""
|
||||
msgstr "Δεν μπορείτε να δείτε αυτή τη σελίδα γιατί δεν είστε συνδεδεμένος!"
|
||||
|
||||
#: .\cookbook\helper\permission_helper.py:153
|
||||
#: .\cookbook\helper\permission_helper.py:159
|
||||
@@ -455,7 +476,7 @@ msgstr ""
|
||||
#: .\cookbook\views\views.py:163 .\cookbook\views\views.py:170
|
||||
#: .\cookbook\views\views.py:249
|
||||
msgid "You do not have the required permissions to view this page!"
|
||||
msgstr ""
|
||||
msgstr "Δεν έχετε τα απαιτούμενα δικαιώματα να δείτε αυτή τη σελίδα!"
|
||||
|
||||
#: .\cookbook\helper\permission_helper.py:177
|
||||
#: .\cookbook\helper\permission_helper.py:200
|
||||
@@ -463,14 +484,15 @@ msgstr ""
|
||||
#: .\cookbook\helper\permission_helper.py:237
|
||||
msgid "You cannot interact with this object as it is not owned by you!"
|
||||
msgstr ""
|
||||
"Δεν μπορείτε να αλληλεπιδράστε με αυτό το αντικείμενο γιατί δεν σας ανήκει!"
|
||||
|
||||
#: .\cookbook\helper\permission_helper.py:321
|
||||
msgid "You have reached the maximum number of recipes for your space."
|
||||
msgstr ""
|
||||
msgstr "Έχετε υπερβεί τον μέγιστο αριθμό συνταγών για τον χώρο σας."
|
||||
|
||||
#: .\cookbook\helper\permission_helper.py:333
|
||||
msgid "You have more users than allowed in your space."
|
||||
msgstr ""
|
||||
msgstr "Έχετε περισσότερους χρήστες από το επιτρεπόμενο στον χώρο σας."
|
||||
|
||||
#: .\cookbook\helper\recipe_search.py:565
|
||||
msgid "One of queryset or hash_key must be provided"
|
||||
@@ -519,33 +541,33 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\integration\paprika.py:46
|
||||
msgid "Notes"
|
||||
msgstr ""
|
||||
msgstr "Σημειώσεις"
|
||||
|
||||
#: .\cookbook\integration\paprika.py:49
|
||||
msgid "Nutritional Information"
|
||||
msgstr ""
|
||||
msgstr "Διατροφικές πληροφορίες"
|
||||
|
||||
#: .\cookbook\integration\paprika.py:53
|
||||
msgid "Source"
|
||||
msgstr ""
|
||||
msgstr "Πηγή"
|
||||
|
||||
#: .\cookbook\integration\saffron.py:23
|
||||
msgid "Servings"
|
||||
msgstr ""
|
||||
msgstr "Μερίδες"
|
||||
|
||||
#: .\cookbook\integration\saffron.py:25
|
||||
msgid "Waiting time"
|
||||
msgstr ""
|
||||
msgstr "Χρόνος αναμονής"
|
||||
|
||||
#: .\cookbook\integration\saffron.py:27
|
||||
msgid "Preparation Time"
|
||||
msgstr ""
|
||||
msgstr "Χρόνος προετοιμασίας"
|
||||
|
||||
#: .\cookbook\integration\saffron.py:29
|
||||
#: .\cookbook\templates\forms\ingredients.html:7
|
||||
#: .\cookbook\templates\index.html:7
|
||||
msgid "Cookbook"
|
||||
msgstr ""
|
||||
msgstr "Βιβλίο συνταγών"
|
||||
|
||||
#: .\cookbook\integration\saffron.py:31
|
||||
msgid "Section"
|
||||
@@ -569,19 +591,19 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\migrations\0047_auto_20200602_1133.py:14
|
||||
msgid "Breakfast"
|
||||
msgstr ""
|
||||
msgstr "Πρωινό"
|
||||
|
||||
#: .\cookbook\migrations\0047_auto_20200602_1133.py:19
|
||||
msgid "Lunch"
|
||||
msgstr ""
|
||||
msgstr "Μεσημεριανό"
|
||||
|
||||
#: .\cookbook\migrations\0047_auto_20200602_1133.py:24
|
||||
msgid "Dinner"
|
||||
msgstr ""
|
||||
msgstr "Βραδινό"
|
||||
|
||||
#: .\cookbook\migrations\0047_auto_20200602_1133.py:29
|
||||
msgid "Other"
|
||||
msgstr ""
|
||||
msgstr "Άλλο"
|
||||
|
||||
#: .\cookbook\models.py:251
|
||||
msgid ""
|
||||
|
||||
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'),
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
@@ -454,6 +457,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 +467,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 +476,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 +486,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 +503,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 +544,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 +555,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 +582,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.IntegerField(default=100, blank=True)
|
||||
properties_food_unit = models.ForeignKey(Unit, on_delete=models.PROTECT, blank=True, null=True)
|
||||
|
||||
preferred_unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True, default=None, related_name='preferred_unit')
|
||||
preferred_shopping_unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True, default=None, related_name='preferred_shopping_unit')
|
||||
fdc_id = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||
|
||||
open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space', _manager_class=TreeManager)
|
||||
|
||||
@@ -642,7 +664,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 +673,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 +712,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 +767,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 +841,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 +863,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
|
||||
|
||||
@@ -102,15 +106,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 +286,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):
|
||||
@@ -440,7 +453,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,7 +465,7 @@ 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')
|
||||
|
||||
|
||||
@@ -484,7 +498,37 @@ class SupermarketSerializer(UniqueFieldsMixin, SpacedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Supermarket
|
||||
fields = ('id', 'name', 'description', 'category_to_supermarket')
|
||||
fields = ('id', 'name', 'description', 'category_to_supermarket', 'open_data_slug')
|
||||
|
||||
|
||||
class PropertyTypeSerializer(serializers.ModelSerializer):
|
||||
def create(self, validated_data):
|
||||
validated_data['space'] = self.context['request'].space
|
||||
|
||||
if property_type := PropertyType.objects.filter(Q(name=validated_data['name'])).first():
|
||||
return property_type
|
||||
|
||||
return super().create(validated_data)
|
||||
|
||||
class Meta:
|
||||
model = PropertyType
|
||||
fields = ('id', 'name', 'icon', 'unit', 'description', 'open_data_slug')
|
||||
|
||||
|
||||
class PropertySerializer(UniqueFieldsMixin, WritableNestedModelSerializer):
|
||||
property_type = PropertyTypeSerializer()
|
||||
property_amount = CustomDecimalField()
|
||||
|
||||
# TODO prevent updates
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['space'] = self.context['request'].space
|
||||
return super().create(validated_data)
|
||||
|
||||
class Meta:
|
||||
model = Property
|
||||
fields = ('id', 'property_amount', 'property_type')
|
||||
read_only_fields = ('id',)
|
||||
|
||||
|
||||
class RecipeSimpleSerializer(WritableNestedModelSerializer):
|
||||
@@ -523,19 +567,29 @@ 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)
|
||||
|
||||
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 +601,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 +633,11 @@ 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()
|
||||
|
||||
obj, created = Food.objects.get_or_create(name=name, plural_name=plural_name, space=space, properties_food_unit=properties_food_unit,
|
||||
defaults=validated_data)
|
||||
return obj
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
@@ -606,9 +664,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 +678,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 +708,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 +764,30 @@ class StepRecipeSerializer(WritableNestedModelSerializer):
|
||||
)
|
||||
|
||||
|
||||
class UnitConversionSerializer(WritableNestedModelSerializer):
|
||||
name = serializers.SerializerMethodField('get_conversion_name')
|
||||
base_unit = UnitSerializer()
|
||||
converted_unit = UnitSerializer()
|
||||
food = FoodSerializer(allow_null=True, required=False)
|
||||
base_amount = CustomDecimalField()
|
||||
converted_amount = CustomDecimalField()
|
||||
|
||||
def get_conversion_name(self, obj):
|
||||
text = f'{round(obj.base_amount)} {obj.base_unit} '
|
||||
if obj.food:
|
||||
text += f' {obj.food}'
|
||||
return text + f' = {round(obj.converted_amount)} {obj.converted_unit}'
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['space'] = self.context['request'].space
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
|
||||
class Meta:
|
||||
model = UnitConversion
|
||||
fields = ('id', 'name', 'base_amount', 'base_unit', 'converted_amount', 'converted_unit', 'food', 'open_data_slug')
|
||||
|
||||
|
||||
class NutritionInformationSerializer(serializers.ModelSerializer):
|
||||
carbohydrates = CustomDecimalField()
|
||||
fats = CustomDecimalField()
|
||||
@@ -738,21 +838,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 +1196,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 +1317,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,6 +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"/>
|
||||
|
||||
|
||||
<link rel="shortcut icon" type="image/x-icon" href="{% static 'assets/favicon.svg' %}">
|
||||
@@ -49,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>
|
||||
|
||||
@@ -269,6 +270,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>
|
||||
@@ -322,6 +350,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>
|
||||
@@ -348,6 +382,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">
|
||||
@@ -412,7 +447,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,14 @@ 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 bookmarklet(request):
|
||||
|
||||
@@ -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)
|
||||
@@ -6,17 +6,23 @@ from rest_framework import permissions, routers
|
||||
from rest_framework.schemas import get_schema_view
|
||||
|
||||
from cookbook.helper import dal
|
||||
from recipes.settings import DEBUG
|
||||
from recipes.settings import DEBUG, PLUGINS
|
||||
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
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
# extend DRF default router class to allow including additional routers
|
||||
class DefaultRouter(routers.DefaultRouter):
|
||||
def extend(self, r):
|
||||
self.registry.extend(r.registry)
|
||||
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'automation', api.AutomationViewSet)
|
||||
router.register(r'bookmarklet-import', api.BookmarkletImportViewSet)
|
||||
router.register(r'cook-log', api.CookLogViewSet)
|
||||
@@ -34,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)
|
||||
@@ -53,6 +62,13 @@ router.register(r'user-space', api.UserSpaceViewSet)
|
||||
router.register(r'view-log', api.ViewLogViewSet)
|
||||
router.register(r'access-token', api.AccessTokenViewSet)
|
||||
|
||||
for p in PLUGINS:
|
||||
if c := locate(f'{p["module"]}.urls.{p["api_router_name"]}'):
|
||||
try:
|
||||
router.extend(c)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.index, name='index'),
|
||||
path('setup/', views.setup, name='view_setup'),
|
||||
@@ -119,7 +135,6 @@ urlpatterns = [
|
||||
path('api/switch-active-space/<int:space_id>/', api.switch_active_space, name='api_switch_active_space'),
|
||||
path('api/download-file/<int:file_id>/', api.download_file, name='api_download_file'),
|
||||
|
||||
|
||||
path('dal/keyword/', dal.KeywordAutocomplete.as_view(), name='dal_keyword'),
|
||||
# TODO is this deprecated? not yet, some old forms remain, could likely be changed to generic API endpoints
|
||||
path('dal/food/', dal.IngredientsAutocomplete.as_view(), name='dal_food'), # TODO is this deprecated?
|
||||
@@ -139,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'),
|
||||
|
||||
@@ -189,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.through.objects.all().delete()
|
||||
|
||||
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}))
|
||||
@@ -1323,11 +1420,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.
|
||||
|
||||
@@ -40,6 +40,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
|
||||
@@ -80,6 +81,8 @@ DJANGO_TABLES2_PAGE_RANGE = 8
|
||||
HCAPTCHA_SITEKEY = os.getenv('HCAPTCHA_SITEKEY', '')
|
||||
HCAPTCHA_SECRET = os.getenv('HCAPTCHA_SECRET', '')
|
||||
|
||||
FDA_API_KEY = os.getenv('FDA_API_KEY', 'DEMO_KEY')
|
||||
|
||||
SHARING_ABUSE = bool(int(os.getenv('SHARING_ABUSE', False)))
|
||||
SHARING_LIMIT = int(os.getenv('SHARING_LIMIT', 0))
|
||||
|
||||
@@ -144,6 +147,9 @@ try:
|
||||
'base_path': os.path.join(BASE_DIR, 'recipes', 'plugins', d),
|
||||
'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:
|
||||
@@ -412,7 +418,7 @@ for p in PLUGINS:
|
||||
if p['bundle_name'] != '':
|
||||
WEBPACK_LOADER[p['bundle_name']] = {
|
||||
'CACHE': not DEBUG,
|
||||
'BUNDLE_DIR_NAME': f'{p["base_path"]}/vue/', # must end with slash
|
||||
'BUNDLE_DIR_NAME': f'vue/', # must end with slash
|
||||
'STATS_FILE': os.path.join(p["base_path"], 'vue', 'webpack-stats.json'),
|
||||
'POLL_INTERVAL': 0.1,
|
||||
'TIMEOUT': None,
|
||||
@@ -445,6 +451,7 @@ LANGUAGES = [
|
||||
('hu', _('Hungarian')),
|
||||
('it', _('Italian')),
|
||||
('lv', _('Latvian')),
|
||||
('nb', _('Norwegian ')),
|
||||
('pl', _('Polish')),
|
||||
('ru', _('Russian')),
|
||||
('es', _('Spanish')),
|
||||
@@ -514,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
|
||||
@@ -17,7 +17,7 @@ Markdown==3.4.3
|
||||
Pillow==9.4.0
|
||||
psycopg2-binary==2.9.5
|
||||
python-dotenv==0.21.0
|
||||
requests==2.28.2
|
||||
requests==2.31.0
|
||||
six==1.16.0
|
||||
webdavclient3==3.14.6
|
||||
whitenoise==6.2.0
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"vue-multiselect": "^2.1.6",
|
||||
"vue-property-decorator": "^9.1.2",
|
||||
"vue-sanitize": "^0.2.2",
|
||||
"vue-simple-calendar": "TandoorRecipes/vue-simple-calendar#lastvue2",
|
||||
"vue-simple-calendar": "5.0.1",
|
||||
"vue-template-compiler": "2.7.14",
|
||||
"vue2-touch-events": "^3.2.2",
|
||||
"vuedraggable": "^2.24.3",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -137,8 +137,7 @@
|
||||
|
||||
<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>
|
||||
<property-view-component :recipe="recipe" :servings="servings" @foodUpdated="loadRecipe(recipe.id)"></property-view-component>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -185,6 +184,7 @@ import CustomInputSpinButton from "@/components/CustomInputSpinButton"
|
||||
import {ApiApiFactory} from "@/utils/openapi/api";
|
||||
import ImportTandoor from "@/components/Modals/ImportTandoor.vue";
|
||||
import BottomNavigationBar from "@/components/BottomNavigationBar.vue";
|
||||
import PropertyViewComponent from "@/components/PropertyViewComponent.vue";
|
||||
|
||||
Vue.prototype.moment = moment
|
||||
|
||||
@@ -202,13 +202,14 @@ export default {
|
||||
IngredientsCard,
|
||||
StepComponent,
|
||||
RecipeContextMenu,
|
||||
NutritionComponent,
|
||||
// NutritionComponent,
|
||||
KeywordsComponent,
|
||||
LoadingSpinner,
|
||||
AddRecipeToBook,
|
||||
RecipeSwitcher,
|
||||
CustomInputSpinButton,
|
||||
BottomNavigationBar,
|
||||
PropertyViewComponent,
|
||||
},
|
||||
computed: {
|
||||
ingredient_factor: function () {
|
||||
|
||||
@@ -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,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,46 +1,52 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-modal :id="'modal_' + id" @hidden="cancelAction">
|
||||
<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}`" />
|
||||
</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" />
|
||||
<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" />
|
||||
<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">
|
||||
<div class="col-6 align-self-end">
|
||||
<b-form-checkbox v-if="advancedForm" sm switch v-model="show_advanced">{{ $t("Advanced") }}</b-form-checkbox>
|
||||
</div>
|
||||
<div class="col-auto justify-content-end">
|
||||
<b-button class="mx-1" variant="secondary" v-on:click="cancelAction">{{ $t("Cancel") }}</b-button>
|
||||
<b-button class="mx-1" variant="primary" v-on:click="doAction">{{ form.ok_label }}</b-button>
|
||||
</div>
|
||||
<template v-if="form_component !== undefined">
|
||||
<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}`"/>
|
||||
</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"/>
|
||||
<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"/>
|
||||
</div>
|
||||
</template>
|
||||
</b-modal>
|
||||
<template v-slot:modal-footer>
|
||||
<div class="row w-100">
|
||||
<div class="col-6 align-self-end">
|
||||
<b-form-checkbox v-if="advancedForm" sm switch v-model="show_advanced">{{ $t("Advanced") }}</b-form-checkbox>
|
||||
</div>
|
||||
<div class="col-auto justify-content-end">
|
||||
<b-button class="mx-1" variant="secondary" v-on:click="cancelAction">{{ $t("Cancel") }}</b-button>
|
||||
<b-button class="mx-1" variant="primary" v-on:click="doAction">{{ form.ok_label }}</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</b-modal>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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"
|
||||
@@ -54,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() {
|
||||
@@ -76,7 +93,8 @@ export default {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
show: { required: true, type: Boolean, default: false },
|
||||
show: {required: true, type: Boolean, default: false},
|
||||
models: {required: false, type: Function, default: null}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -92,6 +110,10 @@ export default {
|
||||
mounted() {
|
||||
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) {
|
||||
this.Models = this.models // override models definition file with prop
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
advancedForm() {
|
||||
@@ -111,6 +133,15 @@ export default {
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
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) {
|
||||
return () => import(/* webpackChunkName: "header-component" */ `@/components/${this.form.component}`)
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
show: function () {
|
||||
@@ -153,6 +184,7 @@ export default {
|
||||
if (this.dirty) {
|
||||
this.dirty = false
|
||||
this.$emit("finish-action", "cancel")
|
||||
this.$emit("hidden")
|
||||
}
|
||||
},
|
||||
storeValue: function (field, value) {
|
||||
@@ -174,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")
|
||||
})
|
||||
@@ -193,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)
|
||||
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)
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
|
||||
this.$emit("finish-action", "cancel")
|
||||
})
|
||||
}
|
||||
@@ -224,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")
|
||||
})
|
||||
},
|
||||
@@ -250,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">
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Изменить описание"
|
||||
}
|
||||
|
||||
0
vue/src/stores/GenericApiStore.js
Normal file
0
vue/src/stores/GenericApiStore.js
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -50,7 +50,7 @@ export class StandardToasts {
|
||||
static FAIL_MOVE = "FAIL_MOVE"
|
||||
static FAIL_MERGE = "FAIL_MERGE"
|
||||
|
||||
static makeStandardToast(context, toast, err) {
|
||||
static makeStandardToast(context, toast, err = undefined, always_show_errors = false) {
|
||||
let title = ''
|
||||
let msg = ''
|
||||
let variant = ''
|
||||
@@ -124,7 +124,7 @@ export class StandardToasts {
|
||||
}
|
||||
|
||||
|
||||
let DEBUG = localStorage.getItem("DEBUG") === "True" || false
|
||||
let DEBUG = localStorage.getItem("DEBUG") === "True" || always_show_errors
|
||||
|
||||
if (err !== undefined && 'response' in err && 'headers' in err.response) {
|
||||
if (DEBUG && err.response.headers['content-type'] === 'application/json' && err.response.status < 500) {
|
||||
@@ -311,7 +311,7 @@ export function calculateHourMinuteSplit(amount) {
|
||||
let minutes = amount - hours * 60
|
||||
let output_text = hours + " h"
|
||||
|
||||
if (minutes > 0){
|
||||
if (minutes > 0) {
|
||||
output_text += " " + minutes + " min"
|
||||
}
|
||||
|
||||
@@ -368,6 +368,9 @@ export const ApiMixin = {
|
||||
let func = setup.function
|
||||
let parameters = buildParams(options, setup)
|
||||
let apiClient = new ApiApiFactory()
|
||||
if (model.apiClient !== undefined) {
|
||||
apiClient = model.apiClient
|
||||
}
|
||||
return apiClient[func](...parameters)
|
||||
},
|
||||
genericGetAPI: function (url, options) {
|
||||
|
||||
1783
vue/yarn.lock
1783
vue/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user