Merge branch 'develop' into json_import

This commit is contained in:
vabene1111
2021-05-01 22:39:20 +02:00
committed by GitHub
47 changed files with 2451 additions and 861 deletions

2
.idea/recipes.iml generated
View File

@ -6,7 +6,7 @@
<option name="rootFolder" value="$MODULE_DIR$" />
<option name="settingsModule" value="recipes/settings.py" />
<option name="manageScript" value="$MODULE_DIR$/manage.py" />
<option name="environment" value="&lt;map&gt;&#10; &lt;entry&gt;&#10; &lt;string&gt;POSTGRES_USER&lt;/string&gt;&#10; &lt;string&gt;postgres&lt;/string&gt;&#10; &lt;/entry&gt;&#10; &lt;entry&gt;&#10; &lt;string&gt;POSTGRES_HOST&lt;/string&gt;&#10; &lt;string&gt;localhost&lt;/string&gt;&#10; &lt;/entry&gt;&#10; &lt;entry&gt;&#10; &lt;string&gt;DB_ENGINE&lt;/string&gt;&#10; &lt;string&gt;django.db.backends.postgresql_psycopg2&lt;/string&gt;&#10; &lt;/entry&gt;&#10; &lt;entry&gt;&#10; &lt;string&gt;POSTGRES_PORT&lt;/string&gt;&#10; &lt;string&gt;5432&lt;/string&gt;&#10; &lt;/entry&gt;&#10; &lt;entry&gt;&#10; &lt;string&gt;POSTGRES_PASSWORD&lt;/string&gt;&#10; &lt;string&gt;Computer1234&lt;/string&gt;&#10; &lt;/entry&gt;&#10; &lt;entry&gt;&#10; &lt;string&gt;POSTGRES_DB&lt;/string&gt;&#10; &lt;string&gt;recipes_db&lt;/string&gt;&#10; &lt;/entry&gt;&#10;&lt;/map&gt;" />
<option name="environment" value="&lt;map/&gt;" />
<option name="doNotUseTestRunner" value="false" />
<option name="trackFilePattern" value="migrations" />
</configuration>

View File

@ -0,0 +1,66 @@
from datetime import datetime, timedelta
from functools import reduce
from django.contrib.postgres.search import TrigramSimilarity
from django.db.models import Q
from cookbook.models import ViewLog
from recipes import settings
def search_recipes(request, queryset, params):
search_string = params.get('query', '')
search_keywords = params.getlist('keywords', [])
search_foods = params.getlist('foods', [])
search_books = params.getlist('books', [])
search_keywords_or = params.get('keywords_or', True)
search_foods_or = params.get('foods_or', True)
search_books_or = params.get('books_or', True)
search_internal = params.get('internal', None)
search_random = params.get('random', False)
search_last_viewed = int(params.get('last_viewed', 0))
if search_last_viewed > 0:
last_viewed_recipes = ViewLog.objects.filter(created_by=request.user, space=request.space, created_at__gte=datetime.now() - timedelta(days=14)).values_list('recipe__pk', flat=True).distinct()
return queryset.filter(pk__in=list(set(last_viewed_recipes))[-search_last_viewed:])
if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
'django.db.backends.postgresql']:
queryset = queryset.annotate(similarity=TrigramSimilarity('name', search_string), ).filter(
Q(similarity__gt=0.1) | Q(name__unaccent__icontains=search_string)).order_by('-similarity')
else:
queryset = queryset.filter(name__icontains=search_string)
if len(search_keywords) > 0:
if search_keywords_or == 'true':
queryset = queryset.filter(keywords__id__in=search_keywords)
else:
for k in search_keywords:
queryset = queryset.filter(keywords__id=k)
if len(search_foods) > 0:
if search_foods_or == 'true':
queryset = queryset.filter(steps__ingredients__food__id__in=search_foods)
else:
for k in search_foods:
queryset = queryset.filter(steps__ingredients__food__id=k)
if len(search_books) > 0:
if search_books_or == 'true':
queryset = queryset.filter(recipebookentry__book__id__in=search_books)
else:
for k in search_books:
queryset = queryset.filter(recipebookentry__book__id=k)
queryset = queryset.distinct()
if search_internal == 'true':
queryset = queryset.filter(internal=True)
if search_random == 'true':
queryset = queryset.order_by("?")
return queryset

View File

@ -20,8 +20,10 @@ class Paprika(Integration):
recipe_json = json.loads(recipe_zip.read().decode("utf-8"))
recipe = Recipe.objects.create(
name=recipe_json['name'].strip(), description=recipe_json['description'].strip(),
created_by=self.request.user, internal=True, space=self.request.space)
name=recipe_json['name'].strip(), created_by=self.request.user, internal=True, space=self.request.space)
if 'description' in recipe_json:
recipe.description = recipe_json['description'].strip()
try:
if re.match(r'([0-9])+\s(.)*', recipe_json['servings'] ):
@ -40,14 +42,17 @@ class Paprika(Integration):
recipe.save()
instructions = recipe_json['directions']
if len(recipe_json['notes'].strip()) > 0:
if recipe_json['notes'] and len(recipe_json['notes'].strip()) > 0:
instructions += '\n\n### ' + _('Notes') + ' \n' + recipe_json['notes']
if len(recipe_json['nutritional_info'].strip()) > 0:
if recipe_json['nutritional_info'] and len(recipe_json['nutritional_info'].strip()) > 0:
instructions += '\n\n### ' + _('Nutritional Information') + ' \n' + recipe_json['nutritional_info']
try:
if len(recipe_json['source'].strip()) > 0 or len(recipe_json['source_url'].strip()) > 0:
instructions += '\n\n### ' + _('Source') + ' \n' + recipe_json['source'].strip() + ' \n' + recipe_json['source_url'].strip()
except AttributeError:
pass
step = Step.objects.create(
instruction=instructions
@ -58,6 +63,7 @@ class Paprika(Integration):
keyword, created = Keyword.objects.get_or_create(name=c.strip(), space=self.request.space)
recipe.keywords.add(keyword)
try:
for ingredient in recipe_json['ingredients'].split('\n'):
if len(ingredient.strip()) > 0:
amount, unit, ingredient, note = parse(ingredient)
@ -66,8 +72,12 @@ class Paprika(Integration):
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note
))
except AttributeError:
pass
recipe.steps.add(step)
if recipe_json.get("photo_data", None):
self.import_recipe_image(recipe, BytesIO(base64.b64decode(recipe_json['photo_data'])))
return recipe

View File

@ -15,8 +15,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-04-11 15:09+0200\n"
"PO-Revision-Date: 2021-04-11 15:23+0000\n"
"Last-Translator: vabene1111 <vabene1234@googlemail.com>\n"
"PO-Revision-Date: 2021-05-01 13:01+0000\n"
"Last-Translator: Marcel Paluch <marcelpaluch@icloud.com>\n"
"Language-Team: German <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/de/>\n"
"Language: de\n"
@ -43,7 +43,9 @@ msgstr ""
#: .\cookbook\forms.py:46
msgid "Default Unit to be used when inserting a new ingredient into a recipe."
msgstr "Standardeinheit für neue Zutaten."
msgstr ""
"Standardeinheit, die beim Einfügen einer neuen Zutat in ein Rezept zu "
"verwenden ist."
#: .\cookbook\forms.py:47
msgid ""
@ -71,7 +73,9 @@ msgstr "Anzahl an Dezimalstellen, auf die gerundet werden soll."
#: .\cookbook\forms.py:51
msgid "If you want to be able to create and see comments underneath recipes."
msgstr "Ob Kommentare unter Rezepten erstellt und angesehen werden können."
msgstr ""
"Wenn du in der Lage sein willst, Kommentare unter Rezepten zu erstellen und "
"zu sehen."
#: .\cookbook\forms.py:53
msgid ""
@ -94,7 +98,7 @@ msgid ""
"Both fields are optional. If none are given the username will be displayed "
"instead"
msgstr ""
"Beide Felder sind optional, wenn keins von beiden gegeben ist, wird der "
"Beide Felder sind optional. Wenn keins von beiden gegeben ist, wird der "
"Nutzername angezeigt"
#: .\cookbook\forms.py:93 .\cookbook\forms.py:315
@ -123,7 +127,7 @@ msgstr "Pfad"
#: .\cookbook\forms.py:98
msgid "Storage UID"
msgstr "Speicher-ID"
msgstr "Speicher-UID"
#: .\cookbook\forms.py:121
msgid "Default"
@ -135,7 +139,7 @@ msgid ""
"ignored. Check this box to import everything."
msgstr ""
"Um Duplikate zu vermeiden werden Rezepte mit dem gleichen Namen ignoriert. "
"Checken sie diese Box um alle Rezepte zu importieren."
"Aktivieren Sie dieses Kontrollkästchen, um alles zu importieren."
#: .\cookbook\forms.py:149
msgid "New Unit"
@ -204,8 +208,8 @@ msgstr "Mindestens ein Rezept oder ein Titel müssen angegeben werden."
#: .\cookbook\forms.py:367
msgid "You can list default users to share recipes with in the settings."
msgstr ""
"Benutzer, mit denen neue Rezepte standardmäßig geteilt werden sollen, können "
"in den Einstellungen angegeben werden."
"Sie können in den Einstellungen Standardbenutzer auflisten, für die Sie "
"Rezepte freigeben möchten."
#: .\cookbook\forms.py:368
#: .\cookbook\templates\forms\edit_internal_recipe.html:377
@ -213,8 +217,8 @@ msgid ""
"You can use markdown to format this field. See the <a href=\"/docs/markdown/"
"\">docs here</a>"
msgstr ""
"Markdown kann genutzt werden, um dieses Feld zu formatieren. Siehe <a href="
"\"/docs/markdown/\">hier</a> für weitere Information."
"Markdown kann genutzt werden, um dieses Feld zu formatieren. Siehe <a href=\""
"/docs/markdown/\">hier</a> für weitere Information"
#: .\cookbook\forms.py:393
msgid "A username is not required, if left blank the new user can choose one."
@ -309,8 +313,9 @@ msgstr "Quelle"
#: .\cookbook\templates\forms\edit_internal_recipe.html:75
#: .\cookbook\templates\include\log_cooking.html:16
#: .\cookbook\templates\url_import.html:84
#, fuzzy
msgid "Servings"
msgstr "Portion(en)"
msgstr "Portionen"
#: .\cookbook\integration\safron.py:25
msgid "Waiting time"

View File

@ -13,7 +13,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-04-11 15:09+0200\n"
"PO-Revision-Date: 2021-04-12 20:22+0000\n"
"PO-Revision-Date: 2021-04-22 18:29+0000\n"
"Last-Translator: Jesse <jesse.kamps@pm.me>\n"
"Language-Team: Dutch <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/nl/>\n"
@ -96,7 +96,7 @@ msgid ""
"instead"
msgstr ""
"Beide velden zijn optioneel. Indien niks is opgegeven wordt de "
"gebruikersnaam weergegeven."
"gebruikersnaam weergegeven"
#: .\cookbook\forms.py:93 .\cookbook\forms.py:315
#: .\cookbook\templates\forms\edit_internal_recipe.html:45

View File

@ -115,8 +115,9 @@ class UserPreference(models.Model, PermissionModelMixin):
# Search Style
SMALL = 'SMALL'
LARGE = 'LARGE'
NEW = 'NEW'
SEARCH_STYLE = ((SMALL, _('Small')), (LARGE, _('Large')),)
SEARCH_STYLE = ((SMALL, _('Small')), (LARGE, _('Large')), (NEW, _('New')))
user = AutoOneToOneField(User, on_delete=models.CASCADE, primary_key=True)
theme = models.CharField(choices=THEMES, max_length=128, default=FLATLY)
@ -420,6 +421,9 @@ class Comment(models.Model, PermissionModelMixin):
def get_space_key():
return 'recipe', 'space'
def get_space(self):
return self.recipe.space
def __str__(self):
return self.text

View File

@ -269,6 +269,12 @@ class NutritionInformationSerializer(serializers.ModelSerializer):
class RecipeOverviewSerializer(WritableNestedModelSerializer):
keywords = KeywordLabelSerializer(many=True)
def create(self, validated_data):
pass
def update(self, instance, validated_data):
return instance
class Meta:
model = Recipe
fields = (
@ -342,7 +348,8 @@ class RecipeBookEntrySerializer(serializers.ModelSerializer):
fields = ('id', 'book', 'recipe',)
class MealPlanSerializer(SpacedModelSerializer):
class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
recipe = RecipeOverviewSerializer(required=False)
recipe_name = serializers.ReadOnlyField(source='recipe.name')
meal_type_name = serializers.ReadOnlyField(source='meal_type.name')
note_markdown = serializers.SerializerMethodField('get_note_markdown')

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -27,6 +27,5 @@
window.IMPORT_ID = {{pk}};
</script>
{% render_bundle 'chunk-vendors' %}
{% render_bundle 'import_response_view' %}
{% endblock %}

View File

@ -509,28 +509,28 @@
this.getRecipes();
},
getRecipes: function () {
let url = "{% url 'api:recipe-list' %}?limit=5"
let url = "{% url 'api:recipe-list' %}?page_size=5"
if (this.recipe_query !== '') {
url += '&query=' + this.recipe_query;
} else {
url += '&random=True'
url += '&random=true'
}
this.$http.get(url).then((response) => {
this.recipes = response.data;
this.recipes = response.data.results;
}).catch((err) => {
console.log("getRecipes error: ", err);
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
})
},
getMdNote: function () {
let url = "{% url 'api:recipe-list' %}?limit=5"
let url = "{% url 'api:recipe-list' %}?page_size=5"
if (this.recipe_query !== '') {
url += '&query=' + this.recipe_query;
}
this.$http.get(url).then((response) => {
this.recipes = response.data;
this.recipes = response.data.results;
}).catch((err) => {
console.log("getRecipes error: ", err);
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
@ -627,13 +627,14 @@
cloneRecipe: function (recipe) {
let r = {
id: Math.round(Math.random() * 1000) + 10000,
recipe: recipe.id,
recipe: recipe,
recipe_name: recipe.name,
servings: (this.new_note_servings > 1) ? this.new_note_servings : recipe.servings,
title: this.new_note_title,
note: this.new_note_text,
is_new: true
}
console.log(recipe)
this.new_note_title = ''
this.new_note_text = ''
@ -669,7 +670,7 @@
}
},
planDetailRecipeUrl: function () {
return "{% url 'view_recipe' 12345 %}".replace(/12345/, this.plan_detail.recipe);
return "{% url 'view_recipe' 12345 %}".replace(/12345/, this.plan_detail.recipe.id);
},
planDetailEditUrl: function () {
return "{% url 'edit_meal_plan' 12345 %}".replace(/12345/, this.plan_detail.id);

View File

@ -38,8 +38,6 @@
<script src="{% static 'django_js_reverse/reverse.js' %}"></script>
{% endif %}
{% render_bundle 'chunk-vendors' %}
<!--
yes this is a stupid solution! i need to figure out a better way to do this but the version hashes
of djangos static files prevent my from simply using preCacheAndRoute

View File

@ -70,6 +70,5 @@
}
</script>
{% render_bundle 'chunk-vendors' %}
{% render_bundle 'recipe_view' %}
{% endblock %}

View File

@ -8,7 +8,7 @@
{% block content_fluid %}
<div id="app">
<div id="app" >
<recipe-search-view></recipe-search-view>
</div>
@ -24,9 +24,8 @@
{% endif %}
<script type="application/javascript">
window.IMAGE_PLACEHOLDER = "{% static 'assets/recipe_no_image.svg' %}"
</script>
{% render_bundle 'chunk-vendors' %}
{% render_bundle 'recipe_search_view' %}
{% endblock %}

View File

@ -825,7 +825,7 @@
}
},
getRecipes: function () {
let url = "{% url 'api:recipe-list' %}?limit=5&internal=true"
let url = "{% url 'api:recipe-list' %}?page_size=5&internal=true"
if (this.recipe_query !== '') {
url += '&query=' + this.recipe_query;
} else {
@ -834,7 +834,7 @@
}
this.$http.get(url).then((response) => {
this.recipes = response.data;
this.recipes = response.data.results;
}).catch((err) => {
console.log("getRecipes error: ", err);
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')

File diff suppressed because one or more lines are too long

View File

@ -19,12 +19,14 @@ def meal_type(space_1, u1_s1):
@pytest.fixture()
def obj_1(space_1, recipe_1_s1, meal_type, u1_s1):
return MealPlan.objects.create(recipe=recipe_1_s1, space=space_1, meal_type=meal_type, date=datetime.now(), created_by=auth.get_user(u1_s1))
return MealPlan.objects.create(recipe=recipe_1_s1, space=space_1, meal_type=meal_type, date=datetime.now(),
created_by=auth.get_user(u1_s1))
@pytest.fixture
def obj_2(space_1, recipe_1_s1, meal_type, u1_s1):
return MealPlan.objects.create(recipe=recipe_1_s1, space=space_1, meal_type=meal_type, date=datetime.now(), created_by=auth.get_user(u1_s1))
return MealPlan.objects.create(recipe=recipe_1_s1, space=space_1, meal_type=meal_type, date=datetime.now(),
created_by=auth.get_user(u1_s1))
@pytest.mark.parametrize("arg", [
@ -55,13 +57,16 @@ def test_list_filter(obj_1, u1_s1):
response = json.loads(r.content)
assert len(response) == 1
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?from_date={(datetime.now() + timedelta(days=2)).strftime("%Y-%m-%d")}').content)
response = json.loads(
u1_s1.get(f'{reverse(LIST_URL)}?from_date={(datetime.now() + timedelta(days=2)).strftime("%Y-%m-%d")}').content)
assert len(response) == 0
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?to_date={(datetime.now() - timedelta(days=2)).strftime("%Y-%m-%d")}').content)
response = json.loads(
u1_s1.get(f'{reverse(LIST_URL)}?to_date={(datetime.now() - timedelta(days=2)).strftime("%Y-%m-%d")}').content)
assert len(response) == 0
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?from_date={(datetime.now() - timedelta(days=2)).strftime("%Y-%m-%d")}&to_date={(datetime.now() + timedelta(days=2)).strftime("%Y-%m-%d")}').content)
response = json.loads(u1_s1.get(
f'{reverse(LIST_URL)}?from_date={(datetime.now() - timedelta(days=2)).strftime("%Y-%m-%d")}&to_date={(datetime.now() + timedelta(days=2)).strftime("%Y-%m-%d")}').content)
assert len(response) == 1
@ -100,10 +105,12 @@ def test_add(arg, request, u1_s2, recipe_1_s1, meal_type):
c = request.getfixturevalue(arg[0])
r = c.post(
reverse(LIST_URL),
{'recipe': recipe_1_s1.id, 'meal_type': meal_type.id, 'date': (datetime.now()).strftime("%Y-%m-%d"), 'servings': 1, 'title': 'test'},
{'recipe': {'id': recipe_1_s1.id, 'name': recipe_1_s1.name, 'keywords': []}, 'meal_type': meal_type.id,
'date': (datetime.now()).strftime("%Y-%m-%d"), 'servings': 1, 'title': 'test'},
content_type='application/json'
)
response = json.loads(r.content)
print(response)
assert r.status_code == arg[1]
if r.status_code == 201:
assert response['title'] == 'test'

View File

@ -22,15 +22,15 @@ def test_list_permission(arg, request):
def test_list_space(recipe_1_s1, u1_s1, u1_s2, space_2):
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 1
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)['results']) == 1
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)['results']) == 0
with scopes_disabled():
recipe_1_s1.space = space_2
recipe_1_s1.save()
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 0
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 1
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)['results']) == 0
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)['results']) == 1
@pytest.mark.parametrize("arg", [

View File

@ -16,19 +16,33 @@ from django.http import FileResponse, HttpResponse, JsonResponse
from django.shortcuts import redirect, get_object_or_404
from django.utils.translation import gettext as _
from icalendar import Calendar, Event
from rest_framework import decorators, viewsets
from rest_framework.exceptions import APIException, PermissionDenied
from recipe_scrapers import scrape_me, WebsiteNotImplementedError, NoSchemaFoundInWildMode
from rest_framework import decorators, viewsets
from rest_framework.exceptions import APIException, PermissionDenied
from rest_framework.pagination import PageNumberPagination
from rest_framework.parsers import MultiPartParser
from rest_framework.response import Response
from rest_framework.schemas.openapi import AutoSchema
from rest_framework.schemas.utils import is_list_view
from rest_framework.viewsets import ViewSetMixin
from cookbook.helper.ingredient_parser import parse
from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest,
CustomIsOwner, CustomIsShare,
CustomIsShared, CustomIsUser,
group_required, share_link_valid)
from cookbook.helper.recipe_html_import import get_recipe_from_source
from cookbook.helper.recipe_url_import import get_from_scraper
group_required)
from cookbook.helper.recipe_search import search_recipes
from cookbook.helper.recipe_url_import import get_from_html, get_from_scraper, find_recipe_json
from cookbook.models import (CookLog, Food, Ingredient, Keyword, MealPlan,
MealType, Recipe, RecipeBook, ShoppingList,
ShoppingListEntry, ShoppingListRecipe, Step,
@ -48,9 +62,9 @@ from cookbook.serializer import (FoodSerializer, IngredientSerializer,
StorageSerializer, SyncLogSerializer,
SyncSerializer, UnitSerializer,
UserNameSerializer, UserPreferenceSerializer,
ViewLogSerializer, CookLogSerializer, RecipeBookEntrySerializer, RecipeOverviewSerializer, SupermarketSerializer, ImportLogSerializer)
ViewLogSerializer, CookLogSerializer, RecipeBookEntrySerializer,
RecipeOverviewSerializer, SupermarketSerializer, ImportLogSerializer)
from recipes.settings import DEMO
from recipe_scrapers import scrape_me, WebsiteNotImplementedError, NoSchemaFoundInWildMode
class StandardFilterMixin(ViewSetMixin):
@ -247,7 +261,8 @@ class MealTypeViewSet(viewsets.ModelViewSet):
permission_classes = [CustomIsOwner]
def get_queryset(self):
queryset = self.queryset.order_by('order', 'id').filter(created_by=self.request.user).filter(space=self.request.space).all()
queryset = self.queryset.order_by('order', 'id').filter(created_by=self.request.user).filter(
space=self.request.space).all()
return queryset
@ -269,33 +284,88 @@ class StepViewSet(viewsets.ModelViewSet):
return self.queryset.filter(recipe__space=self.request.space)
class RecipeViewSet(viewsets.ModelViewSet, StandardFilterMixin):
"""
list:
optional parameters
class RecipePagination(PageNumberPagination):
page_size = 25
page_size_query_param = 'page_size'
max_page_size = 100
- **query**: search recipes for a string contained
in the recipe name (case in-sensitive)
- **limit**: limits the amount of returned results
"""
# TODO move to separate class to cleanup
class RecipeSchema(AutoSchema):
def get_path_parameters(self, path, method):
if not is_list_view(path, method, self.view):
return []
parameters = super().get_path_parameters(path, method)
parameters.append({
"name": 'query', "in": "query", "required": False,
"description": 'Query string matched (fuzzy) against recipe name. In the future also fulltext search.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'keywords', "in": "query", "required": False,
"description": 'Id of keyword a recipe should have. For multiple repeat parameter.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'foods', "in": "query", "required": False,
"description": 'Id of food a recipe should have. For multiple repeat parameter.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'books', "in": "query", "required": False,
"description": 'Id of book a recipe should have. For multiple repeat parameter.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'keywords_or', "in": "query", "required": False,
"description": 'If recipe should have all (AND) or any (OR) of the provided keywords.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'foods_or', "in": "query", "required": False,
"description": 'If recipe should have all (AND) or any (OR) any of the provided foods.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'books_or', "in": "query", "required": False,
"description": 'If recipe should be in all (AND) or any (OR) any of the provided books.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'internal', "in": "query", "required": False,
"description": 'true or false. If only internal recipes should be returned or not.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'random', "in": "query", "required": False,
"description": 'true or false. returns the results in randomized order.',
'schema': {'type': 'string', },
})
return parameters
class RecipeViewSet(viewsets.ModelViewSet):
queryset = Recipe.objects
serializer_class = RecipeSerializer
# TODO split read and write permission for meal plan guest
permission_classes = [CustomIsShare | CustomIsGuest]
pagination_class = RecipePagination
schema = RecipeSchema()
def get_queryset(self):
share = self.request.query_params.get('share', None)
if not (share and self.detail):
self.queryset = self.queryset.filter(space=self.request.space)
internal = self.request.query_params.get('internal', None)
if internal:
self.queryset = self.queryset.filter(internal=True)
self.queryset = search_recipes(self.request, self.queryset, self.request.GET)
return super().get_queryset()
# TODO write extensive tests for permissions
def get_serializer_class(self):
if self.action == 'list':
return RecipeOverviewSerializer
@ -344,7 +414,9 @@ class ShoppingListRecipeViewSet(viewsets.ModelViewSet):
permission_classes = [CustomIsOwner | CustomIsShared]
def get_queryset(self):
return self.queryset.filter(Q(shoppinglist__created_by=self.request.user) | Q(shoppinglist__shared=self.request.user)).filter(shoppinglist__space=self.request.space).all()
return self.queryset.filter(
Q(shoppinglist__created_by=self.request.user) | Q(shoppinglist__shared=self.request.user)).filter(
shoppinglist__space=self.request.space).all()
class ShoppingListEntryViewSet(viewsets.ModelViewSet):
@ -353,7 +425,9 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet):
permission_classes = [CustomIsOwner | CustomIsShared]
def get_queryset(self):
return self.queryset.filter(Q(shoppinglist__created_by=self.request.user) | Q(shoppinglist__shared=self.request.user)).filter(shoppinglist__space=self.request.space).all()
return self.queryset.filter(
Q(shoppinglist__created_by=self.request.user) | Q(shoppinglist__shared=self.request.user)).filter(
shoppinglist__space=self.request.space).all()
class ShoppingListViewSet(viewsets.ModelViewSet):
@ -362,7 +436,8 @@ class ShoppingListViewSet(viewsets.ModelViewSet):
permission_classes = [CustomIsOwner | CustomIsShared]
def get_queryset(self):
return self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter(space=self.request.space).distinct()
return self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter(
space=self.request.space).distinct()
def get_serializer_class(self):
try:
@ -603,6 +678,7 @@ def recipe_from_source(request):
'images': images,
})
else:
return JsonResponse(
{

View File

@ -14,6 +14,7 @@ from django.utils.translation import gettext as _
from django.utils.translation import ngettext
from django_tables2 import RequestConfig
from PIL import Image, UnidentifiedImageError
from requests.exceptions import MissingSchema
from cookbook.forms import BatchEditForm, SyncForm
from cookbook.helper.permission_helper import group_required, has_group_permission
@ -168,7 +169,7 @@ def import_url(request):
step.ingredients.add(ingredient)
print(ingredient)
if 'image' in data and data['image'] != '':
if 'image' in data and data['image'] != '' and data['image'] is not None:
try:
response = requests.get(data['image'])
img = Image.open(BytesIO(response.content))
@ -187,6 +188,8 @@ def import_url(request):
recipe.save()
except UnidentifiedImageError:
pass
except MissingSchema:
pass
return HttpResponse(reverse('view_recipe', args=[recipe.pk]))

View File

@ -55,6 +55,9 @@ def index(request):
def search(request):
if has_group_permission(request.user, ('guest',)):
if request.user.userpreference.search_style == UserPreference.NEW:
return search_v2(request)
f = RecipeFilter(request.GET,
queryset=Recipe.objects.filter(space=request.user.userpreference.space).all().order_by('name'),
space=request.space)

16
node_modules/.yarn-integrity generated vendored Normal file
View File

@ -0,0 +1,16 @@
{
"systemParams": "win32-x64-83",
"modulesFolders": [
"node_modules"
],
"flags": [],
"linkedModules": [],
"topLevelPatterns": [
"vue-cookies@^1.7.4"
],
"lockfileEntries": {
"vue-cookies@^1.7.4": "https://registry.yarnpkg.com/vue-cookies/-/vue-cookies-1.7.4.tgz#d241d0a0431da0795837651d10b4d73e7c8d3e8d"
},
"files": [],
"artifacts": {}
}

21
node_modules/vue-cookies/LICENSE generated vendored Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2016
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

223
node_modules/vue-cookies/README.md generated vendored Normal file
View File

@ -0,0 +1,223 @@
# vue-cookies
A simple Vue.js plugin for handling browser cookies
## Installation
### Browser
```
<script src="https://unpkg.com/vue/dist/vue.js"></script>
<script src="https://unpkg.com/vue-cookies@1.7.4/vue-cookies.js"></script>
```
### Package Managers
```
npm install vue-cookies --save
// require
var Vue = require('vue')
Vue.use(require('vue-cookies'))
// es2015 module
import Vue from 'vue'
import VueCookies from 'vue-cookies'
Vue.use(VueCookies)
// set default config
Vue.$cookies.config('7d')
// set global cookie
Vue.$cookies.set('theme','default');
Vue.$cookies.set('hover-time','1s');
```
## Api
syntax format: **[this | Vue].$cookies.[method]**
* Set global config
```
$cookies.config(expireTimes[,path[, domain[, secure[, sameSite]]]) // default: expireTimes = 1d, path = '/', domain = '', secure = '', sameSite = 'Lax'
```
* Set a cookie
```
$cookies.set(keyName, value[, expireTimes[, path[, domain[, secure[, sameSite]]]]]) //return this
```
* Get a cookie
```
$cookies.get(keyName) // return value
```
* Remove a cookie
```
$cookies.remove(keyName [, path [, domain]]) // return this
```
* Exist a `cookie name`
```
$cookies.isKey(keyName) // return false or true
```
* Get All `cookie name`
```
$cookies.keys() // return a array
```
## Example Usage
#### set global config
```
// 30 day after, expire
Vue.$cookies.config('30d')
// set secure, only https works
Vue.$cookies.config('7d','','',true)
// 2019-03-13 expire
this.$cookies.config(new Date(2019,03,13).toUTCString())
// 30 day after, expire, '' current path , browser default
this.$cookies.config(60 * 60 * 24 * 30,'');
```
#### support json object
```
var user = { id:1, name:'Journal',session:'25j_7Sl6xDq2Kc3ym0fmrSSk2xV2XkUkX' };
this.$cookies.set('user',user);
// print user name
console.log(this.$cookies.get('user').name)
```
#### set expire times
**Suppose the current time is : Sat, 11 Mar 2017 12:25:57 GMT**
**Following equivalence: 1 day after, expire**
**Support chaining sets together**
``` javascript
// default expire time: 1 day
this.$cookies.set("user_session","25j_7Sl6xDq2Kc3ym0fmrSSk2xV2XkUkX")
// number + d , ignore case
.set("user_session","25j_7Sl6xDq2Kc3ym0fmrSSk2xV2XkUkX","1d")
.set("user_session","25j_7Sl6xDq2Kc3ym0fmrSSk2xV2XkUkX","1D")
// Base of second
.set("user_session","25j_7Sl6xDq2Kc3ym0fmrSSk2xV2XkUkX",60 * 60 * 24)
// input a Date, + 1day
.set("user_session","25j_7Sl6xDq2Kc3ym0fmrSSk2xV2XkUkX", new Date(2017, 03, 12))
// input a date string, + 1day
.set("user_session","25j_7Sl6xDq2Kc3ym0fmrSSk2xV2XkUkX", "Sat, 13 Mar 2017 12:25:57 GMT")
```
#### set expire times, input number type
```
this.$cookies.set("default_unit_second","input_value",1); // 1 second after, expire
this.$cookies.set("default_unit_second","input_value",60 + 30); // 1 minute 30 second after, expire
this.$cookies.set("default_unit_second","input_value",60 * 60 * 12); // 12 hour after, expire
this.$cookies.set("default_unit_second","input_value",60 * 60 * 24 * 30); // 1 month after, expire
```
#### set expire times - end of browser session
```
this.$cookies.set("default_unit_second","input_value",0); // end of session - use 0 or "0"!
```
#### set expire times , input string type
| Unit | full name |
| ----------- | ----------- |
| y | year |
| m | month |
| d | day |
| h | hour |
| min | minute |
| s | second |
**Unit Names Ignore Case**
**not support the combination**
**not support the double value**
```javascript
this.$cookies.set("token","GH1.1.1689020474.1484362313","60s"); // 60 second after, expire
this.$cookies.set("token","GH1.1.1689020474.1484362313","30MIN"); // 30 minute after, expire, ignore case
this.$cookies.set("token","GH1.1.1689020474.1484362313","24d"); // 24 day after, expire
this.$cookies.set("token","GH1.1.1689020474.1484362313","4m"); // 4 month after, expire
this.$cookies.set("token","GH1.1.1689020474.1484362313","16h"); // 16 hour after, expire
this.$cookies.set("token","GH1.1.1689020474.1484362313","3y"); // 3 year after, expire
// input date string
this.$cookies.set('token',"GH1.1.1689020474.1484362313", new Date(2017,3,13).toUTCString());
this.$cookies.set("token","GH1.1.1689020474.1484362313", "Sat, 13 Mar 2017 12:25:57 GMT ");
```
#### set expire support date
```
var date = new Date;
date.setDate(date.getDate() + 1);
this.$cookies.set("token","GH1.1.1689020474.1484362313", date);
```
#### set never expire
```
this.$cookies.set("token","GH1.1.1689020474.1484362313", Infinity); // never expire
// never expire , only -1,Other negative Numbers are invalid
this.$cookies.set("token","GH1.1.1689020474.1484362313", -1);
```
#### remove cookie
```
this.$cookies.set("token",value); // domain.com and *.doamin.com are readable
this.$cookies.remove("token"); // remove token of domain.com and *.doamin.com
this.$cookies.set("token", value, null, null, "domain.com"); // only domain.com are readable
this.$cookies.remove("token", null, "domain.com"); // remove token of domain.com
```
#### set other arguments
```
// set path
this.$cookies.set("use_path_argument","value","1d","/app");
// set domain
this.$cookies.set("use_path_argument","value",null, null, "domain.com"); // default 1 day after,expire
// set secure
this.$cookies.set("use_path_argument","value",null, null, null,true);
// set sameSite - should be one of `None`, `Strict` or `Lax`. Read more https://web.dev/samesite-cookies-explained/
this.$cookies.set("use_path_argument","value",null, null, null, null, "Lax");
```
#### other operation
```
// check a cookie exist
this.$cookies.isKey("token")
// get a cookie
this.$cookies.get("token");
// remove a cookie
this.$cookies.remove("token");
// get all cookie key names, line shows
this.$cookies.keys().join("\n");
// remove all cookie
this.$cookies.keys().forEach(cookie => this.$cookies.remove(cookie))
// vue-cookies global
[this | Vue].$cookies.[method]
```
## Warning
**$cookies key names Cannot be set to ['expires','max-age','path','domain','secure','SameSite']**
## License
[MIT](http://opensource.org/licenses/MIT)
Copyright (c) 2016-present, cmp-cc

29
node_modules/vue-cookies/package.json generated vendored Normal file
View File

@ -0,0 +1,29 @@
{
"name": "vue-cookies",
"version": "1.7.4",
"description": "A simple Vue.js plugin for handling browser cookies",
"main": "vue-cookies.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/cmp-cc/vue-cookies.git"
},
"keywords":[
"javascript",
"vue",
"cookie",
"cookies",
"vue-cookies",
"browser",
"session"
],
"author": "cmp-cc",
"license": "MIT",
"bugs": {
"url": "https://github.com/cmp-cc/vue-cookies/issues"
},
"homepage": "https://github.com/cmp-cc/vue-cookies#readme",
"typings": "types/index.d.ts"
}

51
node_modules/vue-cookies/sample/welcome.html generated vendored Normal file
View File

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<script src="https://unpkg.com/vue/dist/vue.js"></script>
<script src="../vue-cookies.js"></script>
<title>Welcome Username</title>
</head>
<body>
<div id="v-main">
<p v-if="!welcomeValue">
Please enter your name : <input type="text" @keyup.enter="username">
</p>
<p v-else>
Welcome again : {{ welcomeValue }}
<button @click="deleteUser">{{deleteUserText}}</button>
{{deleteUserState}}
</p>
</div>
</body>
<script>
new Vue({
el:'#v-main',
data: function() {
return {
welcomeValue: this.$cookies.get('username'),
deleteUserText : 'Delete Cookie',
deleteUserState:''
}
},
methods: {
username : function(event){
this.welcomeValue = event.target.value;
this.$cookies.set('username', this.welcomeValue)
},
deleteUser: function(){
this.$cookies.remove('username');
this.deleteUserState = '√'
setTimeout(function(){
location.reload()
}, 0.5 * 1000)
}
}
})
</script>
</html>

44
node_modules/vue-cookies/types/index.d.ts generated vendored Normal file
View File

@ -0,0 +1,44 @@
import _Vue from 'vue';
import './vue';
export declare function install(Vue: typeof _Vue): void;
export interface VueCookies {
/**
* Set global config
*/
config(expireTimes: string | number | Date, path?: string, domain?: string, secure?: boolean, sameSite?: string): void;
/**
* Set a cookie
*/
set(keyName: string, value: any, expireTimes?: string | number | Date,
path?: string, domain?: string, secure?: boolean, sameSite?: string): this;
/**
* Get a cookie
*/
get(keyName: string): any;
/**
* Remove a cookie
*/
remove(keyName: string, path?: string, domain?: string): this;
/**
* Exist a cookie name
*/
isKey(keyName: string): boolean;
/**
* Get All cookie name
*/
keys(): string[];
}
declare const _default : {
VueCookies: VueCookies;
install: typeof install;
};
export default _default;

11
node_modules/vue-cookies/types/vue.d.ts generated vendored Normal file
View File

@ -0,0 +1,11 @@
import { VueCookies } from "./index";
declare module "vue/types/vue" {
interface Vue {
$cookies: VueCookies;
}
interface VueConstructor {
$cookies: VueCookies;
}
}

146
node_modules/vue-cookies/vue-cookies.js generated vendored Normal file
View File

@ -0,0 +1,146 @@
/**
* Vue Cookies v1.7.4
* https://github.com/cmp-cc/vue-cookies
*
* Copyright 2016, cmp-cc
* Released under the MIT license
*/
(function () {
var defaultConfig = {
expires: '1d',
path: '; path=/',
domain: '',
secure: '',
sameSite: '; SameSite=Lax'
};
var VueCookies = {
// install of Vue
install: function (Vue) {
Vue.prototype.$cookies = this;
Vue.$cookies = this;
},
config: function (expireTimes, path, domain, secure, sameSite) {
defaultConfig.expires = expireTimes ? expireTimes : '1d';
defaultConfig.path = path ? '; path=' + path : '; path=/';
defaultConfig.domain = domain ? '; domain=' + domain : '';
defaultConfig.secure = secure ? '; Secure' : '';
defaultConfig.sameSite = sameSite ? '; SameSite=' + sameSite : '; SameSite=Lax';
},
get: function (key) {
var value = decodeURIComponent(document.cookie.replace(new RegExp('(?:(?:^|.*;)\\s*' + encodeURIComponent(key).replace(/[\-\.\+\*]/g, '\\$&') + '\\s*\\=\\s*([^;]*).*$)|^.*$'), '$1')) || null;
if (value && value.substring(0, 1) === '{' && value.substring(value.length - 1, value.length) === '}') {
try {
value = JSON.parse(value);
} catch (e) {
return value;
}
}
return value;
},
set: function (key, value, expireTimes, path, domain, secure, sameSite) {
if (!key) {
throw new Error('Cookie name is not find in first argument.');
} else if (/^(?:expires|max\-age|path|domain|secure|SameSite)$/i.test(key)) {
throw new Error('Cookie key name illegality, Cannot be set to ["expires","max-age","path","domain","secure","SameSite"]\t current key name: ' + key);
}
// support json object
if (value && value.constructor === Object) {
value = JSON.stringify(value);
}
var _expires = '';
expireTimes = expireTimes == undefined ? defaultConfig.expires : expireTimes;
if (expireTimes && expireTimes != 0) {
switch (expireTimes.constructor) {
case Number:
if (expireTimes === Infinity || expireTimes === -1) _expires = '; expires=Fri, 31 Dec 9999 23:59:59 GMT';
else _expires = '; max-age=' + expireTimes;
break;
case String:
if (/^(?:\d+(y|m|d|h|min|s))$/i.test(expireTimes)) {
// get capture number group
var _expireTime = expireTimes.replace(/^(\d+)(?:y|m|d|h|min|s)$/i, '$1');
// get capture type group , to lower case
switch (expireTimes.replace(/^(?:\d+)(y|m|d|h|min|s)$/i, '$1').toLowerCase()) {
// Frequency sorting
case 'm':
_expires = '; max-age=' + +_expireTime * 2592000;
break; // 60 * 60 * 24 * 30
case 'd':
_expires = '; max-age=' + +_expireTime * 86400;
break; // 60 * 60 * 24
case 'h':
_expires = '; max-age=' + +_expireTime * 3600;
break; // 60 * 60
case 'min':
_expires = '; max-age=' + +_expireTime * 60;
break; // 60
case 's':
_expires = '; max-age=' + _expireTime;
break;
case 'y':
_expires = '; max-age=' + +_expireTime * 31104000;
break; // 60 * 60 * 24 * 30 * 12
default:
new Error('unknown exception of "set operation"');
}
} else {
_expires = '; expires=' + expireTimes;
}
break;
case Date:
_expires = '; expires=' + expireTimes.toUTCString();
break;
}
}
document.cookie =
encodeURIComponent(key) + '=' + encodeURIComponent(value) +
_expires +
(domain ? '; domain=' + domain : defaultConfig.domain) +
(path ? '; path=' + path : defaultConfig.path) +
(secure == undefined ? defaultConfig.secure : secure ? '; Secure' : '') +
(sameSite == undefined ? defaultConfig.sameSite : (sameSite ? '; SameSite=' + sameSite : ''));
return this;
},
remove: function (key, path, domain) {
if (!key || !this.isKey(key)) {
return false;
}
document.cookie = encodeURIComponent(key) +
'=; expires=Thu, 01 Jan 1970 00:00:00 GMT' +
(domain ? '; domain=' + domain : defaultConfig.domain) +
(path ? '; path=' + path : defaultConfig.path) +
'; SameSite=Lax';
return this;
},
isKey: function (key) {
return (new RegExp('(?:^|;\\s*)' + encodeURIComponent(key).replace(/[\-\.\+\*]/g, '\\$&') + '\\s*\\=')).test(document.cookie);
},
keys: function () {
if (!document.cookie) return [];
var _keys = document.cookie.replace(/((?:^|\s*;)[^\=]+)(?=;|$)|^\s*|\s*(?:\=[^;]*)?(?:\1|$)/g, '').split(/\s*(?:\=[^;]*)?;\s*/);
for (var _index = 0; _index < _keys.length; _index++) {
_keys[_index] = decodeURIComponent(_keys[_index]);
}
return _keys;
}
};
if (typeof exports == 'object') {
module.exports = VueCookies;
} else if (typeof define == 'function' && define.amd) {
define([], function () {
return VueCookies;
});
} else if (window.Vue) {
Vue.use(VueCookies);
}
// vue-cookies can exist independently,no dependencies library
if (typeof window !== 'undefined') {
window.$cookies = VueCookies;
}
})();

5
package.json Normal file
View File

@ -0,0 +1,5 @@
{
"dependencies": {
"vue-cookies": "^1.7.4"
}
}

View File

@ -13,8 +13,10 @@ import ast
import os
from django.contrib import messages
from django.contrib.staticfiles.storage import staticfiles_storage
from django.utils.translation import gettext_lazy as _
from dotenv import load_dotenv
from webpack_loader.loader import WebpackLoader
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@ -192,6 +194,18 @@ DATABASES = {
# Vue webpack settings
VUE_DIR = os.path.join(BASE_DIR, 'vue')
class CustomWebpackLoader(WebpackLoader):
def get_chunk_url(self, chunk):
asset = self.get_assets()['assets'][chunk['name']]
return super().get_chunk_url(asset)
def filter_chunks(self, chunks):
chunks = [chunk if isinstance(chunk, dict) else {'name': chunk} for chunk in chunks]
return super().filter_chunks(chunks)
WEBPACK_LOADER = {
'DEFAULT': {
'CACHE': not DEBUG,
@ -199,7 +213,8 @@ WEBPACK_LOADER = {
'STATS_FILE': os.path.join(VUE_DIR, 'webpack-stats.json'),
'POLL_INTERVAL': 0.1,
'TIMEOUT': None,
'IGNORE': [r'.+\.hot-update.js', r'.+\.map']
'IGNORE': [r'.+\.hot-update.js', r'.+\.map'],
'LOADER_CLASS': 'recipes.settings.CustomWebpackLoader',
}
}

View File

@ -2,7 +2,7 @@ Django==3.2
cryptography==3.4.7
django-annoying==0.10.6
django-autocomplete-light==3.8.2
django-cleanup==5.1.0
django-cleanup==5.2.0
django-crispy-forms==1.11.2
django-emoji-picker==0.0.6
django-filter==2.4.0
@ -16,7 +16,7 @@ lxml==4.6.3
Markdown==3.3.4
Pillow==8.2.0
psycopg2-binary==2.8.6
python-dotenv==0.17.0
python-dotenv==0.17.1
requests==2.25.1
simplejson==3.17.2
six==1.15.0
@ -31,7 +31,7 @@ Jinja2==2.11.3
django-webpack-loader==0.7.0
django-js-reverse==0.9.1
django-allauth==0.44.0
recipe-scrapers==12.2.2
recipe-scrapers==13.1.1
django-scopes==1.2.0
pytest==6.2.3
pytest-django==4.2.0

View File

@ -14,11 +14,13 @@
"moment": "^2.29.1",
"vue": "^2.6.11",
"vue-class-component": "^7.2.3",
"vue-cookies": "^1.7.4",
"vue-i18n": "^8.24.3",
"vue-multiselect": "^2.1.6",
"vue-property-decorator": "^9.1.2",
"vue-template-compiler": "^2.6.12",
"vuex": "^3.6.0"
"vuex": "^3.6.0",
"workbox-webpack-plugin": "^6.1.5"
},
"devDependencies": {
"@kazupon/vue-i18n-loader": "^0.5.0",
@ -32,11 +34,11 @@
"@vue/compiler-sfc": "^3.0.0",
"@vue/eslint-config-typescript": "^7.0.0",
"babel-eslint": "^10.1.0",
"eslint": "^6.7.2",
"eslint": "^7.25.0",
"eslint-plugin-vue": "^7.0.0-0",
"typescript": "~4.1.5",
"typescript": "~4.2.4",
"vue-cli-plugin-i18n": "^2.1.0",
"webpack-bundle-tracker": "0.4.3",
"webpack-bundle-tracker": "1.0.0-alpha.1",
"workbox-expiration": "^6.0.2",
"workbox-navigation-preload": "^6.0.2",
"workbox-precaching": "^6.0.2",

View File

@ -1,8 +1,8 @@
<template>
<div id="app">
<div id="app" style="margin-bottom: 4vh">
<div class="row">
<div class="col-xl-2 d-none d-xl-block">
<div class="col-md-2 d-none d-md-block">
</div>
<div class="col-xl-8 col-12">
@ -10,27 +10,207 @@
<div class="row">
<div class="col col-md-12">
<b-input class="form-control" v-model="search_input" @keyup="refreshData"></b-input>
<b-input-group class="mt-3">
<b-input class="form-control" v-model="search_input" @keyup="refreshData"
v-bind:placeholder="$t('Search')"></b-input>
<b-input-group-append>
<b-button v-b-toggle.collapse_advanced_search variant="primary" class="shadow-none"><i
class="fas fa-caret-down" v-if="!settings.advanced_search_visible"></i><i class="fas fa-caret-up"
v-if="settings.advanced_search_visible"></i>
</b-button>
</b-input-group-append>
</b-input-group>
<b-collapse id="collapse_advanced_search" class="mt-2" v-model="settings.advanced_search_visible">
<div class="card">
<div class="card-body">
<div class="row">
<div class="col-md-3" style="margin-top: 1vh">
<a class="btn btn-primary btn-block text-uppercase"
:href="resolveDjangoUrl('new_recipe')">{{ $t('New_Recipe') }}</a>
</div>
<div class="col-md-3" style="margin-top: 1vh">
<a class="btn btn-primary btn-block text-uppercase"
:href="resolveDjangoUrl('data_import_url')">{{ $t('Url_Import') }}</a>
</div>
<div class="col-md-3" style="margin-top: 1vh">
<button class="btn btn-primary btn-block text-uppercase" @click="resetSearch">
{{ $t('Reset_Search') }}
</button>
</div>
<div class="col-md-2" style="position: relative; margin-top: 1vh">
<b-form-checkbox v-model="search_internal" name="check-button" @change="refreshData"
class="shadow-none"
style="position:relative;top: 50%; transform: translateY(-50%);" switch>
{{ $t('show_only_internal') }}
</b-form-checkbox>
</div>
<div class="col-md-1" style="position: relative; margin-top: 1vh">
<button id="id_settings_button" class="btn btn-primary btn-block"><i class="fas fa-cog"></i></button>
</div>
</div>
<b-popover
target="id_settings_button"
triggers="click"
placement="bottom"
:title="$t('Settings')">
<div>
<b-form-group
v-bind:label="$t('Recently_Viewed')"
label-for="popover-input-1"
label-cols="6"
class="mb-3">
<b-form-input
type="number"
v-model="settings.recently_viewed"
id="popover-input-1"
size="sm"
></b-form-input>
</b-form-group>
<b-form-group
v-bind:label="$t('Meal_Plan')"
label-for="popover-input-2"
label-cols="6"
class="mb-3">
<b-form-checkbox
switch
v-model="settings.show_meal_plan"
id="popover-input-2"
size="sm"
></b-form-checkbox>
</b-form-group>
</div>
<div class="row" style="margin-top: 1vh">
<div class="col-12" style="text-align: right">
<b-button size="sm" variant="secondary" style="margin-right:8px" @click="$root.$emit('bv::hide::popover')">{{$t('Close')}}</b-button>
</div>
</div>
</b-popover>
<div class="row">
<div class="col-12">
<b-input-group style="margin-top: 1vh">
<generic-multiselect @change="genericSelectChanged" parent_variable="search_keywords"
:initial_selection="search_keywords"
search_function="listKeywords" label="label"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
v-bind:placeholder="$t('Keywords')"></generic-multiselect>
<b-input-group-append>
<b-input-group-text>
<b-form-checkbox v-model="settings.search_keywords_or" name="check-button"
@change="refreshData"
class="shadow-none" switch>
<span class="text-uppercase" v-if="settings.search_keywords_or">{{ $t('or') }}</span>
<span class="text-uppercase" v-else>{{ $t('and') }}</span>
</b-form-checkbox>
</b-input-group-text>
</b-input-group-append>
</b-input-group>
</div>
</div>
<div class="row">
<div class="col-12">
<b-input-group style="margin-top: 1vh">
<generic-multiselect @change="genericSelectChanged" parent_variable="search_foods"
:initial_selection="search_foods"
search_function="listFoods" label="name"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
v-bind:placeholder="$t('Ingredients')"></generic-multiselect>
<b-input-group-append>
<b-input-group-text>
<b-form-checkbox v-model="settings.search_foods_or" name="check-button"
@change="refreshData"
class="shadow-none" switch>
<span class="text-uppercase" v-if="settings.search_foods_or">{{ $t('or') }}</span>
<span class="text-uppercase" v-else>{{ $t('and') }}</span>
</b-form-checkbox>
</b-input-group-text>
</b-input-group-append>
</b-input-group>
</div>
</div>
<div class="row">
<div class="col-12">
<b-input-group style="margin-top: 1vh">
<generic-multiselect @change="genericSelectChanged" parent_variable="search_books"
:initial_selection="search_books"
search_function="listRecipeBooks" label="name"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
v-bind:placeholder="$t('Books')"></generic-multiselect>
<b-input-group-append>
<b-input-group-text>
<b-form-checkbox v-model="settings.search_books_or" name="check-button"
@change="refreshData"
class="shadow-none" tyle="width: 100%" switch>
<span class="text-uppercase" v-if="settings.search_books_or">{{ $t('or') }}</span>
<span class="text-uppercase" v-else>{{ $t('and') }}</span>
</b-form-checkbox>
</b-input-group-text>
</b-input-group-append>
</b-input-group>
</div>
</div>
</div>
</div>
</b-collapse>
</div>
</div>
<div class="row" style="margin-top: 2vh">
<div class="col col-md-12">
<b-card-group deck>
<recipe-card style="max-width: 15vw; height: 30vh" v-for="r in recipes" v-bind:key="r.id" :recipe="r"></recipe-card>
</b-card-group>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));grid-gap: 1rem;">
<template
v-if="search_input === '' && search_keywords.length === 0 && search_foods.length === 0 && search_books.length === 0">
<recipe-card v-bind:key="`mp_${m.id}`" v-for="m in meal_plans" :recipe="m.recipe"
:meal_plan="m" :footer_text="m.meal_type_name"
footer_icon="far fa-calendar-alt"></recipe-card>
<recipe-card v-for="r in last_viewed_recipes" v-bind:key="`rv_${r.id}`" :recipe="r"
v-bind:footer_text="$t('Recently_Viewed')" footer_icon="fas fa-eye"></recipe-card>
</template>
<recipe-card v-for="r in recipes" v-bind:key="r.id" :recipe="r"></recipe-card>
</div>
</div>
</div>
</div>
<div class="col-xl-2 d-none d-xl-block">
<div class="row" style="margin-top: 2vh">
<div class="col col-md-12">
<b-pagination
v-model="pagination_page"
:total-rows="pagination_count"
per-page="25"
@change="pageChange"
align="center">
</b-pagination>
</div>
</div>
</div>
<div class="col-md-2 d-none d-md-block">
</div>
</div>
</div>
</template>
@ -40,6 +220,11 @@ import Vue from 'vue'
import {BootstrapVue} from 'bootstrap-vue'
import 'bootstrap-vue/dist/bootstrap-vue.css'
import moment from 'moment'
import VueCookies from 'vue-cookies'
Vue.use(VueCookies)
import {ResolveUrlMixin} from "@/utils/utils";
@ -47,37 +232,149 @@ import LoadingSpinner from "@/components/LoadingSpinner";
import {ApiApiFactory} from "@/utils/openapi/api.ts";
import RecipeCard from "@/components/RecipeCard";
import GenericMultiselect from "@/components/GenericMultiselect";
Vue.use(BootstrapVue)
export default {
name: 'RecipeSearchView',
mixins: [],
components: {RecipeCard},
mixins: [ResolveUrlMixin],
components: {GenericMultiselect, RecipeCard},
data() {
return {
recipes: [],
meal_plans: [],
last_viewed_recipes: [],
search_input: '',
search_internal: false,
search_keywords: [],
search_foods: [],
search_books: [],
settings: {
search_keywords_or: true,
search_foods_or: true,
search_books_or: true,
advanced_search_visible: false,
show_meal_plan: true,
recently_viewed: 5,
},
pagination_count: 0,
pagination_page: 1,
}
},
mounted() {
this.$nextTick(function () {
if (this.$cookies.isKey('search_settings')) {
console.log('loaded cookie settings', this.$cookies.get("search_settings"))
this.settings = this.$cookies.get("search_settings")
}
this.loadMealPlan()
this.loadRecentlyViewed()
})
this.refreshData()
},
watch: {
settings: {
handler() {
this.$cookies.set("search_settings", this.settings, -1)
},
deep: true
},
'settings.show_meal_plan': function () {
console.log('Test')
this.loadMealPlan()
},
'settings.recently_viewed': function () {
console.log('RV')
this.loadRecentlyViewed()
},
},
methods: {
refreshData: function () {
let apiClient = new ApiApiFactory()
apiClient.listRecipes({query: {query: this.search_input, limit: 10}}).then(result => {
this.recipes = result.data
apiClient.listRecipes({
query: {
query: this.search_input,
keywords: this.search_keywords.map(function (A) {
return A["id"];
}),
foods: this.search_foods.map(function (A) {
return A["id"];
}),
books: this.search_books.map(function (A) {
return A["id"];
}),
keywords_or: this.settings.search_keywords_or,
foods_or: this.settings.search_foods_or,
books_or: this.settings.search_books_or,
internal: this.search_internal,
page: this.pagination_page,
}
}).then(result => {
this.recipes = result.data.results
this.pagination_count = result.data.count
})
},
loadMealPlan: function () {
let apiClient = new ApiApiFactory()
if (this.settings.show_meal_plan) {
apiClient.listMealPlans({
query: {
from_date: moment().format('YYYY-MM-DD'),
to_date: moment().format('YYYY-MM-DD')
}
}).then(result => {
this.meal_plans = result.data
})
} else {
this.meal_plans = []
}
},
loadRecentlyViewed: function () {
let apiClient = new ApiApiFactory()
if (this.settings.recently_viewed > 0) {
apiClient.listRecipes({query: {last_viewed: this.settings.recently_viewed}}).then(result => {
this.last_viewed_recipes = result.data.results
})
} else {
this.last_viewed_recipes = []
}
},
genericSelectChanged: function (obj) {
this[obj.var] = obj.val
this.refreshData()
},
resetSearch: function () {
this.search_input = ''
this.search_internal = false
this.search_keywords = []
this.search_foods = []
this.search_books = []
this.refreshData()
},
pageChange: function (page) {
this.pagination_page = page
this.refreshData()
}
}
}
</script>
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
<style>
</style>

View File

@ -0,0 +1,66 @@
<template>
<multiselect
v-model="selected_objects"
:options="objects"
:close-on-select="true"
:clear-on-select="true"
:hide-selected="true"
:preserve-search="true"
:placeholder="placeholder"
:label="label"
track-by="id"
:multiple="true"
:loading="loading"
@search-change="search"
@input="selectionChanged">
</multiselect>
</template>
<script>
import Multiselect from 'vue-multiselect'
import {ApiApiFactory} from "@/utils/openapi/api";
export default {
name: "GenericMultiselect",
components: {Multiselect},
data() {
return {
loading: false,
objects: [],
selected_objects: [],
}
},
props: {
placeholder: String,
search_function: String,
label: String,
parent_variable: String,
initial_selection: Array,
},
watch: {
initial_selection: function (newVal, oldVal) { // watch it
this.selected_objects = newVal
}
},
mounted() {
this.search('')
},
methods: {
search: function (query) {
let apiClient = new ApiApiFactory()
apiClient[this.search_function]({query: {query: query, limit: 10}}).then(result => {
this.objects = result.data
})
},
selectionChanged: function () {
this.$emit('change', {var: this.parent_variable, val: this.selected_objects})
}
}
}
</script>
<style scoped>
</style>

View File

@ -1,7 +1,7 @@
<template>
<div v-if="recipe.keywords.length > 0">
<small :key="k.id" v-for="k in recipe.keywords" style="padding: 2px">
{{k.icon}} {{k.name}}
<b-badge pill variant="light">{{k.label}}</b-badge>
</small>
</div>
</template>

View File

@ -1,31 +1,81 @@
<template>
<div>
<div>
<b-card no-body>
<a :href="clickUrl()">
<b-card-img-lazy style="height: 15vh; object-fit: cover" :src=recipe_image v-bind:alt="$t('Recipe_Image')"
top></b-card-img-lazy>
<b-card-img-lazy :src=recipe.image v-bind:alt="$t('Recipe_Image')" top></b-card-img-lazy>
<div class="card-img-overlay h-100 d-flex flex-column justify-content-right"
style="float:right; text-align: right; padding-top: 10px; padding-right: 5px">
<recipe-context-menu :recipe="recipe" style="float:right" v-if="recipe !== null"></recipe-context-menu>
</div>
<b-card-body :title=recipe.name>
<recipe-context-menu :recipe="recipe"></recipe-context-menu>
<b-card-text>
</a>
<b-card-body>
<h5><a :href="clickUrl()">
<template v-if="recipe !== null">{{ recipe.name }}</template>
<template v-else>{{ meal_plan.title }}</template>
</a></h5>
<b-card-text style="text-overflow: ellipsis">
<template v-if="recipe !== null">
{{ recipe.description }}
<keywords :recipe="recipe" style="margin-top: 4px"></keywords>
<b-badge pill variant="info" v-if="!recipe.internal">{{ $t('External') }}</b-badge>
</template>
<template v-else>{{ meal_plan.note }}</template>
</b-card-text>
</b-card-body>
<b-card-footer v-if="footer_text !== undefined">
<i v-bind:class="footer_icon"></i> {{ footer_text }}
</b-card-footer>
</b-card>
</div>
</div>
</template>
<script>
import RecipeContextMenu from "@/components/RecipeContextMenu";
import Keywords from "@/components/Keywords";
import {resolveDjangoUrl, ResolveUrlMixin} from "@/utils/utils";
export default {
name: "RecipeCard",
components: {RecipeContextMenu},
mixins: [
ResolveUrlMixin,
],
components: {Keywords, RecipeContextMenu},
props: {
recipe: Object,
meal_plan: Object,
footer_text: String,
footer_icon: String,
},
data() {
return {
recipe_image: '',
}
},
mounted() {
if (this.recipe == null || this.recipe.image === null) {
this.recipe_image = window.IMAGE_PLACEHOLDER
} else {
this.recipe_image = this.recipe.image
}
},
methods: {
clickUrl: function () {
if (this.recipe !== null) {
return resolveDjangoUrl('view_recipe', this.recipe.id)
} else {
return resolveDjangoUrl('view_plan_entry', this.meal_plan.id)
}
}
}
}
</script>

View File

@ -19,5 +19,34 @@
"Fats": "Fette",
"Carbohydrates": "Kohlenhydrate",
"Calories": "Kalorien",
"Nutrition": "Nährwerte"
"Nutrition": "Nährwerte",
"Keywords": "Stichwörter",
"Books": "Bücher",
"show_only_internal": "Nur interne Rezepte anzeigen",
"Ingredients": "Zutaten",
"min": "Min",
"Servings": "Portionen",
"Waiting": "Wartezeit",
"Preparation": "Zubereitung",
"Edit": "Bearbeiten",
"Open": "Öffnen",
"Save": "Speichern",
"Step": "Schritt",
"Search": "Suchen",
"Print": "Drucken",
"New_Recipe": "Neues Rezept",
"Url_Import": "URL Import",
"Reset_Search": "Suche zurücksetzen",
"or": "oder",
"and": "und",
"Recently_Viewed": "Kürzlich angesehen",
"External": "Extern",
"Settings": "Einstellungen",
"Meal_Plan": "Speiseplan",
"Date": "Datum",
"Share": "Teilen",
"Export": "Exportieren",
"Rating": "Bewertung",
"Close": "Schließen",
"Add": "Hinzufügen"
}

View File

@ -2,6 +2,9 @@
"import_running": "Import running, please wait!",
"all_fields_optional": "All fields are optional and can be left empty.",
"convert_internal": "Convert to internal recipe",
"show_only_internal": "Show only internal recipes",
"Log_Recipe_Cooking": "Log Recipe Cooking",
"External_Recipe_Image": "External Recipe Image",
@ -10,13 +13,19 @@
"Add_to_Plan": "Add to Plan",
"Step_start_time": "Step start time",
"Meal_Plan": "Meal Plan",
"Select_Book": "Select Book",
"Recipe_Image": "Recipe Image",
"Import_finished": "Import finished",
"View_Recipes": "View Recipes",
"Log_Cooking": "Log Cooking",
"New_Recipe": "New Recipe",
"Url_Import": "Url Import",
"Reset_Search": "Reset Search",
"Recently_Viewed": "Recently Viewed",
"Keywords": "Keywords",
"Books": "Books",
"Proteins": "Proteins",
"Fats": "Fats",
"Carbohydrates": "Carbohydrates",
@ -33,6 +42,7 @@
"Servings": "Servings",
"Waiting": "Waiting",
"Preparation": "Preparation",
"External": "External",
"Edit": "Edit",
"Open": "Open",
"Save": "Save",
@ -40,5 +50,8 @@
"Search": "Search",
"Import": "Import",
"Print": "Print",
"Settings": "Settings",
"or": "or",
"and": "and",
"Information": "Information"
}

View File

@ -13,28 +13,36 @@
"Import_finished": "Importeren gereed",
"View_Recipes": "Bekijk Recepten",
"Log_Cooking": "Log Bereiding",
"Proteins": "Proteïnes",
"Proteins": "Eiwitten",
"Fats": "Vetten",
"Carbohydrates": "Koolhydraten",
"Calories": "Calorieën",
"Nutrition": "Voedingswaarde",
"Date": "Datum",
"Share": "Deel",
"Export": "Exporteer",
"Rating": "Waardering",
"Close": "Sluit",
"Export": "Exporteren",
"Rating": "Beoordeling",
"Close": "Sluiten",
"Add": "Voeg toe",
"Ingredients": "Ingrediënten",
"min": "min",
"Servings": "Porties",
"Waiting": "Wachten",
"Preparation": "Bereiding",
"Edit": "Bewerk",
"Edit": "Bewerken",
"Open": "Open",
"Save": "Sla op",
"Save": "Opslaan",
"Step": "Stap",
"Search": "Zoeken",
"Import": "Importeer",
"Print": "Afdrukken",
"Information": "Informatie"
"Information": "Informatie",
"Keywords": "Etiketten",
"Books": "Boeken",
"show_only_internal": "Toon alleen interne recepten",
"New_Recipe": "Nieuw Recept",
"Url_Import": "Importeer URL",
"Reset_Search": "Zoeken resetten",
"or": "of",
"and": "en"
}

View File

@ -78,9 +78,7 @@ module.exports = {
})
*/
config
.plugin('BundleTracker')
.use(BundleTracker, [{filename: '../vue/webpack-stats.json'}]);
config.plugin('BundleTracker').use(BundleTracker, [{relativePath: true, path: '../vue/'}]);
config.resolve.alias
.set('__STATIC__', 'static')

View File

@ -1 +1 @@
{"status":"done","chunks":{"chunk-vendors":[{"name":"css/chunk-vendors.css","path":"F:\\Developement\\Django\\recipes\\cookbook\\static\\vue\\css\\chunk-vendors.css"},{"name":"js/chunk-vendors.js","path":"F:\\Developement\\Django\\recipes\\cookbook\\static\\vue\\js\\chunk-vendors.js"}],"import_response_view":[{"name":"js/import_response_view.js","path":"F:\\Developement\\Django\\recipes\\cookbook\\static\\vue\\js\\import_response_view.js"}],"offline_view":[{"name":"js/offline_view.js","path":"F:\\Developement\\Django\\recipes\\cookbook\\static\\vue\\js\\offline_view.js"}],"recipe_search_view":[{"name":"js/recipe_search_view.js","path":"F:\\Developement\\Django\\recipes\\cookbook\\static\\vue\\js\\recipe_search_view.js"}],"recipe_view":[{"name":"js/recipe_view.js","path":"F:\\Developement\\Django\\recipes\\cookbook\\static\\vue\\js\\recipe_view.js"}]}}
{"status":"done","chunks":{"recipe_search_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/recipe_search_view.js"],"recipe_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/recipe_view.js"],"offline_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/offline_view.js"],"import_response_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/import_response_view.js"]},"assets":{"../../templates/sw.js":{"name":"../../templates/sw.js","path":"..\\..\\templates\\sw.js"},"css/chunk-vendors.css":{"name":"css/chunk-vendors.css","path":"css\\chunk-vendors.css"},"js/chunk-vendors.js":{"name":"js/chunk-vendors.js","path":"js\\chunk-vendors.js"},"js/import_response_view.js":{"name":"js/import_response_view.js","path":"js\\import_response_view.js"},"js/offline_view.js":{"name":"js/offline_view.js","path":"js\\offline_view.js"},"js/recipe_search_view.js":{"name":"js/recipe_search_view.js","path":"js\\recipe_search_view.js"},"js/recipe_view.js":{"name":"js/recipe_view.js","path":"js\\recipe_view.js"},"recipe_search_view.html":{"name":"recipe_search_view.html","path":"recipe_search_view.html"},"recipe_view.html":{"name":"recipe_view.html","path":"recipe_view.html"},"offline_view.html":{"name":"offline_view.html","path":"offline_view.html"},"import_response_view.html":{"name":"import_response_view.html","path":"import_response_view.html"},"manifest.json":{"name":"manifest.json","path":"manifest.json"}}}

File diff suppressed because it is too large Load Diff

8
yarn.lock Normal file
View File

@ -0,0 +1,8 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
vue-cookies@^1.7.4:
version "1.7.4"
resolved "https://registry.yarnpkg.com/vue-cookies/-/vue-cookies-1.7.4.tgz#d241d0a0431da0795837651d10b4d73e7c8d3e8d"
integrity sha512-mOS5Btr8V9zvAtkmQ7/TfqJIropOx7etDAgBywPCmHjvfJl2gFbH2XgoMghleLoyyMTi5eaJss0mPN7arMoslA==