mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2025-12-28 12:39:36 -05:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc320f2e6d | ||
|
|
acbca83553 | ||
|
|
cb26c5dfc8 | ||
|
|
b5c4174700 | ||
|
|
3e37d11c6a | ||
|
|
36e83a9d01 | ||
|
|
efcd759869 | ||
|
|
7c81396ec5 | ||
|
|
9b50665375 | ||
|
|
83795581e6 | ||
|
|
af51524109 | ||
|
|
738aa12243 | ||
|
|
f25de4b4ce | ||
|
|
698aa5a753 | ||
|
|
6444680e06 | ||
|
|
c604369e86 | ||
|
|
79abb8bf8f | ||
|
|
fd4236672e | ||
|
|
00148a2993 | ||
|
|
359fcb24cf | ||
|
|
f5d7919f72 | ||
|
|
86c4278553 | ||
|
|
2a5c0bb740 | ||
|
|
432dfa9e86 | ||
|
|
f61a8371f4 | ||
|
|
0bcdf5e0a3 | ||
|
|
169f799a23 | ||
|
|
942d1130a1 | ||
|
|
64cc20aed2 | ||
|
|
3a6731ec8d | ||
|
|
e6f11a17b9 | ||
|
|
cc1cd610e7 | ||
|
|
6a3b5ee844 | ||
|
|
49b119571e | ||
|
|
e024e3deb0 | ||
|
|
7ccedb559d | ||
|
|
103daf000d | ||
|
|
70df456307 | ||
|
|
375174ee41 | ||
|
|
f19beba014 | ||
|
|
865756e4b2 | ||
|
|
41f834db08 | ||
|
|
2c94753a5a | ||
|
|
0e05c77fa7 | ||
|
|
793c152b26 | ||
|
|
9df75f551c | ||
|
|
da49280ef2 | ||
|
|
e6087d5129 | ||
|
|
4f9bff20c8 | ||
|
|
683f1ac10a | ||
|
|
e844d2995a |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -84,3 +84,5 @@ vue3/.vite
|
||||
# Configs
|
||||
vetur.config.js
|
||||
venv/
|
||||
.idea/easy-i18n.xml
|
||||
cookbook/static/vue3
|
||||
@@ -35,6 +35,13 @@ RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-de
|
||||
#Copy project and execute it.
|
||||
COPY . ./
|
||||
|
||||
# commented for now https://github.com/TandoorRecipes/recipes/issues/3478
|
||||
#HEALTHCHECK --interval=30s \
|
||||
# --timeout=5s \
|
||||
# --start-period=10s \
|
||||
# --retries=3 \
|
||||
# CMD [ "/usr/bin/wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8080/openapi" ]
|
||||
|
||||
# collect information from git repositories
|
||||
RUN /opt/recipes/venv/bin/python version.py
|
||||
# delete git repositories to reduce image size
|
||||
|
||||
14
boot.sh
14
boot.sh
@@ -29,6 +29,18 @@ if [ -z "${SECRET_KEY}" ]; then
|
||||
display_warning "The environment variable 'SECRET_KEY' (or 'SECRET_KEY_FILE' that points to an existing file) is not set but REQUIRED for running Tandoor!"
|
||||
fi
|
||||
|
||||
if [ -f "${AUTH_LDAP_BIND_PASSWORD_FILE}" ]; then
|
||||
export AUTH_LDAP_BIND_PASSWORD=$(cat "$AUTH_LDAP_BIND_PASSWORD_FILE")
|
||||
fi
|
||||
|
||||
if [ -f "${EMAIL_HOST_PASSWORD_FILE}" ]; then
|
||||
export EMAIL_HOST_PASSWORD=$(cat "$EMAIL_HOST_PASSWORD_FILE")
|
||||
fi
|
||||
|
||||
if [ -f "${SOCIALACCOUNT_PROVIDERS_FILE}" ]; then
|
||||
export SOCIALACCOUNT_PROVIDERS=$(cat "$SOCIALACCOUNT_PROVIDERS_FILE")
|
||||
fi
|
||||
|
||||
|
||||
echo "Waiting for database to be ready..."
|
||||
|
||||
@@ -83,4 +95,4 @@ if [ "$ipv6_disable" -eq 0 ]; then
|
||||
exec gunicorn -b "[::]:$TANDOOR_PORT" --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --access-logfile - --error-logfile - --log-level $GUNICORN_LOG_LEVEL recipes.wsgi
|
||||
else
|
||||
exec gunicorn -b ":$TANDOOR_PORT" --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --access-logfile - --error-logfile - --log-level $GUNICORN_LOG_LEVEL recipes.wsgi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
from allauth.account.forms import ResetPasswordForm, SignupForm
|
||||
from allauth.socialaccount.forms import SignupForm as SocialSignupForm
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -14,15 +16,13 @@ from .models import Comment, InviteLink, Keyword, Recipe, SearchPreference, Spac
|
||||
|
||||
|
||||
class SelectWidget(widgets.Select):
|
||||
|
||||
class Media:
|
||||
js = ('custom/js/form_select.js', )
|
||||
js = ('custom/js/form_select.js',)
|
||||
|
||||
|
||||
class MultiSelectWidget(widgets.SelectMultiple):
|
||||
|
||||
class Media:
|
||||
js = ('custom/js/form_multiselect.js', )
|
||||
js = ('custom/js/form_multiselect.js',)
|
||||
|
||||
|
||||
# Yes there are some stupid browsers that still dont support this but
|
||||
@@ -139,7 +139,7 @@ class CommentForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Comment
|
||||
fields = ('text', )
|
||||
fields = ('text',)
|
||||
|
||||
labels = {'text': _('Add your comment: '), }
|
||||
widgets = {'text': forms.Textarea(attrs={'rows': 2, 'cols': 15}), }
|
||||
@@ -161,7 +161,6 @@ class StorageForm(forms.ModelForm):
|
||||
help_texts = {'url': _('Leave empty for dropbox and enter only base url for nextcloud (<code>/remote.php/webdav/</code> is added automatically)'), }
|
||||
|
||||
|
||||
|
||||
class ConnectorConfigForm(forms.ModelForm):
|
||||
enabled = forms.BooleanField(
|
||||
help_text="Is the connector enabled",
|
||||
@@ -315,6 +314,18 @@ class AllAuthSignupForm(SignupForm):
|
||||
pass
|
||||
|
||||
|
||||
class AllAuthSocialSignupForm(SocialSignupForm):
|
||||
terms = forms.BooleanField(label=_('Accept Terms and Privacy'))
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
if settings.PRIVACY_URL == '' and settings.TERMS_URL == '':
|
||||
self.fields.pop('terms')
|
||||
|
||||
def signup(self, request, user):
|
||||
pass
|
||||
|
||||
|
||||
class CustomPasswordResetForm(ResetPasswordForm):
|
||||
captcha = hCaptchaField()
|
||||
|
||||
@@ -345,12 +356,13 @@ class SearchPreferenceForm(forms.ModelForm):
|
||||
|
||||
help_texts = {
|
||||
'search': _('Select type method of search. Click <a href="/docs/search/">here</a> for full description of choices.'), 'lookup':
|
||||
_('Use fuzzy matching on units, keywords and ingredients when editing and importing recipes.'), 'unaccent':
|
||||
_('Fields to search ignoring accents. Selecting this option can improve or degrade search quality depending on language'), 'icontains':
|
||||
_("Fields to search for partial matches. (e.g. searching for 'Pie' will return 'pie' and 'piece' and 'soapie')"), 'istartswith':
|
||||
_("Fields to search for beginning of word matches. (e.g. searching for 'sa' will return 'salad' and 'sandwich')"), 'trigram':
|
||||
_("Fields to 'fuzzy' search. (e.g. searching for 'recpie' will find 'recipe'.) Note: this option will conflict with 'web' and 'raw' methods of search."), 'fulltext':
|
||||
_("Fields to full text search. Note: 'web', 'phrase', and 'raw' search methods only function with fulltext fields."),
|
||||
_('Use fuzzy matching on units, keywords and ingredients when editing and importing recipes.'), 'unaccent':
|
||||
_('Fields to search ignoring accents. Selecting this option can improve or degrade search quality depending on language'), 'icontains':
|
||||
_("Fields to search for partial matches. (e.g. searching for 'Pie' will return 'pie' and 'piece' and 'soapie')"), 'istartswith':
|
||||
_("Fields to search for beginning of word matches. (e.g. searching for 'sa' will return 'salad' and 'sandwich')"), 'trigram':
|
||||
_("Fields to 'fuzzy' search. (e.g. searching for 'recpie' will find 'recipe'.) Note: this option will conflict with 'web' and 'raw' methods of search."),
|
||||
'fulltext':
|
||||
_("Fields to full text search. Note: 'web', 'phrase', and 'raw' search methods only function with fulltext fields."),
|
||||
}
|
||||
|
||||
labels = {
|
||||
@@ -360,5 +372,5 @@ class SearchPreferenceForm(forms.ModelForm):
|
||||
|
||||
widgets = {
|
||||
'search': SelectWidget, 'unaccent': MultiSelectWidget, 'icontains': MultiSelectWidget, 'istartswith': MultiSelectWidget, 'trigram': MultiSelectWidget, 'fulltext':
|
||||
MultiSelectWidget,
|
||||
MultiSelectWidget,
|
||||
}
|
||||
|
||||
@@ -35,6 +35,20 @@ def get_filetype(name):
|
||||
return '.jpeg'
|
||||
|
||||
|
||||
def is_file_type_allowed(filename, image_only=False):
|
||||
is_file_allowed = False
|
||||
allowed_file_types = ['.pdf','.docx', '.xlsx']
|
||||
allowed_image_types = ['.png', '.jpg', '.jpeg', '.gif']
|
||||
check_list = allowed_image_types
|
||||
if not image_only:
|
||||
check_list += allowed_file_types
|
||||
|
||||
for file_type in check_list:
|
||||
if filename.endswith(file_type):
|
||||
is_file_allowed = True
|
||||
|
||||
return is_file_allowed
|
||||
|
||||
# TODO this whole file needs proper documentation, refactoring, and testing
|
||||
# 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
|
||||
|
||||
@@ -3,6 +3,8 @@ from gettext import gettext as _
|
||||
import bleach
|
||||
import markdown as md
|
||||
from jinja2 import Template, TemplateSyntaxError, UndefinedError
|
||||
from jinja2.exceptions import SecurityError
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
from markdown.extensions.tables import TableExtension
|
||||
|
||||
from cookbook.helper.mdx_attributes import MarkdownFormatExtension
|
||||
@@ -89,11 +91,13 @@ def render_instructions(step): # TODO deduplicate markdown cleanup code
|
||||
return f"<scalable-number v-bind:number='{bleach.clean(str(number))}' v-bind:factor='ingredient_factor'></scalable-number>"
|
||||
|
||||
try:
|
||||
template = Template(instructions)
|
||||
instructions = template.render(ingredients=ingredients, scale=scale)
|
||||
env = SandboxedEnvironment()
|
||||
instructions = env.from_string(instructions).render(ingredients=ingredients, scale=scale)
|
||||
except TemplateSyntaxError:
|
||||
return _('Could not parse template code.') + ' Error: Template Syntax broken'
|
||||
except UndefinedError:
|
||||
return _('Could not parse template code.') + ' Error: Undefined Error'
|
||||
except SecurityError:
|
||||
return _('Could not parse template code.') + ' Error: Security Error'
|
||||
|
||||
return instructions
|
||||
|
||||
@@ -12,7 +12,7 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-08-01 15:04+0200\n"
|
||||
"PO-Revision-Date: 2024-11-01 06:58+0000\n"
|
||||
"PO-Revision-Date: 2024-12-09 00:58+0000\n"
|
||||
"Last-Translator: Vincenzo Reale <smart2128vr@gmail.com>\n"
|
||||
"Language-Team: Italian <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/it/>\n"
|
||||
@@ -21,7 +21,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 5.6.2\n"
|
||||
"X-Generator: Weblate 5.8.4\n"
|
||||
|
||||
#: .\cookbook\forms.py:45
|
||||
msgid ""
|
||||
@@ -520,7 +520,7 @@ msgstr "Web"
|
||||
|
||||
#: .\cookbook\models.py:1411 .\cookbook\templates\search_info.html:47
|
||||
msgid "Raw"
|
||||
msgstr "Raw"
|
||||
msgstr "Crudo"
|
||||
|
||||
#: .\cookbook\models.py:1467
|
||||
msgid "Food Alias"
|
||||
@@ -1440,8 +1440,9 @@ msgid ""
|
||||
"\"noreferrer noopener\" target=\"_blank\">this one.</a>"
|
||||
msgstr ""
|
||||
"Le tabelle in markdown sono difficili da creare a mano. Si raccomanda "
|
||||
"l'utilizzo di un editor di come <a href=\"https://www.tablesgenerator.com/"
|
||||
"markdown_tables\" rel=\"noreferrer noopener\" target=\"_blank\">questo.</a>"
|
||||
"l'utilizzo di un editor di tabelle come <a href=\"https://www.tablesgenerator"
|
||||
".com/markdown_tables\" rel=\"noreferrer noopener\" target=\"_blank\""
|
||||
">questo.</a>"
|
||||
|
||||
#: .\cookbook\templates\markdown_info.html:155
|
||||
#: .\cookbook\templates\markdown_info.html:157
|
||||
@@ -2203,8 +2204,8 @@ msgstr ""
|
||||
" Le migrazioni non andate a buon fine probabilmente causeranno il "
|
||||
"malfunzionamento di parti importanti dell'applicazione.\n"
|
||||
" Se una migrazione non riesce, assicurati di avere la versione "
|
||||
"più recente e, in tal caso, pubblica il registro della migrazione e la "
|
||||
"panoramica di seguito in una segnalazione di problema su GitHub.\n"
|
||||
"più recente e, in tal caso, pubblica il registro della migrazione e il "
|
||||
"riepilogo che segue in una segnalazione di problema su GitHub.\n"
|
||||
" "
|
||||
|
||||
#: .\cookbook\templates\system.html:182
|
||||
@@ -2765,7 +2766,7 @@ msgid ""
|
||||
"but not recommended as some features only work with postgres databases."
|
||||
msgstr ""
|
||||
"Questa applicazione non è in esecuzione con un database Postgres. Va bene, "
|
||||
"ma non è consigliato perché alcune funzionalità sono disponibili solo con un "
|
||||
"ma non è consigliato perché alcune funzionalità sono disponibili solo con "
|
||||
"database Postgres."
|
||||
|
||||
#: .\cookbook\views\views.py:360
|
||||
|
||||
@@ -8,8 +8,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-08-01 15:04+0200\n"
|
||||
"PO-Revision-Date: 2024-11-22 07:58+0000\n"
|
||||
"Last-Translator: Oleh Hudyma <oleg.hudymaa@gmail.com>\n"
|
||||
"PO-Revision-Date: 2025-01-16 18:58+0000\n"
|
||||
"Last-Translator: Anton Shevtsov <ashevtsovs@gmail.com>\n"
|
||||
"Language-Team: Ukrainian <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/uk/>\n"
|
||||
"Language: uk\n"
|
||||
@@ -18,7 +18,7 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
|
||||
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
|
||||
"X-Generator: Weblate 5.6.2\n"
|
||||
"X-Generator: Weblate 5.8.4\n"
|
||||
|
||||
#: .\cookbook\forms.py:45
|
||||
msgid ""
|
||||
@@ -32,7 +32,7 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\forms.py:62 .\cookbook\forms.py:246 .\cookbook\views\lists.py:103
|
||||
msgid "Keywords"
|
||||
msgstr ""
|
||||
msgstr "Ключові слова"
|
||||
|
||||
#: .\cookbook\forms.py:62
|
||||
msgid "Preparation time in minutes"
|
||||
@@ -941,13 +941,13 @@ msgstr ""
|
||||
#: .\cookbook\templates\ingredient_editor.html:7
|
||||
#: .\cookbook\templates\ingredient_editor.html:13
|
||||
msgid "Ingredient Editor"
|
||||
msgstr ""
|
||||
msgstr "Редактор Інгредієнтів"
|
||||
|
||||
#: .\cookbook\templates\base.html:275
|
||||
#: .\cookbook\templates\export_response.html:7
|
||||
#: .\cookbook\templates\test2.html:14 .\cookbook\templates\test2.html:20
|
||||
msgid "Export"
|
||||
msgstr ""
|
||||
msgstr "Експорт"
|
||||
|
||||
#: .\cookbook\templates\base.html:287
|
||||
msgid "Properties"
|
||||
|
||||
@@ -12,21 +12,25 @@ class Local(Provider):
|
||||
|
||||
@staticmethod
|
||||
def import_all(monitor):
|
||||
if '/etc/' in monitor.path or '/root/' in monitor.path or '/mediafiles/' in monitor.path or '/usr/' in monitor.path:
|
||||
return False
|
||||
|
||||
files = [f for f in listdir(monitor.path) if isfile(join(monitor.path, f))]
|
||||
|
||||
import_count = 0
|
||||
for file in files:
|
||||
path = monitor.path + '/' + file
|
||||
if not Recipe.objects.filter(file_path__iexact=path, space=monitor.space).exists() and not RecipeImport.objects.filter(file_path=path, space=monitor.space).exists():
|
||||
name = os.path.splitext(file)[0]
|
||||
new_recipe = RecipeImport(
|
||||
name=name,
|
||||
file_path=path,
|
||||
storage=monitor.storage,
|
||||
space=monitor.space,
|
||||
)
|
||||
new_recipe.save()
|
||||
import_count += 1
|
||||
if file.endswith('.pdf') or file.endswith('.png') or file.endswith('.jpg') or file.endswith('.jpeg') or file.endswith('.gif'):
|
||||
path = monitor.path + '/' + file
|
||||
if not Recipe.objects.filter(file_path__iexact=path, space=monitor.space).exists() and not RecipeImport.objects.filter(file_path=path, space=monitor.space).exists():
|
||||
name = os.path.splitext(file)[0]
|
||||
new_recipe = RecipeImport(
|
||||
name=name,
|
||||
file_path=path,
|
||||
storage=monitor.storage,
|
||||
space=monitor.space,
|
||||
)
|
||||
new_recipe.save()
|
||||
import_count += 1
|
||||
|
||||
log_entry = SyncLog(
|
||||
status='SUCCESS',
|
||||
|
||||
@@ -22,6 +22,7 @@ from rest_framework.fields import IntegerField
|
||||
|
||||
from cookbook.helper.CustomStorageClass import CachedS3Boto3Storage
|
||||
from cookbook.helper.HelperFunctions import str2bool
|
||||
from cookbook.helper.image_processing import is_file_type_allowed
|
||||
from cookbook.helper.permission_helper import above_space_limit
|
||||
from cookbook.helper.property_helper import FoodPropertyHelper
|
||||
from cookbook.helper.shopping_helper import RecipeShoppingEditor
|
||||
@@ -233,12 +234,17 @@ class UserFileSerializer(serializers.ModelSerializer):
|
||||
raise ValidationError(_('You have reached your file upload limit.'))
|
||||
|
||||
def create(self, validated_data):
|
||||
if not is_file_type_allowed(validated_data['file'].name):
|
||||
return None
|
||||
|
||||
self.check_file_limit(validated_data)
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
validated_data['space'] = self.context['request'].space
|
||||
return super().create(validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
if not is_file_type_allowed(validated_data['file'].name):
|
||||
return None
|
||||
self.check_file_limit(validated_data)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
@@ -958,6 +964,16 @@ class RecipeImageSerializer(WritableNestedModelSerializer):
|
||||
image = serializers.ImageField(required=False, allow_null=True)
|
||||
image_url = serializers.CharField(max_length=4096, required=False, allow_null=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
if not is_file_type_allowed(validated_data['image'].name, image_only=True):
|
||||
return None
|
||||
return super().create( validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
if not is_file_type_allowed(validated_data['image'].name, image_only=True):
|
||||
return None
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = ['image', 'image_url', ]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% load crispy_forms_filters %}
|
||||
{% load crispy_forms_filters %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans 'Register' %}{% endblock %}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
{% if redirect_field_value %}
|
||||
<input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}"/>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-group">
|
||||
{{ form.username |as_crispy_field }}
|
||||
</div>
|
||||
@@ -30,7 +29,7 @@
|
||||
<div class="form-group">
|
||||
{{ form.terms |as_crispy_field }}
|
||||
<small>
|
||||
{% trans 'I accept the follwoing' %}
|
||||
{% trans 'I accept the following' %}
|
||||
{% if TERMS_URL != '' %}
|
||||
<a href="{{ TERMS_URL }}" target="_blank"
|
||||
rel="noreferrer nofollow">{% trans 'Terms and Conditions' %}</a>
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
|
||||
<h1>{% trans 'System' %}</h1>
|
||||
{% blocktrans %}
|
||||
Django Recipes is an open source free software application. It can be found on
|
||||
<a href="https://github.com/vabene1111/recipes">GitHub</a>.
|
||||
Changelogs can be found <a href="https://github.com/vabene1111/recipes/releases">here</a>.
|
||||
Tandoor Recipes is an open source free software application. It can be found on
|
||||
<a href="https://github.com/TandoorRecipes/recipes">GitHub</a>.
|
||||
Changelogs can be found <a href="https://github.com/TandoorRecipes/recipes/releases">here</a>.
|
||||
{% endblocktrans %}
|
||||
|
||||
<h3 class="mt-5">{% trans 'System Information' %}</h3>
|
||||
|
||||
@@ -31,7 +31,7 @@ def test_edit_storage(storage_obj, a1_s1, a1_s2):
|
||||
}
|
||||
)
|
||||
storage_obj.refresh_from_db()
|
||||
assert r.status_code == 200
|
||||
assert r.status_code == 302
|
||||
r_messages = [m for m in get_messages(r.wsgi_request)]
|
||||
assert not any(m.level > messages.SUCCESS for m in r_messages)
|
||||
|
||||
@@ -54,7 +54,7 @@ def test_edit_storage(storage_obj, a1_s1, a1_s2):
|
||||
['a_u', 302],
|
||||
['g1_s1', 302],
|
||||
['u1_s1', 302],
|
||||
['a1_s1', 200],
|
||||
['a1_s1', 302],
|
||||
['g1_s2', 302],
|
||||
['u1_s2', 302],
|
||||
['a1_s2', 404],
|
||||
|
||||
@@ -1455,13 +1455,21 @@ class RecipeUrlImportView(APIView):
|
||||
|
||||
url = serializer.validated_data.get('url', None)
|
||||
data = unquote(serializer.validated_data.get('data', None))
|
||||
|
||||
duplicate = False
|
||||
if url:
|
||||
# Check for existing recipes with provided url
|
||||
existing_recipe = Recipe.objects.filter(source_url=url).first()
|
||||
if existing_recipe:
|
||||
duplicate = True
|
||||
|
||||
if not url and not data:
|
||||
return Response({'error': True, 'msg': _('Nothing to do.')}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
elif url and not data:
|
||||
if re.match('^(https?://)?(www\\.youtube\\.com|youtu\\.be)/.+$', url):
|
||||
if validate_import_url(url):
|
||||
return Response({'recipe_json': get_from_youtube_scraper(url, request), 'recipe_images': [], }, status=status.HTTP_200_OK)
|
||||
return Response({'recipe_json': get_from_youtube_scraper(url, request), 'recipe_images': [], 'duplicate': duplicate}, 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='
|
||||
@@ -1476,7 +1484,7 @@ class RecipeUrlImportView(APIView):
|
||||
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}))}, status=status.HTTP_201_CREATED)
|
||||
return Response({'link': request.build_absolute_uri(reverse('view_recipe', args={recipe.pk})), 'duplicate': duplicate}, status=status.HTTP_201_CREATED)
|
||||
else:
|
||||
try:
|
||||
if validate_import_url(url):
|
||||
@@ -1511,6 +1519,7 @@ class RecipeUrlImportView(APIView):
|
||||
return Response({
|
||||
'recipe_json': helper.get_from_scraper(scrape, request),
|
||||
'recipe_images': list(dict.fromkeys(get_images_from_soup(scrape.soup, url))),
|
||||
'duplicate': duplicate
|
||||
},
|
||||
status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ class SyncUpdate(GroupRequiredMixin, UpdateView, SpaceFormMixing):
|
||||
def edit_storage(request, pk):
|
||||
instance: Storage = get_object_or_404(Storage, pk=pk, space=request.space)
|
||||
|
||||
if not (instance.created_by == request.user or request.user.is_superuser):
|
||||
if not request.user.is_superuser:
|
||||
messages.add_message(request, messages.ERROR, _('You cannot edit this storage!'))
|
||||
return HttpResponseRedirect(reverse('list_storage'))
|
||||
|
||||
|
||||
@@ -58,10 +58,16 @@ class StorageCreate(GroupRequiredMixin, CreateView):
|
||||
obj = form.save(commit=False)
|
||||
obj.created_by = self.request.user
|
||||
obj.space = self.request.space
|
||||
obj.save()
|
||||
|
||||
if self.request.space.demo or settings.HOSTED:
|
||||
messages.add_message(self.request, messages.ERROR, _('This feature is not yet available in the hosted version of tandoor!'))
|
||||
return redirect('index')
|
||||
|
||||
if not self.request.user.is_superuser:
|
||||
messages.add_message(self.request, messages.ERROR, _('This feature is only available for the instance administrator (superuser)'))
|
||||
return redirect('index')
|
||||
|
||||
obj.save()
|
||||
return HttpResponseRedirect(reverse('edit_storage', kwargs={'pk': obj.pk}))
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
Besides the normal django username and password authentication this application supports multiple
|
||||
Besides the normal django username and password authentication this application supports multiple
|
||||
methods of central account management and authentication.
|
||||
|
||||
## Allauth
|
||||
[Django Allauth](https://django-allauth.readthedocs.io/en/latest/index.html) is an awesome project that
|
||||
[Django Allauth](https://django-allauth.readthedocs.io/en/latest/index.html) is an awesome project that
|
||||
allows you to use a [huge number](https://docs.allauth.org/en/latest/socialaccount/providers/index.html) of different
|
||||
authentication providers.
|
||||
|
||||
@@ -11,8 +11,8 @@ They basically explain everything in their documentation, but the following is a
|
||||
!!! warning "Public Providers"
|
||||
If you choose Google, Github or any other publicly available service as your authentication provider anyone
|
||||
with an account on that site can create an account on your installation.
|
||||
A new account does not have any permission but it is still **not recommended** to give public access to
|
||||
your installation.
|
||||
A new account does not have any permission but it is still **not recommended** to give public access to
|
||||
your installation.
|
||||
|
||||
Choose a provider from the [list](https://docs.allauth.org/en/latest/socialaccount/providers/index.html) and install it using the environment variable `SOCIAL_PROVIDERS` as shown
|
||||
in the example below.
|
||||
@@ -28,15 +28,15 @@ SOCIAL_PROVIDERS=allauth.socialaccount.providers.openid_connect,allauth.socialac
|
||||
|
||||
### Configuration, via environment
|
||||
|
||||
Depending on your authentication provider you **might need** to configure it.
|
||||
This needs to be done through the settings system. To make the system flexible (allow multiple providers) and to
|
||||
Depending on your authentication provider you **might need** to configure it.
|
||||
This needs to be done through the settings system. To make the system flexible (allow multiple providers) and to
|
||||
not require another file to be mounted into the container the configuration ins done through a single
|
||||
environment variable. The downside of this approach is that the configuration needs to be put into a single line
|
||||
as environment files loaded by docker compose don't support multiple lines for a single variable.
|
||||
|
||||
The line data needs to either be in json or as Python dictionary syntax.
|
||||
|
||||
Take the example configuration from the allauth docs, fill in your settings and then inline the whole object
|
||||
Take the example configuration from the allauth docs, fill in your settings and then inline the whole object
|
||||
(you can use a service like [www.freeformatter.com](https://www.freeformatter.com/json-formatter.html) for formatting).
|
||||
Assign it to the additional `SOCIALACCOUNT_PROVIDERS` variable.
|
||||
|
||||
@@ -46,6 +46,13 @@ The example below is for a generic OIDC provider with PKCE enabled. Most values
|
||||
SOCIALACCOUNT_PROVIDERS = "{ 'openid_connect': { 'OAUTH_PKCE_ENABLED': True, 'APPS': [ { 'provider_id': 'oidc', 'name': 'My-IDM', 'client_id': 'my_client_id', 'secret': 'my_client_secret', 'settings': { 'server_url': 'https://idm.example.com/oidc/recipes' } } ] } }"
|
||||
```
|
||||
|
||||
Because this JSON contains sensitive data (client id and secret), you may instead choose to save the JSON in a file
|
||||
and set the environment variable `SOCIALACCOUNT_PROVIDERS_FILE` to the path of the file containing the JSON.
|
||||
|
||||
```
|
||||
SOCIALACCOUNT_PROVIDERS_FILE=/run/secrets/socialaccount_providers.txt
|
||||
```
|
||||
|
||||
!!! success "Improvements ?"
|
||||
There are most likely ways to achieve the same goal but with a cleaner or simpler system.
|
||||
If you know such a way feel free to let me know.
|
||||
@@ -81,7 +88,7 @@ SOCIALACCOUNT_PROVIDERS='{"openid_connect":{"APPS":[{"provider_id":"keycloak","n
|
||||
You are now able to sign in using Keycloak after a restart of the service.
|
||||
|
||||
### Linking accounts
|
||||
To link an account to an already existing normal user go to the settings page of the user and link it.
|
||||
To link an account to an already existing normal user go to the settings page of the user and link it.
|
||||
Here you can also unlink your account if you no longer want to use a social login method.
|
||||
|
||||
## LDAP
|
||||
@@ -111,7 +118,7 @@ AUTH_LDAP_TLS_CACERTFILE=/etc/ssl/certs/own-ca.pem
|
||||
If you just set `REMOTE_USER_AUTH=1` without any additional configuration, _anybody_ can authenticate with _any_ username!
|
||||
|
||||
!!! Info "Community Contributed Tutorial"
|
||||
This tutorial was provided by a community member. We are not able to provide any support! Please only use, if you know what you are doing!
|
||||
This tutorial was provided by a community member. We are not able to provide any support! Please only use, if you know what you are doing!
|
||||
|
||||
In order use external authentication (i.e. using a proxy auth like Authelia, Authentik, etc.) you will need to:
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
These instructions are inspired from a standard django/gunicorn/postgresql instructions ([for example](https://www.digitalocean.com/community/tutorials/how-to-set-up-django-with-postgres-nginx-and-gunicorn-on-ubuntu-16-04))
|
||||
|
||||
!!! warning
|
||||
Make sure to use Python 3.10 or higher, and ensure that `pip` is associated with Python 3. Depending on your system configuration, using `python` or `pip` might default to Python 2. Make sure your machine has at least 2048 MB of memory; otherwise, the `yarn build` process may fail with the error: `FATAL ERROR: Reached heap limit - Allocation failed: JavaScript heap out of memory`.
|
||||
Make sure to use at least Python 3.10 (although 3.12 is preferred) or higher, and ensure that `pip` is associated with Python 3. Depending on your system configuration, using `python` or `pip` might default to Python 2. Make sure your machine has at least 2048 MB of memory; otherwise, the `yarn build` process may fail with the error: `FATAL ERROR: Reached heap limit - Allocation failed: JavaScript heap out of memory`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@@ -354,7 +354,7 @@ SOCIAL_PROVIDERS = allauth.socialaccount.providers.github, allauth.socialaccount
|
||||
Allow authentication via the REMOTE-USER header (can be used for e.g. authelia).
|
||||
|
||||
!!! danger
|
||||
Leave off if you don't know what you are doing! Enabling this without proper configuration will enable anybody
|
||||
Leave off if you don't know what you are doing! Enabling this without proper configuration will enable anybody
|
||||
to login with any username!
|
||||
|
||||
```
|
||||
@@ -377,6 +377,14 @@ AUTH_LDAP_TLS_CACERTFILE=
|
||||
AUTH_LDAP_START_TLS=
|
||||
```
|
||||
|
||||
Instead of passing the LDAP password directly through the environment variable `AUTH_LDAP_BIND_PASSWORD`,
|
||||
you can set the password in a file and set the environment variable `AUTH_LDAP_BIND_PASSWORD_FILE`
|
||||
to the path of the file containing the ldap secret.
|
||||
|
||||
```
|
||||
AUTH_LDAP_BIND_PASSWORD_FILE=/run/secrets/ldap_password.txt
|
||||
```
|
||||
|
||||
### External Services
|
||||
|
||||
#### Email
|
||||
@@ -396,6 +404,14 @@ EMAIL_USE_SSL=0
|
||||
DEFAULT_FROM_EMAIL=
|
||||
```
|
||||
|
||||
Instead of passing the email password directly through the environment variable `EMAIL_HOST_PASSWORD`,
|
||||
you can set the password in a file and set the environment variable `EMAIL_HOST_PASSWORD_FILE`
|
||||
to the path of the file containing the ldap secret.
|
||||
|
||||
```
|
||||
EMAIL_HOST_PASSWORD_FILE=/run/secrets/email_password.txt
|
||||
```
|
||||
|
||||
Optional settings (only copy the ones you need)
|
||||
|
||||
```
|
||||
@@ -561,7 +577,7 @@ STICKY_NAV_PREF_DEFAULT=1
|
||||
|
||||
> default `100` - options: `0-X`
|
||||
|
||||
The default for the number of spaces a user can own. By setting to 0 space creation for users will be disabled.
|
||||
The default for the number of spaces a user can own. By setting to 0 space creation for users will be disabled.
|
||||
Superusers can always bypass this limit.
|
||||
|
||||
```
|
||||
@@ -586,7 +602,7 @@ TZ=Europe/Berlin
|
||||
#### Default Theme
|
||||
> default `0` - options `1-X` (space ID)
|
||||
|
||||
Tandoors appearance can be changed on a user and space level but unauthenticated users always see the tandoor default style.
|
||||
Tandoors appearance can be changed on a user and space level but unauthenticated users always see the tandoor default style.
|
||||
With this setting you can specify the ID of a space of which the appearance settings should be applied if a user is not logged in.
|
||||
|
||||
```
|
||||
@@ -633,7 +649,7 @@ DRF_THROTTLE_RECIPE_URL_IMPORT=60/hour
|
||||
|
||||
#### Default Space Limits
|
||||
You might want to limit how many resources a user might create. The following settings apply automatically to newly
|
||||
created spaces. These defaults can be changed in the admin view after a space has been created.
|
||||
created spaces. These defaults can be changed in the admin view after a space has been created.
|
||||
|
||||
If unset, all settings default to unlimited/enabled
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ server {
|
||||
# serve media files
|
||||
location /media/ {
|
||||
alias /media/;
|
||||
add_header Content-Disposition 'attachment; filename="$args"';
|
||||
}
|
||||
# pass requests for dynamic content to gunicorn
|
||||
location / {
|
||||
|
||||
@@ -66,7 +66,6 @@ LOGGING = {
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# allow djangos wsgi server to server mediafiles
|
||||
GUNICORN_MEDIA = bool(int(os.getenv('GUNICORN_MEDIA', False)))
|
||||
|
||||
@@ -247,14 +246,14 @@ MIDDLEWARE = [
|
||||
]
|
||||
|
||||
if DEBUG_TOOLBAR:
|
||||
MIDDLEWARE += ('debug_toolbar.middleware.DebugToolbarMiddleware', )
|
||||
INSTALLED_APPS += ('debug_toolbar', )
|
||||
MIDDLEWARE += ('debug_toolbar.middleware.DebugToolbarMiddleware',)
|
||||
INSTALLED_APPS += ('debug_toolbar',)
|
||||
|
||||
SORT_TREE_BY_NAME = bool(int(os.getenv('SORT_TREE_BY_NAME', False)))
|
||||
DISABLE_TREE_FIX_STARTUP = bool(int(os.getenv('DISABLE_TREE_FIX_STARTUP', False)))
|
||||
|
||||
if bool(int(os.getenv('SQL_DEBUG', False))):
|
||||
MIDDLEWARE += ('recipes.middleware.SqlPrintingMiddleware', )
|
||||
MIDDLEWARE += ('recipes.middleware.SqlPrintingMiddleware',)
|
||||
|
||||
if ENABLE_METRICS:
|
||||
MIDDLEWARE += 'django_prometheus.middleware.PrometheusAfterMiddleware',
|
||||
@@ -294,7 +293,6 @@ if LDAP_AUTH:
|
||||
"handlers": ["console"]
|
||||
}
|
||||
|
||||
|
||||
AUTHENTICATION_BACKENDS += [
|
||||
'django.contrib.auth.backends.ModelBackend',
|
||||
'allauth.account.auth_backends.AuthenticationBackend',
|
||||
@@ -564,6 +562,9 @@ ACCOUNT_EMAIL_SUBJECT_PREFIX = os.getenv('ACCOUNT_EMAIL_SUBJECT_PREFIX', '[Tando
|
||||
|
||||
# ACCOUNT_SIGNUP_FORM_CLASS = 'cookbook.forms.AllAuthSignupForm'
|
||||
ACCOUNT_FORMS = {'signup': 'cookbook.forms.AllAuthSignupForm', 'reset_password': 'cookbook.forms.CustomPasswordResetForm'}
|
||||
SOCIALACCOUNT_FORMS = {
|
||||
'signup': 'cookbook.forms.AllAuthSocialSignupForm',
|
||||
}
|
||||
|
||||
ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False
|
||||
ACCOUNT_RATE_LIMITS = {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
Django==4.2.16
|
||||
cryptography===43.0.1
|
||||
Django==4.2.18
|
||||
cryptography===44.0.0
|
||||
django-annoying==0.10.6
|
||||
django-cleanup==8.0.0
|
||||
django-crispy-forms==2.3
|
||||
crispy-bootstrap4==2024.1
|
||||
crispy-bootstrap4==2024.10
|
||||
django-tables2==2.7.0
|
||||
djangorestframework==3.15.2
|
||||
drf-writable-nested==0.7.0
|
||||
@@ -20,17 +20,17 @@ requests==2.32.3
|
||||
six==1.16.0
|
||||
webdavclient3==3.14.6
|
||||
whitenoise==6.7.0
|
||||
icalendar==5.0.11
|
||||
icalendar==6.1.0
|
||||
pyyaml==6.0.2
|
||||
uritemplate==4.1.1
|
||||
beautifulsoup4==4.12.3
|
||||
microdata==0.8.0
|
||||
mock==5.1.0
|
||||
Jinja2==3.1.4
|
||||
Jinja2==3.1.5
|
||||
django-webpack-loader==3.0.1
|
||||
git+https://github.com/BITSOLVER/django-js-reverse@071e304fd600107bc64bbde6f2491f1fe049ec82
|
||||
django-allauth==0.61.1
|
||||
recipe-scrapers==15.2.1
|
||||
recipe-scrapers==15.4.0
|
||||
django-scopes==2.0.0
|
||||
django-treebeard==4.7
|
||||
django-cors-headers==4.6.0
|
||||
@@ -48,9 +48,9 @@ redis==5.2.0
|
||||
|
||||
# Development
|
||||
pytest==8.0.0
|
||||
pytest-django==4.8.0
|
||||
pytest-django==4.9.0
|
||||
pytest-cov===5.0.0
|
||||
pytest-factoryboy==2.6.0
|
||||
pytest-factoryboy==2.7.0
|
||||
pytest-html==4.1.1
|
||||
pytest-asyncio==0.23.5
|
||||
pytest-xdist==3.6.1
|
||||
|
||||
@@ -83,6 +83,11 @@
|
||||
<loading-spinner></loading-spinner>
|
||||
</b-card>
|
||||
|
||||
<!-- Warnings -->
|
||||
<b-card no-body v-if="duplicateWarning" class="warning">
|
||||
{{ duplicateWarning }}
|
||||
</b-card>
|
||||
|
||||
<!-- OPTIONS -->
|
||||
<b-card no-body v-if="recipe_json !== undefined">
|
||||
<b-card-header header-tag="header" class="p-1" role="tab">
|
||||
@@ -463,6 +468,7 @@ export default {
|
||||
},
|
||||
// URL import
|
||||
LS_IMPORT_RECENT: 'import_recent_urls', //TODO use central helper to manage all local storage keys (and maybe even access)
|
||||
duplicateWarning: '',
|
||||
website_url: '',
|
||||
website_url_list: '',
|
||||
import_multiple: false,
|
||||
@@ -643,6 +649,12 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if ('duplicate' in response.data && response.data['duplicate']) {
|
||||
this.duplicateWarning = "A recipe with this URL already exists.";
|
||||
} else {
|
||||
this.duplicateWarning = "";
|
||||
}
|
||||
|
||||
this.loading = false
|
||||
this.recipe_json = response.data['recipe_json'];
|
||||
|
||||
@@ -763,6 +775,16 @@ export default {
|
||||
|
||||
<style>
|
||||
|
||||
.warning {
|
||||
color: rgb(255, 149, 0);
|
||||
align-items: center;
|
||||
background-color: #fff4ec;
|
||||
padding: 10px;
|
||||
border: 1px solid rgb(255, 149, 0);
|
||||
border-radius: 5px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.bounce {
|
||||
animation: bounce 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
|
||||
transform: translate3d(0, 0, 0);
|
||||
|
||||
@@ -238,7 +238,7 @@ export default {
|
||||
|
||||
if (e.recipe_mealplan !== null) {
|
||||
let recipe_name = e.recipe_mealplan.recipe_name
|
||||
if (recipes.indexOf(recipe_name) === -1) {
|
||||
if (recipes.indexOf(recipe_name) === -1 && recipe_name !== undefined) {
|
||||
recipes.push(recipe_name.substring(0, 14) + (recipe_name.length > 14 ? '..' : ''))
|
||||
}
|
||||
|
||||
|
||||
@@ -539,5 +539,34 @@
|
||||
"err_importing_recipe": "Der opstod en fejl under importeringen af opskriften!",
|
||||
"Properties_Food_Amount": "Egenskaber Ingrediens Mængde",
|
||||
"FDC_Search": "FDC søgning",
|
||||
"Calculator": "Lommeregner"
|
||||
"Calculator": "Lommeregner",
|
||||
"Undo": "Fortryd",
|
||||
"NoMoreUndo": "Ingen ændringer at fortryde.",
|
||||
"Input": "Input",
|
||||
"Delete_All": "Slet alle",
|
||||
"CustomNavLogoHelp": "Upload et billede til brug som navigationsbarrelogo.",
|
||||
"ShowRecentlyCompleted": "Vis nyligt gennemførte emner",
|
||||
"ShoppingBackgroundSyncWarning": "Dårligt netværk, afventer synkronisering ...",
|
||||
"CustomTheme": "Personaliseret tema",
|
||||
"CustomThemeHelp": "Overskriv det valgte temas stil ved at uploade en personlig CSS-fil.",
|
||||
"property_type_fdc_hint": "Kun egenskabstyper med et FDC ID kan automatisk trække data fra FDC databasen",
|
||||
"Property_Editor": "Egenskabsredaktør",
|
||||
"us_cup": "cup (US, volumen)",
|
||||
"Show_Logo_Help": "Vis Tandoor eller område-logo i navigationsbarre.",
|
||||
"Nav_Text_Mode": "Navigation textmodus",
|
||||
"Nav_Text_Mode_Help": "Opfører sig forskelligt for hvert tema.",
|
||||
"Shopping_input_placeholder": "Fx kartoffel/100 kartofler/100g kartofler",
|
||||
"CustomImageHelp": "Upload et billede for at vise dets plade i område-oversigten.",
|
||||
"CustomLogoHelp": "Upload kvadratiske billeder i forskellige størrelser for at ændre logoet i browser-faneblad og installeret web-app.",
|
||||
"CustomLogos": "Personlige logoer",
|
||||
"Updated": "Opdateret",
|
||||
"Unchanged": "Uændret",
|
||||
"Error": "Fejl",
|
||||
"Logo": "Logo",
|
||||
"Show_Logo": "Vis logo",
|
||||
"Space_Cosmetic_Settings": "Visse kosmetiske indstillinger kan ændres af område-administratorer og vil overskrive klient-indstillinger for pågældende område.",
|
||||
"Enable": "Aktiver",
|
||||
"created_by": "Skabt af",
|
||||
"Created": "Skabt",
|
||||
"DefaultPage": "Startside"
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
"Next_Day": "Naslednji Dan",
|
||||
"Previous_Day": "Prejšnji Dan",
|
||||
"Coming_Soon": "Kmalu",
|
||||
"Auto_Planner": "Avto-planer",
|
||||
"Auto_Planner": "Samodejni planer",
|
||||
"New_Cookbook": "Nova kuharska knjiga",
|
||||
"Hide_Keyword": "Skrij ključne besede",
|
||||
"Clear": "Počisti",
|
||||
@@ -215,7 +215,7 @@
|
||||
"RemoveFoodFromShopping": "Odstrani {food} iz nakupovalnega listka",
|
||||
"SupermarketCategoriesOnly": "Prikaži samo trgovinske kategorije",
|
||||
"DelayFor": "Zamakni za {hours} ur",
|
||||
"OfflineAlert": "Si v offline načinu, nakupovalni listek se mogoče ne bo sinhroniziral.",
|
||||
"OfflineAlert": "Si v načinu brez povezave, nakupovalni listek se mogoče ne bo sinhroniziral.",
|
||||
"shopping_share_desc": "Uporabniki bodo videli vse elemente, ki si jih dodal v nakupovalni listek. Morajo te dodati, da vidiš njihove elemente na listku.",
|
||||
"shopping_auto_sync_desc": "Nastavitev na 0 bo onemogoča avtomatsko sinhronizacijo. Pri ogledu nakupovalnega seznama se seznam posodablja vsakih nekaj sekund za sinhronizacijo sprememb, ki jih je morda naredil nekdo drug. Uporabno pri nakupovanju z več ljudmi, vendar bo uporabljalo mobilne podatke.",
|
||||
"filter_to_supermarket_desc": "Privzeto, razvrsti nakupovalni listek, da vključi samo označene trgovine.",
|
||||
@@ -225,7 +225,7 @@
|
||||
"success_moving_resource": "Premikanje vira je bilo uspešno!",
|
||||
"success_merging_resource": "Združevanje vira je bilo uspešno!",
|
||||
"Added_by": "Dodano s strani",
|
||||
"AddToShopping": "Dodaj nakupovlanemu listku",
|
||||
"AddToShopping": "Dodaj nakupovalnemu listku",
|
||||
"NotInShopping": "{food} ni v tvojem nakupovalnem listku.",
|
||||
"OnHand": "Trenutno imam v roki",
|
||||
"FoodOnHand": "Imaš {food} v roki.",
|
||||
@@ -247,8 +247,8 @@
|
||||
"ShowDelayed": "Pokaži odložene elemente",
|
||||
"Completed": "Končano",
|
||||
"shopping_share": "Deli nakupovalni listek",
|
||||
"shopping_auto_sync": "Avtomatska sinhronizacija",
|
||||
"mealplan_autoadd_shopping": "Avtomatsko dodaj obrok v načrt",
|
||||
"shopping_auto_sync": "Samodejna sinhronizacija",
|
||||
"mealplan_autoadd_shopping": "Samodejno dodaj obrok v načrt",
|
||||
"mealplan_autoexclude_onhand": "Izključi hrano v roki",
|
||||
"mealplan_autoinclude_related": "Dodaj povezane recepte",
|
||||
"default_delay": "Privzete ure za zamik",
|
||||
@@ -275,7 +275,7 @@
|
||||
"copy_markdown_table": "Kopiraj kot Markdown tabela",
|
||||
"in_shopping": "V nakupovalnem listku",
|
||||
"DelayUntil": "Zamakni do",
|
||||
"shopping_add_onhand": "Avtomatsko v roki",
|
||||
"shopping_add_onhand": "Samodejno v roki",
|
||||
"related_recipes": "Povezani recepti",
|
||||
"today_recipes": "Današnji recepti",
|
||||
"mark_complete": "Označi končano",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"all_fields_optional": "Всі поля опціональні і можна залишити їх пустими.",
|
||||
"convert_internal": "Конвертувати у внутрішній рецепт",
|
||||
"show_only_internal": "Показати тільки внутрішні рецепти",
|
||||
"show_split_screen": "",
|
||||
"show_split_screen": "Розділений перегляд",
|
||||
"Log_Recipe_Cooking": "",
|
||||
"External_Recipe_Image": "Зображення Зовнішнього Рецепту",
|
||||
"Add_to_Shopping": "Додати до Покупок",
|
||||
@@ -437,5 +437,8 @@
|
||||
"Use_Fractions_Help": "Автоматично конвертувати десятки в дроби, коли дивитесь рецепт.",
|
||||
"Copy Link": "Скопіювати Посилання",
|
||||
"Original_Text": "Оригінальний текст",
|
||||
"Default_Unit": "Одиниця замовчуванням"
|
||||
"Default_Unit": "Одиниця замовчуванням",
|
||||
"recipe_property_info": "Ви також можете додати властивості до продуктів, щоб розрахувати їх автоматично на основі вашого рецепту!",
|
||||
"per_serving": "на порцію",
|
||||
"err_importing_recipe": "Виникла помилка при імпортуванні рецепту!"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user