Compare commits

...

105 Commits
0.8.4 ... 0.9.2

Author SHA1 Message Date
vabene1111
6979bf34d9 fixed insecure uuid defaults
due to some unexpected behavior the share link uuid values were not random. All already created share links were disabled.
2020-06-25 22:01:54 +02:00
vabene1111
2c5e44d73c improved recipe parser, added tests, cleaned up resources 2020-06-24 21:22:23 +02:00
vabene1111
b6d98397b5 improved recipe import 2020-06-24 20:21:48 +02:00
vabene1111
525ad6dd98 removed debug code and cdn dependencies 2020-06-23 20:36:04 +02:00
vabene1111
166833fe83 moved recipe edit button to dropdown menu 2020-06-23 19:01:35 +02:00
vabene1111
752854028a more recipe import fixes 2020-06-23 18:48:22 +02:00
vabene1111
6197cab1ed improved url import [WIP] 2020-06-23 18:13:16 +02:00
vabene1111
73f2240763 fixed import error 2020-06-23 14:00:07 +02:00
vabene1111
3ef82aee9c updated readme 2020-06-23 12:57:05 +02:00
vabene1111
e49e53e2b2 async loading of multiselect fields 2020-06-23 12:54:27 +02:00
vabene1111
0266476aef import page style update 2020-06-23 12:46:13 +02:00
vabene1111
5ed369ba69 recipe import unit/ingredeitn selection 2020-06-23 12:34:00 +02:00
vabene1111
98c278fe60 importing improvements 2020-06-23 11:04:49 +02:00
vabene1111
8594346488 basic importing working 2020-06-23 10:34:04 +02:00
vabene1111
dc91e1e8ed fixed url router and added recipe import test ressources 2020-06-22 23:35:18 +02:00
vabene1111
976dd13a31 basic importing working 2020-06-22 23:23:06 +02:00
vabene1111
8cca272bb9 import UI cleanup 2020-06-22 22:56:56 +02:00
vabene1111
f066b7097c microdata import 2020-06-22 22:47:33 +02:00
vabene1111
71b41a9ca2 refactor json ld code into helper 2020-06-22 21:35:56 +02:00
vabene1111
9e748552b2 recipe url import ld json 2020-06-22 21:16:31 +02:00
vabene1111
743d7bf608 cleanup vue debug settings 2020-06-22 12:23:49 +02:00
vabene1111
315b4521b6 test page 2020-06-22 12:14:24 +02:00
vabene1111
b558ba55b4 fixed mis-aligned menu in recipe view 2020-06-22 11:12:26 +02:00
vabene1111
0368630c92 added more view tests 2020-06-17 15:25:16 +02:00
vabene1111
02c1ba0c71 structured and extended test 2020-06-17 15:06:29 +02:00
vabene1111
83cc8832cb improved duplicate testing code 2020-06-17 14:53:00 +02:00
vabene1111
14a5d43dc8 added complete test for user preference api 2020-06-17 14:33:55 +02:00
vabene1111
bea079dd05 further permission cleanup 2020-06-17 13:23:04 +02:00
vabene1111
df8170fa55 improved permission handlin 2020-06-17 13:18:28 +02:00
vabene1111
2904d5938d fixed sync create permission 2020-06-17 13:00:13 +02:00
vabene1111
18bfecb026 fixed DRF object permission 2020-06-17 12:12:42 +02:00
vabene1111
4ee5a4fd9f testing with user permission 2020-06-17 11:06:08 +02:00
vabene1111
bbaedfad33 documentation update api 2020-06-17 10:29:01 +02:00
vabene1111
de413f1473 custom browsable api header 2020-06-17 10:12:27 +02:00
vabene1111
d012385088 WIP api stuff 2020-06-16 20:32:41 +02:00
vabene1111
d18a330135 reset log level 2020-06-16 19:56:42 +02:00
vabene1111
a8f7ef8ef7 added missing dependency 2020-06-16 19:55:48 +02:00
vabene1111
d6972cacfb pyyaml dependency 2020-06-16 19:43:44 +02:00
vabene1111
3b21e44422 log level debug 2020-06-16 18:57:08 +02:00
vabene1111
1a78ca68bb added access logging 2020-06-16 18:50:24 +02:00
vabene1111
fac7b8cd5b added gunicorn error logging 2020-06-16 18:43:16 +02:00
vabene1111
8f780545a4 api browser link 2020-06-16 18:14:41 +02:00
vabene1111
218f7d92d7 fixed password settings autofocus 2020-06-16 18:04:42 +02:00
vabene1111
621bacff1c api token settings 2020-06-16 18:01:16 +02:00
vabene1111
9a849a979c ui secret key check 2020-06-16 17:42:53 +02:00
vabene1111
e8366e5280 added secret key system check 2020-06-16 17:42:43 +02:00
vabene1111
0a8270e7cf api documentation basics 2020-06-16 17:21:50 +02:00
vabene1111
aad8b220d1 added custom 404 page 2020-06-16 16:21:15 +02:00
vabene1111
d5e0a0a623 added comment setting to user pref admin table 2020-06-16 12:45:46 +02:00
vabene1111
8cd94d49e8 added comment system preference 2020-06-16 12:44:45 +02:00
vabene1111
08b805a547 fixed meal plan delete after create 2020-06-16 12:12:37 +02:00
vabene1111
ecac30136b no sharing external recipes + hide buttons for unauth 2020-06-16 12:07:18 +02:00
vabene1111
d694408af6 share link admin 2020-06-16 12:05:36 +02:00
vabene1111
6e284f6ae8 moved all recipe button 2020-06-16 12:03:42 +02:00
vabene1111
62c049a6de fixed unauthenticated recipe viewing 2020-06-16 11:49:02 +02:00
vabene1111
dee7249347 added sharing links and appropriate tests 2020-06-16 11:23:58 +02:00
vabene1111
17946c8dac markdown hint text 2020-06-16 10:37:11 +02:00
vabene1111
fa2326949e added back markdown support to meal plannin notes 2020-06-16 10:34:20 +02:00
vabene1111
8177d9ba0f added synology install instruction 2020-06-16 10:19:24 +02:00
vabene1111
8781a6572d set some norefer labels
probably not really needed for this case but satisfies the code scanning and does not hurt to have
2020-06-16 10:13:54 +02:00
vabene1111
c7d518071c Create codeql-analysis.yml 2020-06-16 09:56:57 +02:00
vabene1111
ea96c63289 stupid apple fix 2020-06-13 14:03:50 +02:00
vabene1111
8485a64726 viewlog/cooklog admin 2020-06-12 00:07:47 +02:00
vabene1111
e89bd44412 added to string to meal plan 2020-06-11 23:43:48 +02:00
vabene1111
2e0e48bb38 removed celery 2 2020-06-11 23:29:24 +02:00
vabene1111
040fa7c192 removed celery 2020-06-11 23:29:10 +02:00
vabene1111
7000097602 fixed share reset in meal plan move 2020-06-11 22:53:04 +02:00
vabene1111
3cbc6b5609 meal type admin 2020-06-11 22:48:26 +02:00
vabene1111
8ff52f542e ingredient rounding upgrades 2020-06-11 22:32:45 +02:00
vabene1111
8cc0fcaed2 fixed properly deep copy meal_type array 2020-06-11 20:22:04 +02:00
vabene1111
d4197773bf clean up logging 2020-06-11 19:04:15 +02:00
vabene1111
f530b3dc7a dependency cleanup 2020-06-11 19:00:17 +02:00
vabene1111
d1bf4d4bbb added ical export support 2020-06-11 18:38:56 +02:00
vabene1111
d584a3db25 added more help 2020-06-11 17:57:30 +02:00
vabene1111
aaa3737ae0 help and name based type indexing 2020-06-11 17:55:24 +02:00
vabene1111
5072859e57 re enabled basic sharing 2020-06-11 17:27:27 +02:00
vabene1111
ead3c6ef76 fixed sharing 2020-06-11 17:13:31 +02:00
vabene1111
d734cb813e plan type editing 2020-06-11 16:58:57 +02:00
vabene1111
8aa24d4771 per user meal types 2020-06-11 15:19:15 +02:00
vabene1111
c714ff4dbe name function cleanup 2020-06-11 11:42:38 +02:00
vabene1111
a32545c1dc proper mobile handle 2020-06-11 11:40:32 +02:00
vabene1111
dfe8e1fd42 layout improvements 2020-06-11 10:59:26 +02:00
vabene1111
729d573460 imrpoved detail view 2020-06-11 10:26:40 +02:00
vabene1111
8472b541aa correctly update plan 2020-06-11 01:14:09 +02:00
vabene1111
7e95e985ec proper async loading 2020-06-11 01:05:02 +02:00
vabene1111
f7e2aa9b83 week switching 2020-06-11 00:49:19 +02:00
vabene1111
99cf428470 plan entry detail modal 2020-06-10 23:33:18 +02:00
vabene1111
60a533f9c8 Merge branch 'develop' into feature/meal-planning 2020-06-10 16:01:21 +02:00
vabene1111
2bda5bbbf7 note text saving 2020-06-09 20:27:31 +02:00
vabene1111
a743a4e202 basics completly working 2020-06-09 20:15:06 +02:00
vabene1111
ffa7513f9e recipes and notes basically working 2020-06-09 17:26:50 +02:00
vabene1111
8cb6ed2f60 adding items to plan 2020-06-09 13:11:01 +02:00
vabene1111
2e255aba0d more plan basics working 2020-06-04 19:46:35 +02:00
vabene1111
a136a18a8e basic updating and loading working 2020-06-04 19:34:47 +02:00
vabene1111
3aedbfbdc3 Merge branch 'develop' into feature/meal-planning
# Conflicts:
#	requirements.txt
2020-06-04 13:12:52 +02:00
vabene1111
b95c3f6685 working lists 2020-06-03 18:10:34 +02:00
vabene1111
3b5b505116 basic plan table drag and drop working 2020-06-03 12:05:43 +02:00
vabene1111
aea3f62f9b event parameter 2020-06-03 11:18:37 +02:00
vabene1111
201c493658 some working drag and drop stuff 2020-06-03 00:03:54 +02:00
vabene1111
8ffc6a0236 some basic drag and drop workin 2020-06-02 22:52:28 +02:00
vabene1111
84ad88b30b Merge branch 'develop' into feature/meal-planning 2020-06-02 22:32:30 +02:00
vabene1111
233f2a911f nothing working yet 2020-06-02 14:33:27 +02:00
vabene1111
989d8765d7 basic vue stuff working 2020-06-02 12:47:36 +02:00
vabene1111
2fcd207dc7 basic api 2020-06-02 12:04:14 +02:00
vabene1111
a3dc5f283a dynamic meal types 2020-06-02 11:46:16 +02:00
78 changed files with 18498 additions and 248 deletions

View File

@@ -1,4 +1,5 @@
# only set this to true when testing/debugging
# when unset: 1 (true) - dont unset this, just for development
DEBUG=0
# hosts the application can run under e.g. recipes.mydomain.com,cooking.mydomain.com,...
@@ -18,9 +19,17 @@ POSTGRES_DB=djangodb
# Serve mediafiles directly using gunicorn. Basically everyone recommends not doing this. Please use any of the examples
# provided that include an additional nxginx container to handle media file serving.
# If you know what you are doing turn this back on (1) to serve media files using djangos serve() method.
# when unset: 1 (true) - this is temporary until an appropriate amount of time has passed for everyone to migrate
GUNICORN_MEDIA=0
# allow authentication via reverse proxy (e.g. authelia), leave of if you dont know what you are doing
# docs: https://github.com/vabene1111/recipes/tree/develop/docs/docker/nginx-proxy%20with%20proxy%20authentication
# when unset: 0 (false)
REVERSE_PROXY_AUTH=0
# the default value for the user preference 'comments' (enable/disable commenting system)
# when unset: 1 (true)
COMMENT_PREF_DEFAULT=1

51
.github/workflows/codeql-analysis.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
name: "Code scanning - action"
on:
push:
pull_request:
schedule:
- cron: '0 13 * * 2'
jobs:
CodeQL-Build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
fetch-depth: 2
# If this run was triggered by a pull request event, then checkout
# the head of the pull request instead of the merge commit.
- run: git checkout HEAD^2
if: ${{ github.event_name == 'pull_request' }}
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
# Override language selection by uncommenting this and choosing your languages
# with:
# languages: go, javascript, csharp, python, cpp, java
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
# - name: Autobuild
# uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

View File

@@ -1,7 +1,9 @@
<component name="ProjectDictionaryState">
<dictionary name="vabene1111-PC">
<words>
<w>csrftoken</w>
<w>gunicorn</w>
<w>ical</w>
<w>traefik</w>
</words>
</dictionary>

2
.idea/recipes.iml generated
View File

@@ -27,8 +27,8 @@
<option name="TEMPLATE_CONFIGURATION" value="Django" />
<option name="TEMPLATE_FOLDERS">
<list>
<option value="$MODULE_DIR$/templates" />
<option value="$MODULE_DIR$/cookbook/templates" />
<option value="$MODULE_DIR$/recipes/templates" />
</list>
</option>
</component>

View File

@@ -11,6 +11,7 @@ Recipes is a Django application to manage, tag and search recipes using either b
- :mag: Powerful **search** with Djangos [TrigramSimilarity](https://docs.djangoproject.com/en/3.0/ref/contrib/postgres/search/#trigram-similarity)
- :label: Create and search for **tags**, assign them in batch to all files matching certain filters
- :page_facing_up: **Create recipes** locally within a nice, standardized web interface
- :arrow_down: **Import recipes** from thousands of websites supporting [ld+json or microdata](https://schema.org/Recipe)
- :iphone: Optimized for use on **mobile** devices like phones and tablets
- :shopping_cart: Generate **shopping** lists from recipes
- :calendar: Create a **Plan** on what to eat when

View File

@@ -8,4 +8,4 @@ echo "Done"
chmod -R 755 /opt/recipes/mediafiles
exec gunicorn -b :8080 --access-logfile - --error-logfile - recipes.wsgi
exec gunicorn -b :8080 --access-logfile - --error-logfile - --log-level INFO recipes.wsgi

View File

@@ -3,7 +3,7 @@ from .models import *
class UserPreferenceAdmin(admin.ModelAdmin):
list_display = ('name', 'theme', 'nav_color', 'default_page', 'search_style')
list_display = ('name', 'theme', 'nav_color', 'default_page', 'search_style', 'comments')
@staticmethod
def name(obj):
@@ -94,7 +94,7 @@ admin.site.register(RecipeBookEntry, RecipeBookEntryAdmin)
class MealPlanAdmin(admin.ModelAdmin):
list_display = ('user', 'recipe', 'meal', 'date')
list_display = ('user', 'recipe', 'meal_type', 'date')
@staticmethod
def user(obj):
@@ -102,3 +102,31 @@ class MealPlanAdmin(admin.ModelAdmin):
admin.site.register(MealPlan, MealPlanAdmin)
class MealTypeAdmin(admin.ModelAdmin):
list_display = ('name', 'created_by', 'order')
admin.site.register(MealType, MealTypeAdmin)
class ViewLogAdmin(admin.ModelAdmin):
list_display = ('recipe', 'created_by', 'created_at')
admin.site.register(ViewLog, ViewLogAdmin)
class CookLogAdmin(admin.ModelAdmin):
list_display = ('recipe', 'created_by', 'created_at', 'rating', 'servings')
admin.site.register(CookLog, CookLogAdmin)
class ShareLinkAdmin(admin.ModelAdmin):
list_display = ('recipe', 'created_by', 'uuid', 'created_at',)
admin.site.register(ShareLink, ShareLinkAdmin)

View File

@@ -31,13 +31,15 @@ class UserPreferenceForm(forms.ModelForm):
class Meta:
model = UserPreference
fields = ('default_unit', 'theme', 'nav_color', 'default_page', 'show_recent', 'search_style', 'plan_share')
fields = ('default_unit', 'theme', 'nav_color', 'default_page', 'show_recent', 'search_style', 'plan_share', 'ingredient_decimals', 'comments')
help_texts = {
'nav_color': _('Color of the top navigation bar. Not all colors work with all themes, just try them out!'),
'default_unit': _('Default Unit to be used when inserting a new ingredient into a recipe.'),
'plan_share': _('Default user to share newly created meal plan entries with.'),
'show_recent': _('Show recently viewed recipes on search page.'),
'ingredient_decimals': _('Number of decimals to round ingredients.'),
'comments': _('If you want to be able to create and see comments underneath recipes.')
}
widgets = {
@@ -263,7 +265,7 @@ class MealPlanForm(forms.ModelForm):
class Meta:
model = MealPlan
fields = ('recipe', 'title', 'meal', 'note', 'date', 'shared')
fields = ('recipe', 'title', 'meal_type', 'note', 'date', 'shared')
help_texts = {
'shared': _('You can list default users to share recipes with in the settings.'),

View File

@@ -0,0 +1,10 @@
# Permission Config
from cookbook.helper.permission_helper import CustomIsUser, CustomIsOwner, CustomIsAdmin, CustomIsGuest
class PermissionConfig:
BOOKS = {
'owner': True,
'groups': ['user'],
'drf': [CustomIsUser],
}

View File

@@ -3,12 +3,25 @@ Source: https://djangosnippets.org/snippets/1703/
"""
from django.contrib import messages
from django.contrib.auth.decorators import user_passes_test
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
from django.http import HttpResponseRedirect
from django.urls import reverse_lazy, reverse
from rest_framework import permissions
from cookbook.models import ShareLink
# Helper Functions
def get_allowed_groups(groups_required):
"""
Builds a list of all groups equal or higher to the provided groups
This means checking for guest will also allow admins to access
:param groups_required: list or tuple of groups
:return: tuple of groups
"""
groups_allowed = tuple(groups_required)
if 'guest' in groups_required:
groups_allowed = groups_allowed + ('user', 'admin')
@@ -17,15 +30,70 @@ def get_allowed_groups(groups_required):
return groups_allowed
def has_group_permission(user, groups):
"""
Tests if a given user is member of a certain group (or any higher group)
Superusers always bypass permission checks. Unauthenticated users cant be member of any
group thus always return false.
:param user: django auth user object
:param groups: list or tuple of groups the user should be checked for
:return: True if user is in allowed groups, false otherwise
"""
if not user.is_authenticated:
return False
groups_allowed = get_allowed_groups(groups)
if user.is_authenticated:
if user.is_superuser | bool(user.groups.filter(name__in=groups_allowed)):
return True
return False
def is_object_owner(user, obj):
"""
Tests if a given user is the owner of a given object
test performed by checking user against the objects user and create_by field (if exists)
superusers bypass all checks, unauthenticated users cannot own anything
:param user django auth user object
:param obj any object that should be tested
:return: true if user is owner of object, false otherwise
"""
# TODO this could be improved/cleaned up by adding get_owner methods to all models that allow owner checks
if not user.is_authenticated:
return False
if user.is_superuser:
return True
if owner := getattr(obj, 'created_by', None):
return owner == user
if owner := getattr(obj, 'user', None):
return owner == user
return False
def share_link_valid(recipe, share):
"""
Verifies the validity of a share uuid
:param recipe: recipe object
:param share: share uuid
:return: true if a share link with the given recipe and uuid exists, false otherwise
"""
try:
return True if ShareLink.objects.filter(recipe=recipe, uuid=share).exists() else False
except ValidationError:
return False
# Django Views
def group_required(*groups_required):
"""Requires user membership in at least one of the groups passed in."""
"""
Decorator that tests the requesting user to be member of at least one of the provided groups
or higher level groups
:param groups_required: list of required groups
:return: true if member of group, false otherwise
"""
def in_groups(u):
groups_allowed = get_allowed_groups(groups_required)
if u.is_authenticated:
if u.is_superuser | bool(u.groups.filter(name__in=groups_allowed)):
return True
return False
return has_group_permission(u, groups_required)
return user_passes_test(in_groups, login_url='index')
@@ -38,18 +106,10 @@ class GroupRequiredMixin(object):
groups_required = None
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
messages.add_message(request, messages.ERROR, _('You are not logged in and therefore cannot view this page!'))
return HttpResponseRedirect(reverse_lazy('login'))
else:
if not request.user.is_superuser:
group_allowed = get_allowed_groups(self.groups_required)
user_groups = []
for group in request.user.groups.values_list('name', flat=True):
user_groups.append(group)
if len(set(user_groups).intersection(group_allowed)) <= 0:
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!'))
return HttpResponseRedirect(reverse_lazy('index'))
if not has_group_permission(request.user, self.groups_required):
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!'))
return HttpResponseRedirect(reverse_lazy('index'))
return super(GroupRequiredMixin, self).dispatch(request, *args, **kwargs)
@@ -60,9 +120,59 @@ class OwnerRequiredMixin(object):
messages.add_message(request, messages.ERROR, _('You are not logged in and therefore cannot view this page!'))
return HttpResponseRedirect(reverse_lazy('login'))
else:
obj = self.get_object()
if not (obj.created_by == request.user or request.user.is_superuser):
if not is_object_owner(request.user, self.get_object()):
messages.add_message(request, messages.ERROR, _('You cannot interact with this object as its not owned by you!'))
return HttpResponseRedirect(reverse('index'))
return super(OwnerRequiredMixin, self).dispatch(request, *args, **kwargs)
# Django Rest Framework Permission classes
class CustomIsOwner(permissions.BasePermission):
"""
Custom permission class for django rest framework views
verifies user has ownership over object
(either user or created_by or user is request user)
"""
message = _('You cannot interact with this object as its not owned by you!')
def has_permission(self, request, view):
return request.user.is_authenticated
def has_object_permission(self, request, view, obj):
return is_object_owner(request.user, obj)
class CustomIsGuest(permissions.BasePermission):
"""
Custom permission class for django rest framework views
verifies the user is member of at least the group: guest
"""
message = _('You do not have the required permissions to view this page!')
def has_permission(self, request, view):
has_group_permission(request.user, ['guest'])
class CustomIsUser(permissions.BasePermission):
"""
Custom permission class for django rest framework views
verifies the user is member of at least the group: user
"""
message = _('You do not have the required permissions to view this page!')
def has_permission(self, request, view):
has_group_permission(request.user, ['user'])
class CustomIsAdmin(permissions.BasePermission):
"""
Custom permission class for django rest framework views
verifies the user is member of at least the group: admin
"""
message = _('You do not have the required permissions to view this page!')
def has_permission(self, request, view):
has_group_permission(request.user, ['admin'])

View File

@@ -0,0 +1,184 @@
import json
import random
import re
from json import JSONDecodeError
import microdata
from bs4 import BeautifulSoup
from django.http import JsonResponse
from django.utils.dateparse import parse_duration
from django.utils.translation import gettext as _
from cookbook.models import Keyword
def get_from_html(html_text, url):
soup = BeautifulSoup(html_text, "html.parser")
# first try finding ld+json as its most common
for ld in soup.find_all('script', type='application/ld+json'):
try:
ld_json = json.loads(ld.string)
if type(ld_json) != list:
ld_json = [ld_json]
for ld_json_item in ld_json:
# recipes type might be wrapped in @graph type
if '@graph' in ld_json_item:
for x in ld_json_item['@graph']:
if '@type' in x and x['@type'] == 'Recipe':
ld_json_item = x
if '@type' in ld_json_item and ld_json_item['@type'] == 'Recipe':
return find_recipe_json(ld_json_item, url)
except JSONDecodeError:
JsonResponse({'error': True, 'msg': _('The requested site does not provided malformed data and cannot be read.')}, status=400)
# now try to find microdata
items = microdata.get_items(html_text)
for i in items:
md_json = json.loads(i.json())
if 'schema.org/Recipe' in str(md_json['type']):
return find_recipe_json(md_json['properties'], url)
return JsonResponse({'error': True, 'msg': _('The requested site does not provide any recognized data format to import the recipe from.')}, status=400)
def find_recipe_json(ld_json, url):
if type(ld_json['name']) == list:
try:
ld_json['name'] = ld_json['name'][0]
except:
ld_json['name'] = 'ERROR'
# some sites use ingredients instead of recipeIngredients
if 'recipeIngredient' not in ld_json and 'ingredients' in ld_json:
ld_json['recipeIngredient'] = ld_json['ingredients']
if 'recipeIngredient' in ld_json:
# some pages have comma separated ingredients in a single array entry
if len(ld_json['recipeIngredient']) == 1 and len(ld_json['recipeIngredient'][0]) > 30:
ld_json['recipeIngredient'] = ld_json['recipeIngredient'][0].split(',')
for x in ld_json['recipeIngredient']:
if '\n' in x:
ld_json['recipeIngredient'].remove(x)
for i in x.split('\n'):
ld_json['recipeIngredient'].insert(0, i)
ingredients = []
for x in ld_json['recipeIngredient']:
ingredient_split = x.split()
ingredient = None
amount = 0
unit = ''
if len(ingredient_split) > 2:
ingredient = " ".join(ingredient_split[2:])
unit = ingredient_split[1]
try:
amount = float(ingredient_split[0].replace(',', '.'))
except ValueError:
amount = 0
ingredient = " ".join(ingredient_split)
if len(ingredient_split) == 2:
ingredient = " ".join(ingredient_split[1:])
unit = ''
try:
amount = float(ingredient_split[0].replace(',', '.'))
except ValueError:
amount = 0
ingredient = " ".join(ingredient_split)
if len(ingredient_split) == 1:
ingredient = " ".join(ingredient_split)
if ingredient:
ingredients.append({'amount': amount, 'unit': {'text': unit, 'id': random.randrange(10000, 99999)}, 'ingredient': {'text': ingredient, 'id': random.randrange(10000, 99999)}, 'original': x})
ld_json['recipeIngredient'] = ingredients
else:
ld_json['recipeIngredient'] = []
if 'keywords' in ld_json:
keywords = []
# keywords as string
if type(ld_json['keywords']) == str:
ld_json['keywords'] = ld_json['keywords'].split(',')
# keywords as string in list
if type(ld_json['keywords']) == list and len(ld_json['keywords']) == 1 and ',' in ld_json['keywords'][0]:
ld_json['keywords'] = ld_json['keywords'][0].split(',')
# keywords as list
for kw in ld_json['keywords']:
if k := Keyword.objects.filter(name=kw).first():
keywords.append({'id': str(k.id), 'text': str(k).strip()})
else:
keywords.append({'id': "null", 'text': kw.strip()})
ld_json['keywords'] = keywords
else:
ld_json['keywords'] = []
if 'recipeInstructions' in ld_json:
instructions = ''
# flatten instructions if they are in a list
if type(ld_json['recipeInstructions']) == list:
for i in ld_json['recipeInstructions']:
if type(i) == str:
instructions += i
else:
if 'text' in i:
instructions += i['text'] + '\n\n'
elif 'itemListElement' in i:
for ile in i['itemListElement']:
if type(ile) == str:
instructions += ile + '\n\n'
elif 'text' in ile:
instructions += ile['text'] + '\n\n'
else:
instructions += str(i)
ld_json['recipeInstructions'] = instructions
ld_json['recipeInstructions'] = re.sub(r'\n\s*\n', '\n\n', ld_json['recipeInstructions'])
ld_json['recipeInstructions'] = re.sub(' +', ' ', ld_json['recipeInstructions'])
ld_json['recipeInstructions'] = ld_json['recipeInstructions'].replace('<p>', '')
ld_json['recipeInstructions'] = ld_json['recipeInstructions'].replace('</p>', '')
else:
ld_json['recipeInstructions'] = ''
ld_json['recipeInstructions'] += '\n\n' + _('Imported from ') + url
if 'image' in ld_json:
# check if list of images is returned, take first if so
if (type(ld_json['image'])) == list:
if type(ld_json['image'][0]) == str:
ld_json['image'] = ld_json['image'][0]
elif 'url' in ld_json['image'][0]:
ld_json['image'] = ld_json['image'][0]['url']
# ignore relative image paths
if 'http' not in ld_json['image']:
ld_json['image'] = ''
if 'cookTime' in ld_json:
if type(ld_json['cookTime']) == list and len(ld_json['cookTime']) > 0:
ld_json['cookTime'] = ld_json['cookTime'][0]
ld_json['cookTime'] = round(parse_duration(ld_json['cookTime']).seconds / 60)
else:
ld_json['cookTime'] = 0
if 'prepTime' in ld_json:
if type(ld_json['prepTime']) == list and len(ld_json['prepTime']) > 0:
ld_json['prepTime'] = ld_json['prepTime'][0]
ld_json['prepTime'] = round(parse_duration(ld_json['prepTime']).seconds / 60)
else:
ld_json['prepTime'] = 0
for key in list(ld_json):
if key not in ['prepTime', 'cookTime', 'image', 'recipeInstructions', 'keywords', 'name', 'recipeIngredient']:
ld_json.pop(key, None)
return JsonResponse(ld_json)

View File

@@ -0,0 +1,27 @@
# Generated by Django 3.0.5 on 2020-06-02 09:33
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0045_userpreference_show_recent'),
]
operations = [
migrations.CreateModel(
name='MealType',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=128)),
('order', models.IntegerField(default=0)),
],
),
migrations.AddField(
model_name='mealplan',
name='meal_type',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.MealType'),
),
]

View File

@@ -0,0 +1,51 @@
# Generated by Django 3.0.5 on 2020-06-02 09:33
from django.db import migrations
from django.utils.translation import gettext as _
def migrate_meal_types(apps, schema_editor):
MealPlan = apps.get_model('cookbook', 'MealPlan')
MealType = apps.get_model('cookbook', 'MealType')
breakfast = MealType.objects.create(
name=_('Breakfast'),
order=0,
)
lunch = MealType.objects.create(
name=_('Lunch'),
order=0,
)
dinner = MealType.objects.create(
name=_('Dinner'),
order=0,
)
other = MealType.objects.create(
name=_('Other'),
order=0,
)
for m in MealPlan.objects.all():
if m.meal == 'BREAKFAST':
m.meal_type = breakfast
if m.meal == 'LUNCH':
m.meal_type = lunch
if m.meal == 'DINNER':
m.meal_type = dinner
if m.meal == 'OTHER':
m.meal_type = other
m.save()
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0046_auto_20200602_1133'),
]
operations = [
migrations.RunPython(migrate_meal_types),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 3.0.5 on 2020-06-02 09:40
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0047_auto_20200602_1133'),
]
operations = [
migrations.RemoveField(
model_name='mealplan',
name='meal',
),
migrations.AlterField(
model_name='mealplan',
name='meal_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.MealType'),
),
]

View File

@@ -0,0 +1,21 @@
# Generated by Django 3.0.7 on 2020-06-11 13:08
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('cookbook', '0048_auto_20200602_1140'),
]
operations = [
migrations.AddField(
model_name='mealtype',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1,30 @@
# Generated by Django 3.0.7 on 2020-06-11 13:09
from django.db import migrations
from django.db.models import Q
def migrate_meal_types(apps, schema_editor):
MealPlan = apps.get_model('cookbook', 'MealPlan')
MealType = apps.get_model('cookbook', 'MealType')
User = apps.get_model('auth', 'User')
for u in User.objects.all():
for t in MealType.objects.filter(created_by=None).all():
user_type = MealType.objects.create(
name=t.name,
created_by=u,
)
MealPlan.objects.filter(Q(created_by=u) and Q(meal_type=t)).update(meal_type=user_type)
MealType.objects.filter(created_by=None).delete()
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0049_mealtype_created_by'),
]
operations = [
migrations.RunPython(migrate_meal_types),
]

View File

@@ -0,0 +1,21 @@
# Generated by Django 3.0.7 on 2020-06-11 13:18
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('cookbook', '0050_auto_20200611_1509'),
]
operations = [
migrations.AlterField(
model_name='mealtype',
name='created_by',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.0.7 on 2020-06-11 20:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0051_auto_20200611_1518'),
]
operations = [
migrations.AddField(
model_name='userpreference',
name='ingredient_decimals',
field=models.IntegerField(default=2),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.0.7 on 2020-06-11 20:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0052_userpreference_ingredient_decimals'),
]
operations = [
migrations.AlterField(
model_name='recipeingredient',
name='amount',
field=models.DecimalField(decimal_places=16, default=0, max_digits=32),
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 3.0.7 on 2020-06-16 08:57
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('cookbook', '0053_auto_20200611_2217'),
]
operations = [
migrations.CreateModel(
name='ShareLink',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.UUID('dbbf5150-0795-4305-b9bd-3952dfa2264b'))),
('created_at', models.DateTimeField(auto_now_add=True)),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.Recipe')),
],
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 3.0.7 on 2020-06-16 10:36
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0054_sharelink'),
]
operations = [
migrations.AddField(
model_name='userpreference',
name='comments',
field=models.BooleanField(default=True),
),
migrations.AlterField(
model_name='sharelink',
name='uuid',
field=models.UUIDField(default=uuid.UUID('a6e8f192-cc03-4dd4-8a03-58d7ab6b7df7')),
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 3.0.7 on 2020-06-25 19:57
from django.db import migrations, models
import uuid
def invalidate_shares(apps, schema_editor):
ShareLink = apps.get_model('cookbook', 'ShareLink')
ShareLink.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0055_auto_20200616_1236'),
]
operations = [
migrations.AlterField(
model_name='sharelink',
name='uuid',
field=models.UUIDField(default=uuid.uuid4),
),
migrations.RunPython(invalidate_shares)
]

View File

@@ -1,11 +1,13 @@
import re
import uuid
from annoying.fields import AutoOneToOneField
from django.contrib import auth
from django.contrib.auth.models import User
from django.utils.translation import gettext as _
from django.db import models
from recipes.settings import COMMENT_PREF_DEFAULT
def get_user_name(self):
if not (name := f"{self.first_name} {self.last_name}") == " ":
@@ -63,6 +65,8 @@ class UserPreference(models.Model):
search_style = models.CharField(choices=SEARCH_STYLE, max_length=64, default=LARGE)
show_recent = models.BooleanField(default=True)
plan_share = models.ManyToManyField(User, blank=True, related_name='plan_share_default')
ingredient_decimals = models.IntegerField(default=2)
comments = models.BooleanField(default=COMMENT_PREF_DEFAULT)
def __str__(self):
return str(self.user)
@@ -163,7 +167,7 @@ class RecipeIngredient(models.Model):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
ingredient = models.ForeignKey(Ingredient, on_delete=models.PROTECT)
unit = models.ForeignKey(Unit, on_delete=models.PROTECT)
amount = models.DecimalField(default=0, decimal_places=2, max_digits=16)
amount = models.DecimalField(default=0, decimal_places=16, max_digits=32)
note = models.CharField(max_length=64, null=True, blank=True)
def __str__(self):
@@ -211,18 +215,21 @@ class RecipeBookEntry(models.Model):
return self.recipe.name
class MealPlan(models.Model):
BREAKFAST = 'BREAKFAST'
LUNCH = 'LUNCH'
DINNER = 'DINNER'
OTHER = 'OTHER'
MEAL_TYPES = ((BREAKFAST, _('Breakfast')), (LUNCH, _('Lunch')), (DINNER, _('Dinner')), (OTHER, _('Other')),)
class MealType(models.Model):
name = models.CharField(max_length=128)
order = models.IntegerField(default=0)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
def __str__(self):
return self.name
class MealPlan(models.Model):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, blank=True, null=True)
title = models.CharField(max_length=64, blank=True, default='')
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
shared = models.ManyToManyField(User, blank=True, related_name='plan_share')
meal = models.CharField(choices=MEAL_TYPES, max_length=128, default=BREAKFAST)
meal_type = models.ForeignKey(MealType, on_delete=models.CASCADE)
note = models.TextField(blank=True)
date = models.DateField()
@@ -232,8 +239,17 @@ class MealPlan(models.Model):
return str(self.recipe)
def get_meal_name(self):
meals = dict(self.MEAL_TYPES)
return meals.get(self.meal)
return self.meal_type.name
def __str__(self):
return f'{self.get_label()} - {self.date} - {self.meal_type.name}'
class ShareLink(models.Model):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
uuid = models.UUIDField(default=uuid.uuid4)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
class CookLog(models.Model):

128
cookbook/serializer.py Normal file
View File

@@ -0,0 +1,128 @@
from django.contrib.auth.models import User
from rest_framework import serializers
from cookbook.models import MealPlan, MealType, Recipe, ViewLog, UserPreference, Storage, Sync, SyncLog, Keyword, Ingredient, Unit, RecipeIngredient, Comment, RecipeImport, RecipeBook, RecipeBookEntry, ShareLink, CookLog
from cookbook.templatetags.custom_tags import markdown
class UserNameSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ('id', 'username', 'first_name', 'last_name')
class UserPreferenceSerializer(serializers.ModelSerializer):
class Meta:
model = UserPreference
fields = '__all__'
read_only_fields = ['user']
class StorageSerializer(serializers.ModelSerializer):
class Meta:
model = Storage
fields = '__all__'
class SyncSerializer(serializers.ModelSerializer):
class Meta:
model = Sync
fields = '__all__'
class SyncLogSerializer(serializers.ModelSerializer):
class Meta:
model = SyncLog
fields = '__all__'
class KeywordSerializer(serializers.ModelSerializer):
class Meta:
model = Keyword
fields = '__all__'
class RecipeSerializer(serializers.ModelSerializer):
class Meta:
model = Recipe
fields = '__all__'
class UnitSerializer(serializers.ModelSerializer):
class Meta:
model = Unit
fields = '__all__'
class IngredientSerializer(serializers.ModelSerializer):
class Meta:
model = Ingredient
fields = '__all__'
class RecipeIngredientSerializer(serializers.ModelSerializer):
class Meta:
model = RecipeIngredient
fields = '__all__'
class CommentSerializer(serializers.ModelSerializer):
class Meta:
model = Comment
fields = '__all__'
class RecipeImportSerializer(serializers.ModelSerializer):
class Meta:
model = RecipeImport
fields = '__all__'
class RecipeBookSerializer(serializers.ModelSerializer):
class Meta:
model = RecipeBook
fields = '__all__'
read_only_fields = ['id', 'created_by']
class RecipeBookEntrySerializer(serializers.ModelSerializer):
class Meta:
model = RecipeBookEntry
fields = '__all__'
class MealTypeSerializer(serializers.ModelSerializer):
class Meta:
model = MealType
fields = '__all__'
class MealPlanSerializer(serializers.ModelSerializer):
recipe_name = serializers.ReadOnlyField(source='recipe.name')
meal_type_name = serializers.ReadOnlyField(source='meal_type.name')
note_markdown = serializers.SerializerMethodField('get_note_markdown')
def get_note_markdown(self, obj):
return markdown(obj.note)
class Meta:
model = MealPlan
fields = ('id', 'title', 'recipe', 'note', 'note_markdown', 'date', 'meal_type', 'created_by', 'shared', 'recipe_name', 'meal_type_name')
class ShareLinkSerializer(serializers.ModelSerializer):
class Meta:
model = ShareLink
fields = '__all__'
class CookLogSerializer(serializers.ModelSerializer):
class Meta:
model = CookLog
fields = '__all__'
class ViewLogSerializer(serializers.ModelSerializer):
class Meta:
model = ViewLog
fields = '__all__'

File diff suppressed because one or more lines are too long

2
cookbook/static/js/Sortable.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
cookbook/static/js/js.cookie.min.js vendored Normal file
View File

@@ -0,0 +1 @@
!function(e){var n;if("function"==typeof define&&define.amd&&(define(e),n=!0),"object"==typeof exports&&(module.exports=e(),n=!0),!n){var t=window.Cookies,o=window.Cookies=e();o.noConflict=function(){return window.Cookies=t,o}}}(function(){function f(){for(var e=0,n={};e<arguments.length;e++){var t=arguments[e];for(var o in t)n[o]=t[o]}return n}function a(e){return e.replace(/(%[0-9A-Z]{2})+/g,decodeURIComponent)}return function e(u){function c(){}function t(e,n,t){if("undefined"!=typeof document){"number"==typeof(t=f({path:"/"},c.defaults,t)).expires&&(t.expires=new Date(1*new Date+864e5*t.expires)),t.expires=t.expires?t.expires.toUTCString():"";try{var o=JSON.stringify(n);/^[\{\[]/.test(o)&&(n=o)}catch(e){}n=u.write?u.write(n,e):encodeURIComponent(String(n)).replace(/%(23|24|26|2B|3A|3C|3E|3D|2F|3F|40|5B|5D|5E|60|7B|7D|7C)/g,decodeURIComponent),e=encodeURIComponent(String(e)).replace(/%(23|24|26|2B|5E|60|7C)/g,decodeURIComponent).replace(/[\(\)]/g,escape);var r="";for(var i in t)t[i]&&(r+="; "+i,!0!==t[i]&&(r+="="+t[i].split(";")[0]));return document.cookie=e+"="+n+r}}function n(e,n){if("undefined"!=typeof document){for(var t={},o=document.cookie?document.cookie.split("; "):[],r=0;r<o.length;r++){var i=o[r].split("="),c=i.slice(1).join("=");n||'"'!==c.charAt(0)||(c=c.slice(1,-1));try{var f=a(i[0]);if(c=(u.read||u)(c,f)||a(c),n)try{c=JSON.parse(c)}catch(e){}if(t[f]=c,e===f)break}catch(e){}}return e?t[e]:t}}return c.set=t,c.get=function(e){return n(e,!1)},c.getJSON=function(e){return n(e,!0)},c.remove=function(e,n){t(e,"",f(n,{expires:-1}))},c.defaults={},c.withConverter=e,c}(function(){})});

View File

@@ -0,0 +1 @@
{"version":3,"sources":["js.cookie.js"],"names":["factory","registeredInModuleLoader","define","amd","exports","module","OldCookies","window","Cookies","api","noConflict","extend","i","result","arguments","length","attributes","key","decode","s","replace","decodeURIComponent","init","converter","set","value","document","path","defaults","expires","Date","toUTCString","JSON","stringify","test","e","write","encodeURIComponent","String","escape","stringifiedAttributes","attributeName","split","cookie","get","json","jar","cookies","parts","slice","join","charAt","name","read","parse","getJSON","remove","withConverter"],"mappings":"CAOE,SAAUA,GACX,IAAIC,EASJ,GARsB,mBAAXC,QAAyBA,OAAOC,MAC1CD,OAAOF,GACPC,GAA2B,GAEL,iBAAZG,UACVC,OAAOD,QAAUJ,IACjBC,GAA2B,IAEvBA,EAA0B,CAC9B,IAAIK,EAAaC,OAAOC,QACpBC,EAAMF,OAAOC,QAAUR,IAC3BS,EAAIC,WAAa,WAEhB,OADAH,OAAOC,QAAUF,EACVG,IAfT,CAkBC,WACD,SAASE,IAGR,IAFA,IAAIC,EAAI,EACJC,EAAS,GACND,EAAIE,UAAUC,OAAQH,IAAK,CACjC,IAAII,EAAaF,UAAWF,GAC5B,IAAK,IAAIK,KAAOD,EACfH,EAAOI,GAAOD,EAAWC,GAG3B,OAAOJ,EAGR,SAASK,EAAQC,GAChB,OAAOA,EAAEC,QAAQ,mBAAoBC,oBA0HtC,OAvHA,SAASC,EAAMC,GACd,SAASd,KAET,SAASe,EAAKP,EAAKQ,EAAOT,GACzB,GAAwB,oBAAbU,SAAX,CAQkC,iBAJlCV,EAAaL,EAAO,CACnBgB,KAAM,KACJlB,EAAImB,SAAUZ,IAEKa,UACrBb,EAAWa,QAAU,IAAIC,KAAkB,EAAb,IAAIA,KAAkC,MAArBd,EAAWa,UAI3Db,EAAWa,QAAUb,EAAWa,QAAUb,EAAWa,QAAQE,cAAgB,GAE7E,IACC,IAAIlB,EAASmB,KAAKC,UAAUR,GACxB,UAAUS,KAAKrB,KAClBY,EAAQZ,GAER,MAAOsB,IAETV,EAAQF,EAAUa,MACjBb,EAAUa,MAAMX,EAAOR,GACvBoB,mBAAmBC,OAAOb,IACxBL,QAAQ,4DAA6DC,oBAExEJ,EAAMoB,mBAAmBC,OAAOrB,IAC9BG,QAAQ,2BAA4BC,oBACpCD,QAAQ,UAAWmB,QAErB,IAAIC,EAAwB,GAC5B,IAAK,IAAIC,KAAiBzB,EACpBA,EAAWyB,KAGhBD,GAAyB,KAAOC,GACE,IAA9BzB,EAAWyB,KAWfD,GAAyB,IAAMxB,EAAWyB,GAAeC,MAAM,KAAK,KAGrE,OAAQhB,SAASiB,OAAS1B,EAAM,IAAMQ,EAAQe,GAG/C,SAASI,EAAK3B,EAAK4B,GAClB,GAAwB,oBAAbnB,SAAX,CAUA,IANA,IAAIoB,EAAM,GAGNC,EAAUrB,SAASiB,OAASjB,SAASiB,OAAOD,MAAM,MAAQ,GAC1D9B,EAAI,EAEDA,EAAImC,EAAQhC,OAAQH,IAAK,CAC/B,IAAIoC,EAAQD,EAAQnC,GAAG8B,MAAM,KACzBC,EAASK,EAAMC,MAAM,GAAGC,KAAK,KAE5BL,GAA6B,MAArBF,EAAOQ,OAAO,KAC1BR,EAASA,EAAOM,MAAM,GAAI,IAG3B,IACC,IAAIG,EAAOlC,EAAO8B,EAAM,IAIxB,GAHAL,GAAUpB,EAAU8B,MAAQ9B,GAAWoB,EAAQS,IAC9ClC,EAAOyB,GAEJE,EACH,IACCF,EAASX,KAAKsB,MAAMX,GACnB,MAAOR,IAKV,GAFAW,EAAIM,GAAQT,EAER1B,IAAQmC,EACX,MAEA,MAAOjB,KAGV,OAAOlB,EAAM6B,EAAI7B,GAAO6B,GAoBzB,OAjBArC,EAAIe,IAAMA,EACVf,EAAImC,IAAM,SAAU3B,GACnB,OAAO2B,EAAI3B,GAAK,IAEjBR,EAAI8C,QAAU,SAAUtC,GACvB,OAAO2B,EAAI3B,GAAK,IAEjBR,EAAI+C,OAAS,SAAUvC,EAAKD,GAC3BQ,EAAIP,EAAK,GAAIN,EAAOK,EAAY,CAC/Ba,SAAU,MAIZpB,EAAImB,SAAW,GAEfnB,EAAIgD,cAAgBnC,EAEbb,EAGDa,CAAK"}

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

11965
cookbook/static/js/vue.js Normal file

File diff suppressed because it is too large Load Diff

6
cookbook/static/js/vue.min.js vendored Normal file

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

@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% block title %}{% trans "404 Error" %}{% endblock %}
{% block extra_head %}
{% endblock %}
{% block content %}
<div style="text-align: center">
<h1 class="">Not Found</h1>
<br/>
<span>{% trans 'The page you are looking for could not be found.' %}</span>
<br/>
<br/>
<i class="fas fa-search fa-8x"></i>
<br/>
<br/>
<div class="jumbotron">
<code>{{ request_path }}</code><br/>
<code>{{ exception }}</code>
</div>
<br/>
<br/>
<a class="btn btn-outline-success" href="{% url 'index' %}"><i
class="fas fa-home"></i> {% trans 'Take me Home' %}</a>
<a class="btn btn-outline-info" href="https://github.com/vabene1111/recipes/issues" target="_blank"
rel="noreferrer nofollow"><i class="fab fa-github"></i> {% trans 'Report a Bug' %}</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% block title %}{% trans "API Documentation" %}{% endblock %}
{% block extra_head %}
{% endblock %}
{% block content_fluid %}
<redoc spec-url='{% url 'openapi-schema' %}'></redoc>
<script src="{% static 'js/redoc.standalone.js' %}"></script>
{% endblock %}

View File

@@ -54,7 +54,7 @@
</button>
<div class="collapse navbar-collapse" id="navbarText">
<ul class="navbar-nav mr-auto">
<li class="nav-item {% if request.resolver_match.url_name in 'view_search,edit_recipe,edit_internal_recipe,edit_external_recipe,view_recipe' %}active{% endif %}">
<li class="nav-item {% if request.resolver_match.url_name in 'view_search,edit_recipe,edit_internal_recipe,edit_external_recipe,view_recipe,data_import_url' %}active{% endif %}">
<a class="nav-link" href="{% url 'view_search' %}"><i
class="fas fa-book"></i> {% trans 'Cookbook' %}<span
class="sr-only">(current)</span></a>
@@ -141,6 +141,10 @@
class="fab fa-markdown fa-fw"></i> {% trans 'Markdown Help' %}</a>
<a class="dropdown-item" href="https://github.com/vabene1111/recipes"><i
class="fab fa-github fa-fw"></i> {% trans 'GitHub' %}</a>
<a class="dropdown-item" href="{% url 'docs_api' %}"><i
class="fas fa-passport fa-fw"></i> {% trans 'API Documentation' %}</a>
<a class="dropdown-item" href="{% url 'api:api-root' %}"><i
class="fas fa-file-code fa-fw"></i> {% trans 'API Browser' %}</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="{% url 'logout' %}"><i
class="fas fa-sign-out-alt fa-fw"></i> {% trans 'Logout' %}</a>
@@ -175,6 +179,11 @@
{% endblock %}
</div>
<div class="container-fluid">
{% block content_fluid %}
{% endblock %}
</div>
{% block script %}
{% endblock script %}

View File

@@ -0,0 +1,13 @@
{% load static %}
{% load custom_tags %}
{% is_debug as DEBUG %}
{% if DEBUG %}
<script src="{% static 'js/vue.js' %}"></script>
{% else %}
<script src="{% static 'js/vue.min.js' %}"></script>
{% endif %}
<script src="{% static 'js/vue-resource.js' %}"></script>
<script src="{% static 'js/js.cookie.min.js' %}"></script>

View File

@@ -42,6 +42,10 @@
<button class="dropdown-item" type="button"
onclick="location.href='{% url 'new_recipe' %}'"><i
class="fas fa-plus-circle fa-fw"></i> {% trans 'New Recipe' %}</button>
<button class="dropdown-item" type="button"
onclick="location.href='{% url 'data_import_url' %}'"><i
class="fas fa-cloud-download-alt fa-fw"></i> {% trans 'Website Import' %}
</button>
<button data-toggle="collapse" href="#collapse_adv_search"
role="button" class="dropdown-item"
aria-expanded="false" type="button"

View File

@@ -1,97 +1,631 @@
{% extends "base.html" %}
{% load i18n %}
{% load static %}
{% block title %}{% trans 'Meal-Plan' %}{% endblock %}
{% block extra_head %}
{{ form.media }}
{% include 'include/vue_base.html' %}
<script src="{% static 'js/moment-with-locales.min.js' %}"></script>
<script src="{% static 'js/Sortable.min.js' %}"></script>
<script src="{% static 'js/vuedraggable.umd.min.js' %}"></script>
<script src="{% static 'js/js.cookie.min.js' %}"></script>
{% endblock %}
{% block content %}
<style>
.mealplan-cell .mealplan-add-button {
text-align: center;
display: block;
}
@media (hover: hover) {
.mealplan-cell .mealplan-add-button {
visibility: hidden;
float: right;
display: inline;
}
.mealplan-cell:hover .mealplan-add-button {
visibility: initial;
}
}
</style>
<h3>
{% trans 'Meal-Plan' %} <a href="{% url 'new_meal_plan' %}"><i class="fas fa-plus-circle"></i></a>
</h3>
<div class="row">
<div class="col-md-12" style="text-align: center">
<form action="{% url 'view_plan' %}" method="post">
{% csrf_token %}
<label>{% trans 'Week' %}
<div class="input-group">
<div class="input-group-prepend">
<button class="btn btn-outline-secondary" id="btn_prev"
onclick="$('#id_week').val('{{ surrounding_weeks.prev }}'); document.forms[0].submit()">
<i class="fas fa-arrow-left"></i>
</button>
</div>
<input name="week" id="id_week" class="form-control" type="week"
onchange="document.forms[0].submit()" value="{{ js_week }}">
<div class="input-group-append">
<button class="btn btn-outline-secondary" id="btn_next"
onclick="$('#id_week').val('{{ surrounding_weeks.next }}'); document.forms[0].submit()">
<i class="fas fa-arrow-right"></i>
</button>
</div>
<div id="app">
<div class="row">
<div class="col-md-4 offset-md-4">
<div class="input-group" style="margin-top: 8px; margin-bottom: 8px">
<div class="input-group-prepend">
<button class="btn btn-outline-secondary shadow-none" @click="changeWeek(-1)">
<i class="fas fa-arrow-left"></i>
</button>
</div>
</label>
</form>
<input name="week" id="id_week" class="form-control" type="week" v-model="week"
@change="updatePlan()">
<div class="input-group-append">
<button class="btn btn-outline-secondary shadow-none" @click="changeWeek(1)">
<i class="fas fa-arrow-right"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12 table-responsive">
<table class="table table-bordered">
<tr style="text-align: center">
{% for d in days %}
<th>{{ d | date:"l" }}<br/>{{ d }}</th>
{% endfor %}
</tr>
{% for plan_key, plan_value in plan.items %}
<tr>
<td colspan="7" style="text-align: center"><h5>{{ plan_value.type_name }}</h5></td>
</tr>
<tr>
{% for day_key, days_value in plan_value.days.items %}
<td class="mealplan-cell">
<a class="mealplan-add-button"
href="{% url 'new_meal_plan' %}?date={{ day_key|date:'Y-m-d' }}&meal={{ plan_key }}"><i
class="fas fa-plus"></i></a>
{% for mp in days_value %}
<a href="{% url 'view_plan_entry' mp.pk %}">
{% if mp.recipe %}
{{ mp.recipe }}
{% else %}
{{ mp.title }}
{% endif %}
</a><br/>
{% endfor %}
</td>
{% endfor %}
</tr>
{% endfor %}
</table>
<div class="row">
<div class="col-md-12">
<table class="table table-sm table-striped table-responsive-sm">
<thead class="thead-dark">
<tr>
<th v-for="d in days" style="width: 14.2%; text-align: center">[[d]]<br/>[[formatDateDay(d)]].
<button class="btn btn-sm btn-outline-secondary shadow-none" @click="addDayToShopping(d)"><i
class="fas fa-cart-plus fa-sm"></i></button>
</th>
</tr>
</thead>
<tbody v-for="t in meal_types">
<tr v-if="meal_plan[t.name] !== undefined">
<td colspan="7" style="text-align: center">
[[ meal_plan[t.name].name]]
<template
v-if="t.created_by !== {{ request.user.pk }} && user_names[t.created_by] !== undefined">
([[ user_names[t.created_by] ]])
</template>
</td>
</tr>
<tr v-if="meal_plan[t.name] !== undefined">
<td v-for="d in meal_plan[t.name].days">
<draggable class="list-group" :list="d.items" group="plan" style="min-height: 40px"
@change="dragChanged(d.date, t, $event)"
:empty-insert-threshold="10" handle=".handle">
<div class="" v-for="(element, index) in d.items" :key="element.id">
<div class="d-block d-md-none">
<div class="col-">
<i class="fas fa-arrows-alt handle input-group-text"
style="width: 100%"></i>
</div>
<div class="list-group-item">
<a href="#" @click="plan_detail = element" data-toggle="modal"
data-target="#id_plan_detail_modal">[[ planElementName(element)]]</a>
</div>
</div>
<div class="list-group-item handle d-md-block d-none">
<div class="col-md-12">
<a href="#" @click="plan_detail = element" data-toggle="modal"
data-target="#id_plan_detail_modal">[[ planElementName(element)]]</a>
</div>
</div>
</div>
</draggable>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header">
<i class="fas fa-calendar-plus"></i> {% trans 'New Entry' %} <a href="#" data-toggle="modal"
data-target="#id_plan_help_modal"><i
class="far fa-question-circle"></i></a>
</div>
<div class="row">
<div class="col-md-6">
<div class="card-body">
<div class="row">
<div class="col-md-12">
<input type="text" class="form-control" v-model="recipe_query" @keyup="getRecipes"
placeholder="{% trans 'Search Recipe' %}" style="margin-bottom: 8px">
</div>
</div>
<draggable class="list-group" :list="recipes"
:group="{ name: 'plan', pull: 'clone', put: false }" :clone="cloneRecipe">
<div class="list-group-item" v-for="(element, index) in recipes" :key="element.id">
<i class="fas fa-arrows-alt"></i> [[element.name]]
</div>
</draggable>
</div>
</div>
<div class="col-md-6">
<div>
<div class="card-body">
<input type="text" class="form-control" v-model="new_note_title"
placeholder="{% trans 'Title' %}" style="margin-bottom: 8px">
<textarea class="form-control" v-model="new_note_text"
placeholder="{% trans 'Note (optional)' %}"></textarea>
<small><span
class="text-muted">{% trans 'You can use markdown to format this field. See the <a href="/docs/markdown/" target="_blank" rel="noopener noreferrer">docs here</a>' %}</span></small>
<br/>
<br/>
<draggable :list="pseudo_note_list"
:group="{ name: 'plan', pull: 'clone', put: false }" :clone="cloneNote">
<div class="list-group-item" v-for="(element, index) in pseudo_note_list"
:key="element.id">
<i class="fas fa-arrows-alt"></i> {% trans 'Create only note' %}
</div>
</draggable>
</div>
</div>
</div>
</div>
</div>
<br>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<i class="fas fa-shopping-cart"></i> {% trans 'Shopping List' %}
</div>
<div class="card-body">
<template v-if="shopping_list.length < 1">{% trans 'Shopping List currently empty' %}</template>
<template v-else>
<a v-bind:href="getShoppingUrl()" class="btn btn-success"
target="_blank">{% trans 'Open Shopping List' %}</a>
<br/>
<br/>
{% trans 'Recipes' %}
<ul class="list-group" style="margin-top: 8px">
<li class="list-group-item" v-for="item in shopping_list"> [[ item.recipe_name ]]</li>
</ul>
</template>
</div>
</div>
</div>
<div class="col-md-6" style="margin-top: 8px">
<div class="card">
<div class="card-header">
<i class="fas fa-shopping-cart"></i> {% trans 'Plan' %}
</div>
<div class="card-body">
<a href="#" data-toggle="modal"
data-target="#id_plan_types_modal">{% trans 'Edit plan types' %}</a> <br/>
<a href="#" data-toggle="modal"
data-target="#id_plan_help_modal">{% trans 'Show help' %}</a><br/>
<a v-bind:href="getIcalUrl()">{% trans 'Week iCal export' %}</a>
</div>
</div>
</div>
</div>
<br/>
<br/>
<div class="modal fade" id="id_plan_detail_modal" tabindex="-1" role="dialog"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<template v-if="plan_detail.title !==''">[[ plan_detail.title ]]</template>
<template v-else>[[ plan_detail.recipe_name ]]</template>
<small
class="text-muted"><br/>[[ plan_detail.meal_type_name ]] [[
formatLocalDate(plan_detail.date) ]]</small>
</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<template v-if="plan_detail.recipe_name !== undefined ">
<small class="text-muted">{% trans 'Recipe' %}</small><br/>
<a v-bind:href="planDetailRecipeUrl()" target="_blank">[[ plan_detail.recipe_name ]]</a>
<br/>
</template>
<template v-if="plan_detail.note !== ''">
<small class="text-muted">{% trans 'Note' %}</small><br/>
<span v-html="plan_detail.note_markdown"></span>
<br/>
</template>
<br/>
<br/>
<template v-if="plan_detail.created_by !== undefined ">
<small class="text-muted">{% trans 'Created by' %}</small><br/>
[[ user_names[plan_detail.created_by] ]]
<br/>
</template>
<template v-if="plan_detail.shared.length > 0">
<small class="text-muted">{% trans 'Shared with' %}</small><br/>
<span>[[ planDetailUserList() ]]</span>
<br/>
</template>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger"
@click="deleteEntry(plan_detail)">{% trans 'Delete' %}</button>
<button type="button" class="btn btn-success"
v-if="!shopping_list.includes(plan_detail) && plan_detail.recipe_name !== undefined"
@click="shopping_list.push(plan_detail)">{% trans 'Add to Shopping' %}</button>
<a class="btn btn-primary" v-bind:href="planDetailEditUrl()">{% trans 'Edit' %}</a>
<button type="button" class="btn btn-secondary"
data-dismiss="modal">{% trans 'Close' %}</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="id_plan_types_modal" tabindex="-1" role="dialog"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{% trans 'Edit plan types' %}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<draggable :list="meal_types_edit" handle=".handle"
:group="{ name: 'types'}">
<div v-for="(element, index) in meal_types_edit"
:key="element.id">
<template v-if="!element.delete">
<div class="input-group mb-3">
<div class="input-group-prepend handle">
<button tabindex="-1" class="btn btn-outline-secondary"><i
class="fas fa-arrows-alt-v"></i></button>
</div>
<input class="form-control" v-model="element.name">
<div class="input-group-append">
<button tabindex="-1" class="btn btn-outline-danger" type="button"
@click="markTypeDelete(element)"><i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
</template>
</div>
</draggable>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary"
@click="meal_types_edit.push({name:'{% trans 'New meal type' %}', delete:false})">{% trans 'New' %}</button>
<button type="button" class="btn btn-success"
@click="updatePlanTypes()">{% trans 'Save' %}</button>
<button type="button" class="btn btn-secondary"
data-dismiss="modal">{% trans 'Close' %}</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="id_plan_help_modal" tabindex="-1" role="dialog"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{% trans 'Meal Plan Help' %}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
{% blocktrans %}
<p>The meal plan module allows planning of meals both with recipes or just notes.</p>
<p>Simply select a recipe from the list of recently viewed recipes or search the one you
want and drag it to the desired plan position. You can also add a note and a title and
then drag the recipe to create a plan entry with a custom title and note. Creating only
Notes is possible by dragging the create note box into the plan.</p>
<p>Click on a recipe in order to open the detail view. Here you can also add it to the
shopping list. You can also add all recipes of a day to the shopping list by
clicking the shopping cart at the top of the table.</p>
<p>Since a common use case is to plan meals together you can define
users you want to share your plan with in the settings.
</p>
<p>You can also edit the types of meals you want to plan. If you share your plan with
someone with
different meals, their meal types will appear in your list as well. To prevent
duplicates (e.g. Other and Misc.)
name your meal types the same as the users you share your meals with and they will be
merged.</p>
{% endblocktrans %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary"
data-dismiss="modal">{% trans 'Close' %}</button>
</div>
</div>
</div>
</div>
</div>
<script type="application/javascript">
moment.locale('{{request.LANGUAGE_CODE}}');
let csrftoken = Cookies.get('csrftoken');
Vue.http.headers.common['X-CSRFToken'] = csrftoken;
let app = new Vue({
delimiters: ['[[', ']]'],
el: '#app',
data: {
week: moment().format('YYYY-[W]WW'),
days: moment.weekdays(true),
plan_entries: [],
meal_types: [],
meal_types_edit: [],
meal_plan: {},
plan_detail: {shared: []},
recipes: [],
recipe_query: '',
pseudo_note_list: [
{id: 0, title: '', text: ''}
],
new_note_title: '',
new_note_text: '',
default_shared_users: [],
user_id_update: [],
user_names: {},
shopping: false,
shopping_list: [],
},
mounted: function () {
this.default_shared_users = [{% for u in request.user.userpreference.plan_share.all %}
{{ u.pk }},
{% endfor %}]
this.$set(this.user_names, {{ request.user.pk }}, '{{ request.user.get_user_name }}')
this.user_id_update = Array.from(this.default_shared_users)
this.updatePlan();
this.getRecipes();
},
methods: {
updatePlan: function () {
let planEntryPromise = this.getPlanEntries();
let planTypePromise = this.getPlanTypes();
Promise.allSettled([planEntryPromise, planTypePromise]).then(() => {
this.buildGrid()
})
},
getPlanEntries: function () {
return this.$http.get("{% url 'api:mealplan-list' %}?html_week=" + this.week).then((response) => {
this.plan_entries = response.data;
}).catch((err) => {
console.log("getPlanEntries error: ", err);
})
},
getPlanTypes: function () {
return this.$http.get("{% url 'api:mealtype-list' %}").then((response) => {
this.meal_types = response.data;
this.meal_types_edit = jQuery.extend(true, [], response.data);
for (let mte of this.meal_types_edit) {
this.$set(mte, 'delete', false)
}
}).catch((err) => {
console.log("getPlanTypes error: ", err);
})
},
buildGrid: function () {
this.meal_plan = {}
for (let e of this.plan_entries) {
let new_type = {id: e.meal_type, name: e.meal_type_name, created_by: e.created_by}
if (this.meal_types.filter(el => el.name === new_type.name).length === 0) {
this.meal_types.push(new_type)
}
}
for (let t of this.meal_types) {
this.$set(this.meal_plan, t.name, {
name: t.name,
meal_type: t.id,
days: {}
})
for (let d of this.days) {
let date = moment(this.week).weekday(this.days.indexOf(d)).format('YYYY-MM-DD')
this.$set(this.meal_plan[t.name].days, date, {
name: d,
date: date,
items: []
})
}
}
for (let e of this.plan_entries) {
this.meal_plan[e.meal_type_name].days[e.date].items.push(e)
for (let u of e.shared) {
if (!this.user_id_update.includes(parseInt(u))) {
this.user_id_update.push(parseInt(u))
}
}
}
this.updateUserNames()
},
getRecipes: function () {
let url = "{% url 'api:recipe-list' %}?limit=5"
if (this.recipe_query !== '') {
url += '&query=' + this.recipe_query;
}
this.$http.get(url).then((response) => {
this.recipes = response.data;
}).catch((err) => {
console.log("getRecipes error: ", err);
})
},
getMdNote: function () {
let url = "{% url 'api:recipe-list' %}?limit=5"
if (this.recipe_query !== '') {
url += '&query=' + this.recipe_query;
}
this.$http.get(url).then((response) => {
this.recipes = response.data;
}).catch((err) => {
console.log("getRecipes error: ", err);
})
},
updateUserNames: function () {
return this.$http.get("{% url 'api:username-list' %}?filter_list=[" + this.user_id_update + ']').then((response) => {
for (let u of response.data) {
let name = u.username
if (`${u.first_name} ${u.last_name}` !== ' ') {
name = `${u.first_name} ${u.last_name}`
}
this.$set(this.user_names, u.id, name);
}
}).catch((err) => {
console.log("updateUserNames error: ", err);
})
},
dragChanged: function (date, meal_type, evt) {
if (evt.added !== undefined) {
let plan_entry = evt.added.element
plan_entry.date = date
plan_entry.meal_type = meal_type.id
plan_entry.meal_type_name = meal_type.name
if (plan_entry.is_new) { // its not a meal plan object
plan_entry.created_by = {{ request.user.id }};
plan_entry.shared = this.default_shared_users
this.$http.post(`{% url 'api:mealplan-list' %}`, plan_entry).then((response) => {
let entry = response.data
this.meal_plan[entry.meal_type_name].days[entry.date].items = this.meal_plan[entry.meal_type_name].days[entry.date].items.filter(item => !item.is_new)
this.meal_plan[entry.meal_type_name].days[entry.date].items.push(entry)
}).catch((err) => {
console.log("dragChanged create error", err);
})
} else {
this.$http.put(`{% url 'api:mealplan-list' %}${plan_entry.id}/`, plan_entry).then((response) => {
}).catch((err) => {
console.log("dragChanged update error", err);
})
}
}
},
deleteEntry: function (entry) {
$('#id_plan_detail_modal').modal('hide')
this.$http.delete(`{% url 'api:mealplan-list' %}${entry.id}/`, entry).then((response) => {
this.meal_plan[entry.meal_type_name].days[entry.date].items = this.meal_plan[entry.meal_type_name].days[entry.date].items.filter(item => item !== entry)
}).catch((err) => {
console.log("deleteEntry error: ", err);
})
},
updatePlanTypes: function () {
let promise_list = []
let i = 0
for (let x of this.meal_types_edit) {
x.order = i
i++
if (x.id === undefined && !x.delete) {
x.created_by = {{ request.user.id }}
promise_list.push(this.$http.post("{% url 'api:mealtype-list' %}", x).then((response) => {
}).catch((err) => {
console.log("updatePlanTypes create error: ", err);
}))
} else if (x.delete) {
if (x.id !== undefined) {
promise_list.push(this.$http.delete(`{% url 'api:mealtype-list' %}${x.id}/`, x).then((response) => {
}).catch((err) => {
console.log("updatePlanTypes delete error: ", err);
}))
}
} else {
promise_list.push(this.$http.put(`{% url 'api:mealtype-list' %}${x.id}/`, x).then((response) => {
}).catch((err) => {
console.log("updatePlanTypes update error: ", err);
}))
}
}
Promise.allSettled(promise_list).then(() => {
this.updatePlan()
$('#id_plan_types_modal').modal('hide')
})
},
markTypeDelete: function (element) {
if (confirm('{% trans 'When deleting a meal type all entries using that type will be deleted as well. Deletion will apply when configuration is saved. Do you want to proceed?' %}')) {
element.delete = true
}
},
cloneRecipe: function (recipe) {
return {
id: Math.round(Math.random() * 1000) + 10000,
recipe: recipe.id,
recipe_name: recipe.name,
title: this.new_note_title,
note: this.new_note_text,
is_new: true
}
},
cloneNote: function () {
let new_entry = {
id: Math.round(Math.random() * 1000) + 10000,
title: this.new_note_title,
note: this.new_note_text,
is_new: true,
}
if (new_entry.title === '') {
new_entry.title = '{% trans 'Title' %}'
}
this.new_note_title = ''
this.new_note_text = ''
return new_entry
},
planElementName: function (element) {
if (element.title) {
return element.title
} else if (element.recipe_name) {
return element.recipe_name
} else {
return element.name
}
},
planDetailRecipeUrl: function () {
return "{% url 'view_recipe' 12345 %}".replace(/12345/, this.plan_detail.recipe);
},
planDetailEditUrl: function () {
return "{% url 'edit_meal_plan' 12345 %}".replace(/12345/, this.plan_detail.id);
},
planDetailUserList: function () {
let users = []
for (let u of this.plan_detail.shared) {
users.push(this.user_names[u])
}
return users.join(', ')
},
formatLocalDate: function (date) {
return moment(date).format('LL')
},
formatDateDay: function (day) {
return moment(this.week).weekday(this.days.indexOf(day)).format('D')
},
changeWeek: function (change) {
this.week = moment(this.week).add(change, 'w').format('YYYY-[W]WW')
this.updatePlan();
},
getShoppingUrl: function () {
let url = "{% url 'view_shopping' %}"
let first = true
for (let se of this.shopping_list) {
if (first) {
url += `?r=${se.recipe}`
first = false
} else {
url += `&r=${se.recipe}`
}
}
return url
},
getIcalUrl: function () {
return "{% url 'api_get_plan_ical' 12345 %}".replace(/12345/, this.week);
},
addDayToShopping: function (day) {
let date = moment(this.week).weekday(this.days.indexOf(day)).format('YYYY-MM-DD')
for (let t of this.meal_types) {
for (let i of this.meal_plan[t.name].days[date].items) {
if (!this.shopping_list.includes(i)) {
this.shopping_list.push(i)
}
}
}
}
}
});
</script>
{% endblock %}

View File

@@ -12,7 +12,7 @@
{% block content %}
<div class="row">
<div class="col col-12">
<h3>{{ plan.get_meal_name }} {{ plan.date }} <a href="{% url 'edit_meal_plan' plan.pk %}"
<h3>{{ plan.meal_type }} {{ plan.date }} <a href="{% url 'edit_meal_plan' plan.pk %}"
class="d-print-none"><i class="fas fa-pencil-alt"></i></a>
</h3>
<small class="text-muted">{% trans 'Created by' %} {{ plan.created_by.get_user_name }}</small>
@@ -77,7 +77,7 @@
<ul class="list-group list-group-flush">
{% for x in same_day_plan %}
<li class="list-group-item"><a href="{% url 'view_plan_entry' x.pk %}">{{ x.get_label }}
({{ x.get_meal_name }})</a></li>
({{ x.meal_type }})</a></li>
{% endfor %}
</ul>
{% endif %}

View File

@@ -28,32 +28,41 @@
<div class="row">
<div class="col col-md-8">
{% recipe_rating recipe request.user as rating %}
<h3>{{ recipe.name }} {{ rating|safe }} <a href="{% url 'edit_recipe' recipe.pk %}" class="d-print-none"><i
class="fas fa-pencil-alt"></i></a></h3>
</div>
<div class="col col-md-4 d-print-none" style="text-align: right">
<button class="btn btn-success" onclick="$('#bookmarkModal').modal({'show':true})" data-toggle="tooltip"
data-placement="top" title="{% trans 'Add to Book' %}"><i
class="fas fa-bookmark"></i></button>
{% if ingredients %}
<a class="btn btn-secondary" href="{% url 'view_shopping' %}?r={{ recipe.pk }}" data-toggle="tooltip"
data-placement="top" title="{% trans 'Generate shopping list' %}"><i
class="fas fa-shopping-cart"></i></a>
{% endif %}
<a class="btn btn-info" href="{% url 'new_meal_plan' %}?recipe={{ recipe.pk }}" data-toggle="tooltip"
data-placement="top" title="{% trans 'Add to Mealplan' %}"><i
class="fas fa-calendar"></i></a>
<button class="btn btn-warning" onclick="openCookLogModal({{ recipe.pk }})" data-toggle="tooltip"
data-placement="top" title="{% trans 'Log Cooking' %}"><i class="fas fa-clipboard-list"></i>
</button>
<a class="btn btn-light" onclick="window.print()" data-toggle="tooltip"
data-placement="top" title="{% trans 'Print' %}"><i
class="fas fa-print"></i></a>
<a class="btn btn-primary" href="{% url 'view_export' %}?r={{ recipe.pk }}" target="_blank"
data-toggle="tooltip"
data-placement="top" title="{% trans 'Export recipe' %}"><i
class="fas fa-file-export"></i></a>
<h3>{{ recipe.name }} {{ rating|safe }}</h3>
</div>
{% if request.user.is_authenticated %}
<div class="col col-md-4 d-print-none" style="text-align: right">
<div class="dropdown">
<a class="btn shadow-none" href="#" role="button" id="dropdownMenuLink"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fas fa-ellipsis-v"></i>
</a>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuLink">
<a class="dropdown-item" href="{% url 'edit_recipe' recipe.pk %}"><i
class="fas fa-pencil-alt"></i> {% trans 'Bearbeiten' %}</a>
<button class="dropdown-item" onclick="$('#bookmarkModal').modal({'show':true})">
<i class="fas fa-bookmark fa-fw"></i> {% trans 'Add to Book' %}</button>
{% if ingredients %}
<a class="dropdown-item" href="{% url 'view_shopping' %}?r={{ recipe.pk }}">
<i class="fas fa-shopping-cart fa-fw"></i> {% trans 'Add to Shopping' %}</a>
{% endif %}
<a class="dropdown-item" href="{% url 'new_meal_plan' %}?recipe={{ recipe.pk }}"><i
class="fas fa-calendar fa-fw"></i> {% trans 'Add to Plan' %}</a>
<button class="dropdown-item" onclick="openCookLogModal({{ recipe.pk }})"><i
class="fas fa-clipboard-list fa-fw"></i> {% trans 'Log Cooking' %}</button>
<button class="dropdown-item" onclick="window.print()"><i
class="fas fa-print fa-fw"></i> {% trans 'Print' %}</button>
<a class="dropdown-item" href="{% url 'view_export' %}?r={{ recipe.pk }}" target="_blank"
rel="noopener noreferrer"><i class="fas fa-file-export fa-fw"></i> {% trans 'Export' %}</a>
{% if recipe.internal %}
<a class="dropdown-item" href="{% url 'new_share_link' recipe.pk %}" target="_blank"
rel="noopener noreferrer"><i class="fas fa-share-alt fa-fw"></i> {% trans 'Share' %}</a>
{% endif %}
</div>
</div>
</div>
{% endif %}
</div>
{% if recipe.storage %}
@@ -147,7 +156,7 @@
<td style="vertical-align: middle!important;">
{% if i.ingredient.recipe %}
<a href="{% url 'view_recipe' i.ingredient.recipe.pk %}"
target="_blank">
target="_blank" rel="noopener noreferrer">
{% endif %}
{{ i.ingredient.name }}
{% if i.ingredient.recipe %}
@@ -157,13 +166,12 @@
</td>
<td style="vertical-align: middle!important;">
{% if i.note %}
<button class="btn btn-light btn-sm d-print-none" type="button"
data-container="body"
data-toggle="popover"
data-placement="right" data-html="true" data-trigger="focus"
data-content="{{ i.note }}">
<a class="btn btn-light btn-sm d-print-none" tabindex="-1"
data-toggle="popover"
data-placement="right" data-html="true" data-trigger="focus"
data-content="{{ i.note }}">
<i class="fas fa-info"></i>
</button>
</a>
<div class="d-none d-print-block">
<i class="far fa-comment-alt"></i> {{ i.note }}
</div>
@@ -261,35 +269,40 @@
</div>
{% endif %}
<br/>
<br/>
<h5 {% if not comments %}class="d-print-none" {% endif %}><i class="far fa-comments"></i> {% trans 'Comments' %}
</h5>
{% for c in comments %}
<div class="card">
<div class="card-body">
<small class="card-title">{{ c.updated_at }} {% trans 'by' %} {{ c.created_by.username }}</small> <a
href="{% url 'edit_comment' c.pk %}" class="d-print-none"><i class="fas fa-pencil-alt"></i></a><br/>
{{ c.text }}
</div>
</div>
{% if request.user.userpreference.comments %}
<br/>
<br/>
{% endfor %}
<div class="d-print-none">
<form method="POST" class="post-form">
{% csrf_token %}
<div class="input-group mb-3">
<textarea name="comment-text" cols="15" rows="2" class="textarea form-control" required
id="comment-id_text"></textarea>
<div class="input-group-append">
<input type="submit" value="{% trans 'Comment' %}" class="btn btn-success">
<h5 {% if not comments %}class="d-print-none" {% endif %}><i class="far fa-comments"></i> {% trans 'Comments' %}
</h5>
{% for c in comments %}
<div class="card">
<div class="card-body">
<small class="card-title">{{ c.updated_at }} {% trans 'by' %} {{ c.created_by.username }}</small> <a
href="{% url 'edit_comment' c.pk %}" class="d-print-none"><i
class="fas fa-pencil-alt"></i></a><br/>
{{ c.text }}
</div>
</div>
</form>
</div>
<br/>
{% endfor %}
{% if request.user.is_authenticated %}
<div class="d-print-none">
<form method="POST" class="post-form">
{% csrf_token %}
<div class="input-group mb-3">
<textarea name="comment-text" cols="15" rows="2" class="textarea form-control" required
id="comment-id_text"></textarea>
<div class="input-group-append">
<input type="submit" value="{% trans 'Comment' %}" class="btn btn-success">
</div>
</div>
</form>
</div>
{% endif %}
{% endif %}
{% if recipe.storage %}
{% include 'include/recipe_open_modal.html' %}
@@ -327,6 +340,8 @@
<script type="text/javascript">
reloadIngredients()
$(function () {
$('[data-toggle="popover"]').popover()
});
@@ -335,20 +350,27 @@
trigger: 'focus'
});
function roundToTwo(num) {
return +(Math.round(num + "e+2") + "e-2");
function roundDecimals(num) {
let decimals = {% if request.user.userpreference.ingredient_decimals %}
{{ request.user.userpreference.ingredient_decimals }} {% else %} 2 {% endif %}
return +(Math.round(num + `e+${decimals}`) + `e-${decimals}`);
}
function reloadIngredients() {
factor = Number($('#in_factor').val());
ingredients = {
let factor = Number($('#in_factor').val());
let ingredients = {
{% for i in ingredients %}
{{ i.pk }}: {{ i.amount|unlocalize }},
{% endfor %}
}
for (var key in ingredients) {
$('#ing_' + key).html(roundToTwo(ingredients[key] * factor))
for (let key in ingredients) {
let val = ''
if (Math.abs(ingredients[key] * factor - roundDecimals(ingredients[key] * factor)) > 0) {
val += '~'
}
$('#ing_' + key).html(val + roundDecimals(ingredients[key] * factor))
}
}

View File

@@ -0,0 +1,23 @@
{% extends "rest_framework/base.html" %}
{% load i18n %}
{% block branding %}
<a class="navbar-brand" href="{% url 'index' %}">{% trans 'Recipe Home' %}</a>
{% endblock %}
{% block userlinks %}
<ul class="nav navbar-nav">
<li>
<a class="navbar-link" href="{% url 'docs_api' %}">{% trans 'API Documentation' %}</a>
</li>
<li>
<a class='navbar-link' rel="noreferrer nofollow" target="_blank"
href='https://www.django-rest-framework.org/'>
Django REST framework
</a>
</li>
</ul>
{{ block.super }}
{% endblock %}

View File

@@ -24,13 +24,15 @@
<form action="." method="post">
{% csrf_token %}
{{ user_name_form|crispy }}
<button class="btn btn-success" type="submit" name="user_name_form"><i class="fas fa-save"></i> {% trans 'Save' %}</button>
<button class="btn btn-success" type="submit" name="user_name_form"><i
class="fas fa-save"></i> {% trans 'Save' %}</button>
</form>
<form action="." method="post">
{% csrf_token %}
{{ password_form|crispy }}
<button class="btn btn-success" type="submit" name="password_form"><i class="fas fa-save"></i> {% trans 'Save' %}</button>
<button class="btn btn-success" type="submit" name="password_form"><i
class="fas fa-save"></i> {% trans 'Save' %}</button>
</form>
<br/>
@@ -66,10 +68,41 @@
<form action="." method="post">
{% csrf_token %}
{{ preference_form|crispy }}
<button class="btn btn-success" type="submit" name="preference_form"><i class="fas fa-save"></i> {% trans 'Save' %}</button>
<button class="btn btn-success" type="submit" name="preference_form"><i
class="fas fa-save"></i> {% trans 'Save' %}</button>
</form>
<br/>
<br/>
<h4><i class="fas fa-terminal fa-fw"></i> {% trans 'API Token' %}</h4>
{% trans 'You can use both basic authentication and token based authentication to access the REST API.' %} <br/>
<br/>
<div class="input-group mb-3">
<input class="form-control" value="{{ api_token }}" id="id_token">
<div class="input-group-append">
<button class="input-group-btn btn btn-primary" onclick="copyToken()"><i
class="far fa-copy"></i></button>
</div>
</div>
<br/>
{% trans 'Use the token as an Authorization header prefixed by the word token as shown in the following examples:' %}
<br/>
<code>Authorization: Token {{ api_token }}</code> {% trans 'or' %}<br/>
<code>curl -X GET http://your.domain.com/api/recipes/ -H 'Authorization: Token {{ api_token }}'</code>
<br/>
<br/>
<br/>
<br/>
<script type="application/javascript">
function copyToken() {
let token = $('#id_token');
token.select();
document.execCommand("copy");
}
</script>
{% endblock %}

View File

@@ -40,6 +40,21 @@
<br/>
<br/>
<h4>{% trans 'Secret Key' %} <span
class="badge badge-{% if secret_key %}danger{% else %}success{% endif %}">{% if secret_key %}
{% trans 'Warning' %}{% else %}{% trans 'Ok' %}{% endif %}</span></h4>
{% if secret_key %}
{% blocktrans %}
You do not have a <code>SECRET_KEY</code> configured in your <code>.env</code> file. Django defaulted to the standard key
provided with the installation which is publicly know and insecure! Please set
<code>SECRET_KEY</code> int the <code>.env</code> configuration file.
{% endblocktrans %}
{% else %}
{% trans 'Everything is fine!' %}
{% endif %}
<br/>
<br/>
<h4>{% trans 'Debug Mode' %} <span
class="badge badge-{% if debug %}danger{% else %}success{% endif %}">{% if debug %}
{% trans 'Warning' %}{% else %}{% trans 'Ok' %}{% endif %}</span></h4>

View File

@@ -0,0 +1 @@
{{ test }}

View File

@@ -0,0 +1,385 @@
{% extends "base.html" %}
{% load i18n %}
{% load static %}
{% block title %}{% trans 'URL Import' %}{% endblock %}
{% block extra_head %}
{% include 'include/vue_base.html' %}
<script src="{% static 'js/vue-multiselect.min.js' %}"></script>
<link rel="stylesheet" href="{% static 'css/vue-multiselect.min.css' %}">
{% endblock %}
{% block content %}
<div id="app">
<div class="row">
<div class="col-md-12">
<div class="input-group mb-3">
<input class="form-control" v-model="remote_url" placeholder="{% trans 'Enter website URL' %}">
<div class="input-group-append">
<button @click="loadRecipe()" class="btn btn-primary shadow-none" type="button"
id="id_btn_search"><i class="fas fa-search"></i>
</button>
</div>
</div>
</div>
</div>
<br/>
<div v-if="loading" class="text-center">
<br/>
<i class="fas fa-spinner fa-spin fa-8x"></i>
</div>
<template v-if="recipe_data !== undefined">
<form>
<div class="form-group">
<label for="id_name">{% trans 'Recipe Name' %}</label>
<input id="id_name" class="form-control" v-model="recipe_data.name">
</div>
<div class="row">
<div class="col col-md-6" v-if="recipe_data.image !== ''">
<img v-bind:src="recipe_data.image" alt="{% trans 'Recipe Image' %}"
class="img-fluid img-responsive img-rounded">
</div>
<div class="col col-md-6">
<div class="form-group">
<label for="id_prep_time">{% trans 'Preparation time ca.' %}</label>
<input id="id_prep_time" class="form-control" v-model="recipe_data.prepTime">
</div>
<div class="form-group">
<label for="id_waiting_time">{% trans 'Waiting time ca.' %}</label>
<input id="id_waiting_time" class="form-control" v-model="recipe_data.cookTime">
</div>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12">
<template v-for="(i, index) in recipe_data.recipeIngredient">
<div class="card" style="margin-top: 4px">
<div class="card-body">
<div class="row" v-if="i.original">
<div class="col-md-12" style="margin-bottom: 4px">
<span class="text-muted"><i class="fas fa-globe"></i> [[i.original]]</span>
</div>
</div>
<div class="row">
<div class="col-md-1">
<input class="form-control" v-model="i.amount">
</div>
<div class="col-md-5">
<table class="table-layout:fixed">
<col width="95%"/>
<col width="5%"/>
<tr>
<td>
<multiselect v-tabindex
ref="unit"
style="width: 100%!important;"
v-model="i.unit"
:options="units"
:close-on-select="true"
:clear-on-select="true"
:allow-empty="true"
:preserve-search="true"
placeholder="{% trans 'Select one' %}"
tag-placeholder="{% trans 'Select' %}"
label="text"
:taggable="true"
@tag="addUnitType"
@open="openUnitSelect"
:id="'unit_' + index"
track-by="id"
:multiple="false"
:loading="units_loading"
@search-change="searchUnits">
</multiselect>
</td>
<td>
<button class="btn btn-outline-success btn-lg" type="button"
@click="i.unit = ''" tabindex="-1">
<i class="fas fa-eraser"></i></button>
</td>
</tr>
</table>
</div>
<div class="col-md-5">
<multiselect v-tabindex
ref="ingredient"
v-model="i.ingredient"
:options="ingredients"
:taggable="true"
@tag="addIngredientType"
placeholder="{% trans 'Select one' %}"
tag-placeholder="{% trans 'Select' %}"
:close-on-select="true"
:clear-on-select="true"
:allow-empty="false"
:preserve-search="true"
label="text"
:id="'ingredient_' + index"
track-by="id"
:multiple="false"
:loading="ingredients_loading"
@search-change="searchIngredients"
@open="openIngredientSelect">
</multiselect>
</div>
<div class="col-md-1">
<button class="btn btn-outline-danger btn-lg" type="button"
@click="deleteIngredient(i)" tabindex="-1"><i
class="fas fa-trash-alt"></i></button>
</div>
</div>
</div>
</div>
</template>
<div style="text-align: center; margin-top: 16px">
<button class="btn btn-success" type="button" @click="addIngredient()"><i
class="fas fa-plus"></i></button>
<br/><br/>
</div>
</div>
</div>
<div class="form-group">
<label for="id_instructions">{% trans 'Instructions' %}</label>
<textarea id="id_instructions" class="form-control" v-model="recipe_data.recipeInstructions"
rows="8"></textarea>
</div>
<div class="form-group">
<label for="id_keywords">{% trans 'Keywords' %}</label>
<multiselect
v-model="recipe_data.keywords"
:options="keywords"
:close-on-select="false"
:clear-on-select="true"
:hide-selected="true"
:preserve-search="true"
placeholder="{% trans 'Select one' %}"
label="text"
track-by="id"
id="id_keywords"
:multiple="true"
:loading="keywords_loading"
@search-change="searchKeywords">
</multiselect>
</div>
<div class="form-group">
<label for="id_all_keywords">{% trans 'All Keywords' %}</label><br/>
<input id="id_all_keywords" type="checkbox"
v-model="all_keywords"> {% trans 'Import all Keywords not only the ones already existing.' %}
</div>
<div class="form-group">
<button type="button" class="btn btn-success" @click="importRecipe()">{% trans 'Import' %}</button>
</div>
<br/>
<br/>
<br/>
</form>
</template>
<template v-if="error !== undefined">
<div>
<div style="text-align: center">
<i class="fas fa-robot fa-8x"></i><br/><br/>
[[error.msg]]
</div>
</div>
<br/>
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card border-info mb-6">
<div class="card-body text-info">
<h5 class="card-title">{% trans 'Information' %}</h5>
<p class="card-text">
{% blocktrans %} Only websites containing ld+json or microdata information can currently
be imported. Most big recipe pages support this. If you site cannot be imported but
you think
it probably has some kind of structured data feel free to post an example in the
github issues.{% endblocktrans %}
</p>
<a href="https://developers.google.com/search/docs/data-types/recipe" target="_blank"
rel="noreferrer nofollow"
class="card-link">{% trans 'Google ld+json Info' %}</a>
<a href="https://github.com/vabene1111/recipes/issues" target="_blank"
rel="noreferrer nofollow"
class="card-link">{% trans 'GitHub Issues' %}</a>
<a href="https://schema.org/Recipe" target="_blank" rel="noreferrer nofollow"
class="card-link">{% trans 'Recipe Markup Specification' %}</a>
</div>
</div>
</div>
</div>
</template>
</div>
<script type="application/javascript">
let csrftoken = Cookies.get('csrftoken');
Vue.http.headers.common['X-CSRFToken'] = csrftoken;
Vue.component('vue-multiselect', window.VueMultiselect.default)
let app = new Vue({
components: {
Multiselect: window.VueMultiselect.default
},
delimiters: ['[[', ']]'],
el: '#app',
data: {
remote_url: '',
keywords: [],
keywords_loading: false,
units: [],
units_loading: false,
ingredients: [],
ingredients_loading: false,
recipe_data: undefined,
error: undefined,
loading: false,
all_keywords: false,
},
directives: {
tabindex: {
inserted(el) {
el.setAttribute('tabindex', 0);
}
}
},
mounted: function () {
this.searchKeywords('')
this.searchUnits('')
this.searchIngredients('')
},
methods: {
loadRecipe: function () {
this.recipe_data = undefined
this.error = undefined
this.loading = true
this.$http.get("{% url 'api_recipe_from_url' 12345 %}".replace(/12345/, this.remote_url)).then((response) => {
this.recipe_data = response.data;
this.loading = false
}).catch((err) => {
this.error = err.data
this.loading = false
console.log(err)
})
},
importRecipe: function () {
this.$set(this.recipe_data, 'all_keywords', this.all_keywords)
this.$http.post(`{% url 'data_import_url' %}`, this.recipe_data).then((response) => {
location.href = response.data
}).catch((err) => {
console.log("dragChanged create error", err);
})
},
deleteIngredient: function (i) {
this.recipe_data.recipeIngredient = this.recipe_data.recipeIngredient.filter(item => item !== i)
},
addIngredient: function (i) {
this.recipe_data.recipeIngredient.push({
unit: {id: Math.random() * 1000, text: '{{ request.user.userpreference.default_unit }}'},
amount: 0,
ingredient: {id: Math.random() * 1000, text: ''}
})
},
addIngredientType: function (tag, index) {
index = index.replace('ingredient_', '')
let new_ingredient = this.recipe_data.recipeIngredient[index]
new_ingredient.ingredient = {'id': Math.random() * 1000, 'text': tag}
this.ingredients.push(new_ingredient.ingredient)
this.recipe_data.recipeIngredient[index] = new_ingredient
},
addUnitType: function (tag, index) {
index = index.replace('unit_', '')
let new_unit = this.recipe_data.recipeIngredient[index]
new_unit.unit = {'id': Math.random() * 1000, 'text': tag}
this.units.push(new_unit.unit)
this.recipe_data.recipeIngredient[index] = new_unit
},
openUnitSelect: function (id) {
let index = id.replace('unit_', '')
this.$set(app.$refs.unit[index].$data, 'search', this.recipe_data.recipeIngredient[index].unit.text)
},
openIngredientSelect: function (id) {
let index = id.replace('ingredient_', '')
this.$set(this.$refs.ingredient[index].$data, 'search', this.recipe_data.recipeIngredient[index].ingredient.text)
},
searchKeywords: function (query) {
this.keywords_loading = true
this.$http.get("{% url 'dal_keyword' %}" + '?q=' + query).then((response) => {
this.keywords = response.data.results;
this.keywords_loading = false
}).catch((err) => {
console.log(err)
})
},
searchUnits: function (query) {
this.units_loading = true
this.$http.get("{% url 'dal_unit' %}" + '?q=' + query).then((response) => {
this.units = response.data.results;
if (this.recipe_data !== undefined) {
for (let x of Array.from(this.recipe_data.recipeIngredient)) {
if (x.unit.text !== '') {
this.units = this.units.filter(item => item.text !== x.unit.text)
this.units.push(x.unit)
}
}
}
this.units_loading = false
}).catch((err) => {
console.log(err)
})
},
searchIngredients: function (query) {
this.ingredients_loading = true
this.$http.get("{% url 'dal_ingredient' %}" + '?q=' + query).then((response) => {
this.ingredients = response.data.results
if (this.recipe_data !== undefined) {
for (let x of Array.from(this.recipe_data.recipeIngredient)) {
if (x.ingredient.text !== '') {
this.ingredients = this.ingredients.filter(item => item.text !== x.ingredient.text)
this.ingredients.push(x.ingredient)
}
}
}
this.ingredients_loading = false
}).catch((err) => {
console.log(err)
})
},
}
});
</script>
{% endblock %}

View File

@@ -8,6 +8,7 @@ from django.urls import reverse, NoReverseMatch
from cookbook.helper.mdx_attributes import MarkdownFormatExtension
from cookbook.helper.mdx_urlize import UrlizeExtension
from cookbook.models import get_model_name, CookLog
from recipes import settings
register = template.Library()
@@ -40,6 +41,8 @@ def markdown(value):
@register.simple_tag
def recipe_rating(recipe, user):
if not user.is_authenticated:
return ''
rating = recipe.cooklog_set.filter(created_by=user).aggregate(Avg('rating'))
if rating['rating__avg']:
@@ -57,8 +60,15 @@ def recipe_rating(recipe, user):
@register.simple_tag
def recipe_last(recipe, user):
if not user.is_authenticated:
return ''
last = recipe.cooklog_set.filter(created_by=user).last()
if last:
return last.created_at
else:
return ''
@register.simple_tag
def is_debug():
return settings.DEBUG

View File

@@ -26,7 +26,7 @@ def theme_url(request):
def nav_color(request):
if not request.user.is_authenticated:
return 'primary'
return request.user.userpreference.nav_color
return request.user.userpreference.nav_color.lower()
@register.simple_tag

View File

View File

@@ -0,0 +1,86 @@
import json
from django.contrib import auth
from django.urls import reverse
from cookbook.models import UserPreference
from cookbook.tests.views.test_views import TestViews
class TestApiUserPreference(TestViews):
def setUp(self):
super(TestApiUserPreference, self).setUp()
def test_preference_create(self):
r = self.user_client_1.post(reverse('api:userpreference-list'))
self.assertEqual(r.status_code, 201)
response = json.loads(r.content)
self.assertEqual(response['user'], auth.get_user(self.user_client_1).id)
self.assertEqual(response['theme'], UserPreference._meta.get_field('theme').get_default())
def test_preference_list(self):
UserPreference.objects.create(user=auth.get_user(self.user_client_1))
UserPreference.objects.create(user=auth.get_user(self.guest_client_1))
# users can only see own preference in list
r = self.user_client_1.get(reverse('api:userpreference-list'))
self.assertEqual(r.status_code, 200)
response = json.loads(r.content)
self.assertEqual(len(response), 1)
self.assertEqual(response[0]['user'], auth.get_user(self.user_client_1).id)
# superusers can see all user prefs in list
r = self.superuser_client.get(reverse('api:userpreference-list'))
self.assertEqual(r.status_code, 200)
response = json.loads(r.content)
self.assertEqual(len(response), 2)
def test_preference_retrieve(self):
UserPreference.objects.create(user=auth.get_user(self.user_client_1))
UserPreference.objects.create(user=auth.get_user(self.guest_client_1))
self.batch_requests([(self.guest_client_1, 404), (self.user_client_1, 200), (self.user_client_2, 404), (self.anonymous_client, 403), (self.admin_client_1, 404), (self.superuser_client, 200)],
reverse('api:userpreference-detail', args={auth.get_user(self.user_client_1).id}))
def test_preference_update(self):
UserPreference.objects.create(user=auth.get_user(self.user_client_1))
UserPreference.objects.create(user=auth.get_user(self.guest_client_1))
# can update users preference
r = self.user_client_1.put(reverse('api:userpreference-detail', args={auth.get_user(self.user_client_1).id}), {'theme': UserPreference.DARKLY}, content_type='application/json')
response = json.loads(r.content)
self.assertEqual(r.status_code, 200)
self.assertEqual(response['theme'], UserPreference.DARKLY)
# cant set another users non existent pref
r = self.user_client_1.put(reverse('api:userpreference-detail', args={auth.get_user(self.user_client_2).id}), {'theme': UserPreference.DARKLY}, content_type='application/json')
self.assertEqual(r.status_code, 404)
# cant set another users existent pref
r = self.user_client_2.put(reverse('api:userpreference-detail', args={auth.get_user(self.user_client_1).id}), {'theme': UserPreference.FLATLY}, content_type='application/json')
self.assertEqual(r.status_code, 404)
# can set pref as superuser
r = self.superuser_client.put(reverse('api:userpreference-detail', args={auth.get_user(self.user_client_1).id}), {'theme': UserPreference.FLATLY}, content_type='application/json')
self.assertEqual(r.status_code, 200)
def test_preference_delete(self):
UserPreference.objects.create(user=auth.get_user(self.user_client_1))
# can delete own preference
r = self.user_client_1.delete(reverse('api:userpreference-detail', args={auth.get_user(self.user_client_1).id}))
self.assertEqual(r.status_code, 204)
self.assertEqual(UserPreference.objects.count(), 0)
UserPreference.objects.create(user=auth.get_user(self.user_client_1))
# cant delete other preference
r = self.user_client_2.delete(reverse('api:userpreference-detail', args={auth.get_user(self.user_client_1).id}))
self.assertEqual(r.status_code, 404)
self.assertEqual(UserPreference.objects.count(), 1)
# superuser can delete everything
r = self.superuser_client.delete(reverse('api:userpreference-detail', args={auth.get_user(self.user_client_1).id}))
self.assertEqual(r.status_code, 204)
self.assertEqual(UserPreference.objects.count(), 0)

View File

View File

@@ -0,0 +1,28 @@
import json
from cookbook.helper.recipe_url_import import get_from_html
from cookbook.tests.test_setup import TestBase
class TestEditsRecipe(TestBase):
def test_ld_json(self):
test_list = [
{'file': 'cookbook/tests/resources/websites/ld_json_1.html', 'result_length': 3128},
{'file': 'cookbook/tests/resources/websites/ld_json_2.html', 'result_length': 1450},
{'file': 'cookbook/tests/resources/websites/ld_json_3.html', 'result_length': 1545},
{'file': 'cookbook/tests/resources/websites/ld_json_4.html', 'result_length': 1657},
{'file': 'cookbook/tests/resources/websites/ld_json_invalid.html', 'result_length': 115},
{'file': 'cookbook/tests/resources/websites/ld_json_itemList.html', 'result_length': 3131},
{'file': 'cookbook/tests/resources/websites/ld_json_multiple.html', 'result_length': 1546},
{'file': 'cookbook/tests/resources/websites/micro_data_1.html', 'result_length': 1022},
{'file': 'cookbook/tests/resources/websites/micro_data_2.html', 'result_length': 1384},
{'file': 'cookbook/tests/resources/websites/micro_data_3.html', 'result_length': 1100},
{'file': 'cookbook/tests/resources/websites/micro_data_4.html', 'result_length': 4231},
]
for test in test_list:
with open(test['file'], 'rb') as file:
parsed_content = json.loads(get_from_html(file.read(), 'test_url').content)
self.assertEqual(len(str(parsed_content)), test['result_length'])
file.close()

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,7 @@
<script type="application/ld+json">
{"name":"Selbst gemachter Schokopudding","description":"Zutaten für das Rezept Selbst gemachter Schokopudding: Kakao, Zucker, Schokolade, Milch, Speisestärke","image":[{"name":"schoko-pudding.jpg","url":"https:\/\/image.brigitte.de\/10541752\/16x9-1280-720\/205e3db86adcef921b9631deda1d795d\/Xo\/schoko-pudding-262657ee2843930a8f1df01a976f93d4-schoko-pudding-.jpg","width":"1280","height":"720","@type":"ImageObject"},{"name":"schoko-pudding.jpg","url":"https:\/\/image.brigitte.de\/10541752\/16x9-938-528\/205e3db86adcef921b9631deda1d795d\/AD\/schoko-pudding-262657ee2843930a8f1df01a976f93d4-schoko-pudding-.jpg","width":"938","height":"528","@type":"ImageObject"},{"name":"schoko-pudding.jpg","url":"https:\/\/image.brigitte.de\/10541752\/large1x1-622-622\/7181ab746b57d6127ecc4353248a2433\/RS\/schoko-pudding-262657ee2843930a8f1df01a976f93d4-schoko-pudding-.jpg","width":"622","height":"622","@type":"ImageObject"}],"mainEntityOfPage":{"@id":"https:\/\/www.brigitte.de\/rezepte\/selbst-gemachter-schokopudding-10541750.html","@type":"WebPage"},"datePublished":"2019-03-17T13:47:24+01:00","dateModified":"2019-11-07T09:13:54+01:00","author":{"name":"BRIGITTE Küche","@type":"Person"},"publisher":{"name":"BRIGITTE.de","logo":{"url":"https:\/\/image.brigitte.de\/11476842\/uncropped-0-0\/f19537e97b9189bf0f25ce924168bedb\/kK\/bri-logo-schema-org.png","width":"167","height":"60","@type":"ImageObject"},"@type":"Organization"},"aggregateRating":{"bestRating":5,"worstRating":1,"ratingValue":3.8,"ratingCount":84,"@type":"AggregateRating"},"totalTime":"PT10M","recipeIngredient":["50 Gramm Schokolade","500 Milliliter Milch","1 EL Kakao","1 EL Zucker","35 Gramm Speisestärke"],"recipeInstructions":["Die Schokolade grob hacken. Die Hälfte der Milch aufkochen und die gehackte Schokolade darin schmelzen lassen. Kakao, Zucker und Stärke mischen und mit der restlichen Milch glatt rühren.","Die angerührte Mischung dann in die kochende Milch gießen und etwa 1 Minute unter Rühren kochen lassen. Den Pudding in kleine Portionsbecher oder Tassen füllen und abkühlen lassen. Dann im Kühlschrank mindestens 4 Stunden gut durchkühlen lassen."],"nutrition":{"calories":185,"proteinContent":7,"fatContent":7,"carbohydrateContent":24,"@type":"NutritionInformation"},"@context":"http:\/\/schema.org","@type":"Recipe"}
</script>
<script type="application/ld+json">
{"@context":"http:\/\/schema.org","@type":"BreadcrumbList","itemListElement":[{"position":1,"item":{"name":"Home","@id":"https:\/\/www.brigitte.de\/","@type":"WebPage"},"@type":"ListItem"},{"position":2,"item":{"name":"Rezepte","@id":"https:\/\/www.brigitte.de\/rezepte\/","@type":"WebPage"},"@type":"ListItem"},{"position":3,"item":{"name":"Selbst gemachter Schokopudding","@id":"https:\/\/www.brigitte.de\/rezepte\/selbst-gemachter-schokopudding-10541750.html","@type":"WebPage"},"@type":"ListItem"}]}
</script>

View File

@@ -0,0 +1,4 @@
<script type="application/ld+json">
{"@context":"http:\/\/schema.org","@type":"Recipe","name":"Schokopudding","author":"GU","recipeIngredient":["4 EL Speisest\u00e4rke","400 ml Milch","1 EL Zucker","1 EL Kakaopulver","50 g Schokoladenreste","150 g Sahne","150 g Vanillejoghurt"],"recipeInstructions":["St\u00e4rke mit 100 ml Milch verr\u00fchren. Restliche Milch mit Zucker, Kakaopulver und Schokolade aufkochen. St\u00e4rke mit einem Schneebesen einr\u00fchren und 2 Min. unter R\u00fchren kochen. In Sch\u00e4lchen f\u00fcllen und kalt stellen.","F\u00fcr die Vanilleso\u00dfe die Sahne steif schlagen, den Joghurt langsam zugeben und dabei weiterschlagen. Zusammen mit dem Pudding servieren."],"image":"https:\/\/www.kuechengoetter.de\/uploads\/media\/630x630\/06\/15676-schokopudding.jpg?v=1-0","prepTime":"PT15M","cookTime":"","aggregateRating":4,"description":"Klassischen Schokoladenpudding machen Sie ganz schnell selbst. Wer keine Haut auf dem Pudding mag, legt sofort nach dem Einf\u00fcllen Frischhaltefolie auf die Oberfl\u00e4che."}
</script>

View File

@@ -0,0 +1,8 @@
<script type="application/ld+json">{"@context":"https:\/\/schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","item":{"@id":"\/","name":"Start"},"position":1},{"@type":"ListItem","item":{"@id":"\/rezepte","name":"Rezepte"},"position":2},{"@type":"ListItem","item":{"@id":"\/rezepte\/58294-rzpt-schokoladenpudding","name":"Schokoladenpudding"},"position":3}]}</script>
<script type="application/ld+json">
{"@context":"http:\/\/schema.org\/","@type":"Recipe","url":"https:\/\/www.essen-und-trinken.de\/rezepte\/58294-rzpt-schokoladenpudding","name":"Schokoladenpudding","image":"https:\/\/static.essen-und-trinken.de\/bilder\/f6\/bf\/7243\/galleryimage\/c0dd2ddf32bd082a44160917a7b59a4f.jpg","recipeYield":"4 Portionen","nutrition":{"@type":"NutritionInformation","servingSize":"1 Portion","calories":"375 kcal","fatContent":"20 g","carbohydrateContent":"38 g","proteinContent":"7 g"},"totalTime":"PT0H10M","recipeIngredient":["100 g Zartbitterschokolade","500 ml Milch","35 g Speisest\u00e4rke","2 El Kakaopulver","50 g Zucker","100 ml Schlagsahne"],"recipeInstructions":" \n<p>Schokolade grob hacken. 350 ml Milch in einem Topf erw\u00e4rmen. Die H\u00e4lfte der Schokolade zugeben und unter R\u00fchren schmelzen.<\/p> \n<p>Die restliche Milch mit St\u00e4rke, Kakao und Zucker mit dem Schneebesen kl\u00fcmpchenfrei verr\u00fchren. Schokoladenmilch unter R\u00fchren zum Kochen bringen. Kakao\u00ad-St\u00e4rke\u00ad-Mischung einr\u00fchren, aufkochen und 1 Min. kochen. Hei\u00dfen Pudding in 4 Tassen f\u00fcllen (Inhalt ca. 150 ml), mit Klarsichtfolie abdecken und kalt stellen.<\/p> \n<p>Sahne steif schlagen. Schokoladenpudding mit Sahne und der restlichen gehackten Schokolade bestreut servieren.<\/p>","aggregateRating":{"@type":"AggregateRating","ratingValue":"3.76623","reviewCount":"154","bestRating":5,"worstRating":1}}</script>

View File

@@ -0,0 +1,16 @@
<script type="application/ld+json"> {
"@context": "https://schema.org/",
"@type": "Recipe",
"name": "Selbstgemachter Schokopudding",
"author": "AP",
"image": "https://www.lidl-kochen.de/images/recipe/64590/selbstgemachter-schokopudding-144479.jpg",
"description": "Rezept für Selbstgemachter Schokopudding » Über 245x nachgekocht » 20min Zubereitung » 7 Zutaten » 473 kcal/Portion
",
"keywords": "Schokolade, Milch, Schlagsahne, Kuvertüre, schnell, einfach, günstig, lecker, leicht, Snack, glutenfrei, vegetarisch, unter 500 kcal, Sommer, Winter, Herbst, Frühling, Deutschland, Kinder, Familie, für Singles, für Studenten, kochen, ohne Backofen, für Paare, fürs Büro, für die Arbeit, beliebte Rezepte, Dessert", "recipeCuisine": "Deutschland", "cookTime": "PT20M",
"nutrition": {
"@type": "NutritionInformation",
"calories": "473 Kalorie"
},
"recipeInstructions": [{"@type":"HowToStep","text":"Schokolade fein hacken. In einem Topf 400 ml Milch, 150 g Sahne, Zucker, Salz und Schokolade langsam bei kleiner bis mittlerer Stufe unter Rühren erhitzen, bis die Schokolade geschmolzen ist. Anschließend aufkochen, dabei weiter rühren. \r\n\r\n"},{"@type":"HowToStep","text":"Übrige kalte Milch und Stärke in einer Schüssel mit einem Schneebesen gründlich verrühren. Kochende Milch vom Herd nehmen, angerührte Stärke einrühren und Topf zurück auf den Herd setzen. Unter Rühren ca. 1 Min. aufkochen, bis der Pudding dickflüssig ist. Vom Herd nehmen, in eine mit kaltem Wasser ausgespülte Schüssel füllen und erkalten lassen. \r\n\r\n"},{"@type":"HowToStep","text":"In einem hohen Gefäß übrige Sahne mit einem Handrührgerät mit Schneebesen steif schlagen. Pudding mit Sahne und Schokostreuseln garnieren und servieren.\r\n\r\nGuten Appetit!"}],
"recipeIngredient": ["Kuvertüre, zartbitter 150 g","Milch 450 ml","Schlagsahne 200 g","Zucker 75 g","Salz Prise","Speisestärke 30 g","Schokoladenstreusel, Zartbitter 2 EL"],
"recipeCategory": "Snack, Dessert, Andere" }</script>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,2 @@
<script type="application/ld+json">[{"@context":"http:\/\/schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"item":{"@id":"\/","name":"Home"}},{"@type":"ListItem","position":2,"item":{"@id":"https:\/\/www.gutekueche.de\/rezepte","name":"Rezepte"}}]},{"@context":"http:\/\/schema.org","@type":"Recipe","name":"Mamis feiner Schokopudding","url":"https:\/\/www.gutekueche.de\/mamis-feiner-schokopudding-rezept-4274","image":["https:\/\/cdn.gutekueche.de\/upload\/rezept\/4274\/mamis-feiner-schokopudding.jpg","https:\/\/cdn.gutekueche.de\/upload\/rezept\/4274\/1600x1200_mamis-feiner-schokopudding.jpg","https:\/\/cdn.gutekueche.de\/upload\/rezept\/4274\/1600x900_mamis-feiner-schokopudding.jpg"],"aggregateRating":{"@type":"AggregateRating","ratingValue":3,"reviewCount":5,"worstRating":1,"bestRating":5},"author":{"@type":"Organization","name":"GuteKueche.de"},"publisher":{"@type":"Organization","name":"GuteKueche.de","logo":[{"@type":"ImageObject","url":"https:\/\/cdn.gutekueche.de\/assets\/img\/logos\/600x60_gkdelogo.png","width":"600","height":"60"},{"@type":"ImageObject","url":"https:\/\/cdn.gutekueche.de\/assets\/img\/logos\/600x600_gkdelogo.png","width":"600","height":"600"}]},"datePublished":"2018-11-20","mainEntityOfPage":"https:\/\/www.gutekueche.dehttps:\/\/www.gutekueche.de\/mamis-feiner-schokopudding-rezept-4274","description":"Mamis feiner Schokopudding kommt bei Groß und Klein gut an. Das Rezept ergibt eine leckere Nachspeise.","prepTime":"PT10M","cookTime":"PT50M","totalTime":"PT60M","recipeYield":"4 Portionen","recipeCategory":["Dessert","schnelle Rezepte","Kalte Speisen","Pudding"],"recipeIngredient":["2 EL Kakaopulver","500 ml Milch","100 ml Schlagsahne","35 g Speisestärke","50 g Zucker"],"recipeInstructions":[{"@type":"HowToStep","text":"Für Mamis feinen Schokopudding zuerst die Schokolade mit einem scharfen Messer grob hacken."},{"@type":"HowToStep","text":"350 ml Milch in einen Topf geben, erwärmen und die gehackte Schokolade hinzufügen. Unter Rühren die Schokolade zum Schmelzen bringen."},{"@type":"HowToStep","text":"Die restliche Milch mit der Stärke, dem Zucker und dem Kakao mit einem Schneebesen verrühren, bis keine Klümpchen mehr vorhanden sind."},{"@type":"HowToStep","text":"Die Schokoladenmilch unter ständigem Rühren aufkochen, die Kakao-Stärke-Milch einrühren, alles erneut aufkochen und etwa 1 Minute kochen lassen."},{"@type":"HowToStep","text":"Im Anschluss den heißen Pudding in 4 Schälchen füllen und zum Erkalten in den Kühlschrank stellen."}]}]</script>

View File

@@ -0,0 +1,8 @@
</div><div itemscope="" itemtype="https://schema.org/Recipe"><div class="snippet-image"><img width="180" itemprop="image" src="https://www.inspirationforall.de/wp-content/uploads/2019/12/pudding-selber-machen-vanillepudding-schokopudding-rezept-1550x1033-1.jpg" alt="recipe image"><meta itemprop="image" content="https://www.inspirationforall.de/wp-content/uploads/2019/12/pudding-selber-machen-vanillepudding-schokopudding-rezept-1550x1033-1.jpg"></div><div class="aio-info"><div class="snippet-label-img">Rezept</div><div class="snippet-data-img"><span itemprop="name">Pudding selber machen: Rezepte für Vanillepudding &amp; Schokopudding</span></div>
<meta itemprop="description" content="Wie du Pudding selber machen kannst, erfährst du hier. Zwei leckere und einfache Rezepte für Vanillepudding und Schokopudding zeige ich dir.">
<meta itemprop="recipeIngredient" content="1 Vanilleschote, 50 g Zucker, Eigelb von 1 Ei, 30 g Speisestärke, 500 ml Milch">
<div itemprop="nutrition" itemscope="" itemtype="https://schema.org/NutritionInformation">
<meta itemprop="calories" content=""></div>
<div class="snippet-clear"></div><div class="snippet-label-img">Autor</div><div class="snippet-data-img"><span itemprop="author">Inspiration for All</span></div><div class="snippet-clear"></div><div class="snippet-label-img">veröffentlicht am </div><div class="snippet-data-img"><time datetime="2019-12-28T19:04:06+00:00" itemprop="datePublished">2019-12-28</time></div><div class="snippet-clear"></div><div class="snippet-label-img">Bewertung</div> <div class="snippet-data-img"> <span itemprop="aggregateRating" itemscope="" itemtype="https://schema.org/AggregateRating"><span itemprop="ratingValue" class="rating-value">5</span><span class="star-img"><img src="https://www.inspirationforall.de/wp-content/plugins/all-in-one-schemaorg-rich-snippets/images/1star.png" alt="1star"><img src="https://www.inspirationforall.de/wp-content/plugins/all-in-one-schemaorg-rich-snippets/images/1star.png" alt="1star"><img src="https://www.inspirationforall.de/wp-content/plugins/all-in-one-schemaorg-rich-snippets/images/1star.png" alt="1star"><img src="https://www.inspirationforall.de/wp-content/plugins/all-in-one-schemaorg-rich-snippets/images/1star.png" alt="1star"><img src="https://www.inspirationforall.de/wp-content/plugins/all-in-one-schemaorg-rich-snippets/images/1star.png" alt="1star"></span> Based on <span itemprop="reviewCount"><strong>12</strong> </span> Review(s)</span></div><div class="snippet-clear"></div></div>
</div></div><div class="snippet-clear"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,221 @@
<!-- BEGIN M102_Breadcrumbs -->
<div class="row show-for-large hide-for-print">
<div class="small-12 columns spacing-breadcrumbs">
<nav aria-label="You are here:" role="navigation">
<ul class="breadcrumbs">
<li itemscope itemtype="http://data-vocabulary.org/Breadcrumb">
<a href="/home" itemprop="url"><span itemprop="title">Home</span></a>
</li>
<li itemscope itemtype="http://data-vocabulary.org/Breadcrumb">
<a href="/rezepte" itemprop="url"><span itemprop="title">Rezepte</span></a>
</li>
<li class="bread-item current" itemscope itemtype="http://data-vocabulary.org/Breadcrumb">
<a href="/rezepte/schokopudding/13534" itemprop="url"><span itemprop="title">Schokopudding</span></a>
</li>
</ul>
</nav>
</div>
</div>
<!-- END M102_Breadcrumbs -->
<main role="main" id="main">
<!-- BEGIN M308_RecipeDetails -->
<article class="m-recipe spacing-module" itemscope itemtype="http://schema.org/Recipe" data-component="recipe-details">
<div class="hide" aria-hidden="true">
<img itemprop="image" src="/images/recipes/13534_594x445.jpg" alt="Schokopudding-Rezept">
<div itemprop="description">Rezept f&#252;r Schokopudding. Jetzt nachkochen/ nachbacken oder von weiteren k&#246;stlichen Rezepten von und mit Maizena inspirieren lassen!</div>
</div>
<div class="row align-middle">
<div class="small-12 medium-8 columns">
<header class="recipe-header">
<h1 class="recipe-title" itemprop="name">Schokopudding</h1>
</header>
</div>
<div class="small-12 medium-4 columns medium-text-right">
<!-- BEGIN M104_SocialShare -->
<aside class="m-share hide-for-print" data-component="share" data-share-sticky-on="large" data-share-track="true">
<div class="shariff" data-orientation="horizontal" data-services=["facebook","twitter","pinterest","whatsapp"]></div>
</aside>
<!-- END M104_SocialShare -->
</div>
<div class="columns"><hr class="spacing-minimal spacing-none-medium"></div>
</div>
<div class="row spacing-minimal">
<div class="small-12 medium-7 medium-order-2 medium-push-5 print-6 columns">
<figure class="medium-text-right recipe-illu ">
<!-- BEGIN M105_Images -->
<picture data-component="lazypic" data-lazypic-bind-to=".slick-slider">
<!--[if IE 9]><video style="display: none;"><![endif]-->
<source media="(min-width: 60em)" srcset="" data-srcset="/images/recipes/13534_594x445.jpg" />
<source media="(min-width: 30em)" srcset="" data-srcset="/images/recipes/13534_465x348.jpg" />
<source media="(max-width: 30em)" srcset="" data-srcset="/images/recipes/13534_220x124.jpg" />
<!--[if IE 9]></video><![endif]-->
<img src="/images/recipes/13534_594x445.jpg" alt="Schokopudding-Rezept">
</picture>
<!-- END M105_Images -->
</figure>
</div>
<div class="small-12 medium-5 medium-order-1 medium-pull-7 print-6 columns spacing-minimal spacing-none-medium">
<!-- BEGIN M301_Rating -->
<div class="m-rating" data-component="rating" data-rating-count="195">
<div class="rating-stars is-rateable">
<div itemprop="aggregateRating" itemscope itemtype="http://schema.org/AggregateRating">
<meta content="3" itemprop="ratingValue" />
<meta content="195" itemprop="ratingCount" />
</div>
<span class="rating-star active">
<a class="rating-star-action print-no-url" href="/rezepte/rate/13534?rating=1&rateGuid=f328d692-3eba-43f8-9a7c-c99bd1e4b387">
<span class="icon icon-star-full"></span>
</a>
</span>
<span class="rating-star active">
<a class="rating-star-action print-no-url" href="/rezepte/rate/13534?rating=2&rateGuid=f328d692-3eba-43f8-9a7c-c99bd1e4b387">
<span class="icon icon-star-full"></span>
</a>
</span>
<span class="rating-star active">
<a class="rating-star-action print-no-url" href="/rezepte/rate/13534?rating=3&rateGuid=f328d692-3eba-43f8-9a7c-c99bd1e4b387">
<span class="icon icon-star-full"></span>
</a>
</span>
<span class="rating-star ">
<a class="rating-star-action print-no-url" href="/rezepte/rate/13534?rating=4&rateGuid=f328d692-3eba-43f8-9a7c-c99bd1e4b387">
<span class="icon icon-star-full"></span>
</a>
</span>
<span class="rating-star ">
<a class="rating-star-action print-no-url" href="/rezepte/rate/13534?rating=5&rateGuid=f328d692-3eba-43f8-9a7c-c99bd1e4b387">
<span class="icon icon-star-full"></span>
</a>
</span>
</div>
<small class="rating-ratings-none hide">Es gibt noch keine Bewertungen</small>
<small class="rating-ratings-found ">
<span class="rating-count">195</span>
<span class="rating-label" data-singular="Bewertung" data-plural="Bewertungen">Bewertungen</span>
</small>
</div>
<!-- END M301_Rating -->
<!-- BEGIN M305_SkillLevelAndCookingtime -->
<div class="row spacing-module">
<div class="m-recipe-props">
<div class="columns">
<div class="recipe-props-skill skill-level-0">
<strong>Schwierigkeitsgrad:</strong>
<span>einfach</span>
</div>
</div>
</div>
</div>
<!-- END M305_SkillLevelAndCookingtime -->
<!-- BEGIN M302_Actions -->
<div class="m-recipe-actions hide-for-print">
<div class="recipe-actions-item spacing-minimal" data-component="mark-recipe" data-mark-recipe-id="13534" data-mark-recipe-is-favorite="false">
<a class="tracking expanded button recipe-actions-button spacing-none favorite-button " href="/rezepte/addfavoriterecipe/13534" data-type="Custom" data-event="SaveRecipe" data-option="Rezept gemerkt">
<i class="icon icon-heart-outline"></i> Merken
</a>
<a class="tracking expanded button recipe-actions-button spacing-none unfavorite-button hide" href="/rezepte/removefavoriterecipe/13534" data-type="Custom" data-event="UnsaveRecipe" data-option="Rezept entmerkt">
<i class="icon icon-heart"></i> Gemerkt
</a>
</div>
<div class="recipe-actions-item spacing-minimal">
<a class="tracking expanded button recipe-actions-button spacing-none" href="javascript:window.print()" data-type="Other" data-event="Print" data-option="Rezept gedruckt">
<i class="icon icon-print"></i> Drucken
</a>
</div>
</div>
<!-- END M302_Actions -->
</div>
</div>
<div class="row spacing-minimal">
<div class="m-recipe-ingredients small-12 medium-4 print-6 columns">
<h2 class="recipe-ingredients-title recipe-detail-title">Zutaten</h2>
<h3 class="recipe-detail-subtitle">f&#252;r 4 Port.</h3>
<ul class="recipe-ingredients">
<li itemprop="ingredients"><a href="/produkte/maizena-speisestaerke/7611100053891">40 g Maizena Maisstärke</a></li>
<li itemprop="ingredients">3 EL Kakao</li>
<li itemprop="ingredients">500 ml Milch 1,5% Fett</li>
<li itemprop="ingredients">4 EL Kristallzucker</li>
</ul>
<div class="recipe-product text-center">
<a href="/produkte/maizena-speisestaerke/7611100053891">
<!-- BEGIN M105_Images -->
<picture data-component="lazypic" data-lazypic-bind-to=".slick-slider">
<!--[if IE 9]><video style="display: none;"><![endif]-->
<source media="(min-width: 60em)" srcset="" data-srcset="/images/UnileverProducts/de-AT/7611100053891_330451.png" />
<source media="(min-width: 30em)" srcset="" data-srcset="/images/UnileverProducts/de-AT/7611100053891_330451.png" />
<source media="(max-width: 30em)" srcset="" data-srcset="/images/UnileverProducts/de-AT/7611100053891_330426.png" />
<!--[if IE 9]></video><![endif]-->
<img src="/images/UnileverProducts/de-AT/7611100053891_330451.png" alt="MAIZENA Speisestärke">
</picture>
<!-- END M105_Images -->
</a>
</div>
</div>
<div class="small-12 medium-8 print-6 columns">
<h2 class="recipe-instructions-title recipe-detail-title">Zubereitung</h2>
<p itemprop="recipeInstructions">1. MAIZENA und Kakao mit ca. 1/3 der kalten Milch glatt rühren. Die restliche Milch mit Zucker aufkochen, ca. 1 Minute kochen lassen.<br /><br />2. Die Puddingmasse in die mit kaltem Wasser ausgespülten Formen füllen und nach dem Auskühlen im Kühlschrank ca. 2 Stunden kalt stellen.</p>
<h2 class="recipe-tip-title">Tipp zu diesem Rezept</h2>
<p itemprop="recipeInstructions">1 EL Rosinen in 1-2 TL Rum (40%) einweichen und gleich nach dem Kochen unter die Puddingmasse geben.</p>
<section class="m-cooking-times">
<h3 class="recipe-cooking-times-title">Zubereitungszeiten des Rezepts Schokopudding:</h3>
<dl class="recipe-cooking-times">
</dl>
</section>
</div>
</div>
<div class="row hide-for-print">
<div class="small-12 columns">
<aside class="m-added-info spacing-module" id="added-info" data-toggler=".added-info-is-open">
<hr class="spacing-none">
<header class="added-info-header spacing-vertical">
<h2 class="added-info-title">
<a data-toggle="added-info nutrition-layer">N&#228;hrwerte</a>
</h2>
<p class="added-info-subline">Hier finden Sie die N&#228;hrwerte des Rezepts <strong>Schokopudding.</strong></p>
</header>
<div class="added-info-content foldable is-folded" id="nutrition-layer" data-toggler=".is-folded">
<!-- BEGIN M300_NutritionTable -->
<div class="responsive-table responsive" data-component="responsive-table">
<table class="m-nutrition" itemprop="nutrition" itemscope itemtype="http://schema.org/NutritionInformation" cellspacing="0">
<thead>
<tr>
<td width="40%">&#160;</td>
<th>pro Portion</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Energie </th>
<td>
716 kJ /
<span itemprop="calories">171 kcal</span>
</td>
</tr>
<tr>

View File

@@ -0,0 +1,853 @@
itemscope
itemprop="itemListElement"
itemtype="http://schema.org/ListItem">
<a href="https://www.oetker.de/"
class="m017-breadcrumb-link"
title=""
itemprop="item">
<span itemprop="name">Home</span>
<meta itemprop="position" content="1">
</a>
</li>
<li class="m017-breadcrumb-item">
<span>
Unsere Rezepte
</span>
</li>
</ul>
</div>
<div class="r01-default g003-content">
<div class="m031-background m031-background--stage js-m031-background" style="" data-type="stage" data-fill-assets="[]" data-top-assets="['2593580']">
<div class="m031-background m031-background--repeated js-m031-background"
data-type="repeated"
style="background-image: url('/dr-oetker-cms/oetker.de/book%20selfe%20pdf/background-img/image-thumb__66838__BackgroundImage/theme-hintergrund-kachel-dessert.jpg');"
>
<div class="m031-background-body">
</div>
</div>
</div>
<div class="r01-default-container ">
<input type="hidden" class="g007-recipe-image-display-smartbanner" value="false">
<div class="g007-recipe g007-recipe--hasimage js-bootstrap"
data-bootstrap="base/module-groups/g007-recipe/js/g007-recipe::initialize" itemscope=""
itemtype="http://schema.org/Recipe">
<span itemprop="author" content="Dr.&nbsp;Oetker"></span>
<meta itemprop="datePublished" content="1999-08-31">
<div class="r01-default-row r01-default-row--1">
<div class="r01-default-column r01-default-column--1">
<div class="m052-navipreviousnext m052-navipreviousnext--default clearfix">
<div class="m052-navipreviousnext-left">
<a class="m052-navipreviousnext-previous" href="/rezepte/r/schokomousse-mit-zimtsahne" title="Schokomousse mit Zimtsahne">
<span class="m052-navipreviousnext-icon m052-navipreviousnext-icon--previous"></span>
<span class="m052-navipreviousnext-text m052-navipreviousnext-text--previous">
Schokomousse mit Zimtsahne
</span>
</a>
</div>
<div class="m052-navipreviousnext-right">
<a class="m052-navipreviousnext-next" href="/rezepte/r/schokotraum" title="Schokotraum">
<span class="m052-navipreviousnext-text m052-navipreviousnext-text--next">
Schokotraum
</span>
<span class="m052-navipreviousnext-icon m052-navipreviousnext-icon--next"></span>
</a>
</div>
<div class="m052-navipreviousnext-center">
<div class="e005-button-outer ">
<a class="e005-button e005-button--icon has-size-l has-iconPosition-left m052-navipreviousnext-overview js-e005-button"
href="/rezeptsuche?_title=schokopudding-mit-vanille-herzen"
title="">
<span class="e005-button-icon is-secondary icon-menu" data-text=""></span>
<span class="e005-button-text">Zur Rezeptübersicht</span>
</a>
</div>
</div>
</div>
</div>
</div>
<div class="r01-default-row r01-default-row--1">
<div class="r01-default-column r01-default-column--1">
<div class="m013-intro m013-intro--left is-content-container g007-recipe-intro">
<div class="m013-intro-content">
<h1 class="m013-intro-headline" itemprop="name" >Schokopudding mit Vanille-Herzen</h1>
</div>
</div>
</div>
</div>
<div class="r01-default-row r01-default-row--2-1 g007-recipe-header">
<div class="r01-default-column r01-default-column--1">
<div class="g007-recipe-image ">
<a href="javascript:void(0);"
class="e001-link e001-link--neutral"
data-action="m035-lightbox"
data-target="g007-recipe-image-1"
data-mode="picture">
<picture class="e002-image g007-recipe-image-picture js-e002-image e002-image--enlargable">
<source
media="(max-width: 480px)"
data-srcset="/Recipe/Recipes/oetker.de/de-de/dessert/image-thumb__31848__RecipeDetail/schokopudding-mit-vanille-herzen~-~480w.jpg"
srcset="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=
"
>
<source
media="(max-width: 768px)"
data-srcset="/Recipe/Recipes/oetker.de/de-de/dessert/image-thumb__31848__RecipeDetail/schokopudding-mit-vanille-herzen~-~768w.jpg"
srcset="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=
"
>
<img
class="g007-recipe-image-image should-lazyload"
alt="schokopudding mit vanille herzen"
data-src="/Recipe/Recipes/oetker.de/de-de/dessert/image-thumb__31848__RecipeDetail/schokopudding-mit-vanille-herzen.jpg"
src="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=
"
itemprop="image"
>
</picture>
<noscript>
<picture class="e002-image g007-recipe-image-picture js-e002-image e002-image--enlargable">
<img
class="g007-recipe-image-image"
alt="schokopudding mit vanille herzen"
src="/Recipe/Recipes/oetker.de/de-de/dessert/image-thumb__31848__RecipeDetail/schokopudding-mit-vanille-herzen.jpg"
itemprop="image"
>
</picture>
</noscript>
</a>
<span id="g007-recipe-image-1"
class="is-hidden"
data-bootstrap="base/modules/m035-lightbox/js/m035-lightbox::initialize">
<picture class="e002-image g007-recipe-image--full-size-picture js-e002-image">
<source
media="(max-width: 480px)"
data-srcset="/Recipe/Recipes/oetker.de/de-de/dessert/image-thumb__31848__RecipeDetailsLightBox/schokopudding-mit-vanille-herzen~-~480w.jpg"
srcset="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=
"
>
<source
media="(max-width: 768px)"
data-srcset="/Recipe/Recipes/oetker.de/de-de/dessert/image-thumb__31848__RecipeDetailsLightBox/schokopudding-mit-vanille-herzen~-~768w.jpg"
srcset="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=
"
>
<img
class="g007-recipe-image--full-size-image should-lazyload"
alt="schokopudding mit vanille herzen"
data-src="/Recipe/Recipes/oetker.de/de-de/dessert/image-thumb__31848__RecipeDetailsLightBox/schokopudding-mit-vanille-herzen.jpg"
src="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=
"
>
</picture>
<noscript>
<picture class="e002-image g007-recipe-image--full-size-picture js-e002-image">
<img
class="g007-recipe-image--full-size-image"
alt="schokopudding mit vanille herzen"
src="/Recipe/Recipes/oetker.de/de-de/dessert/image-thumb__31848__RecipeDetailsLightBox/schokopudding-mit-vanille-herzen.jpg"
>
</picture>
</noscript>
</span>
<a class="g007-recipe-pin-it-button">
<div class="m014-share js-bootstrap js-m014-share"
data-bootstrap="base/modules/m014-share/js/m014-share::initialize"
data-tracking-event="click"
data-tracking-type="custom"
data-tracking-config="%7B%22Target%22%3A%5B%5B%22Earned%20Media%22%2C%22__network__%20ContentShare%22%2C%221%22%2C%22a%22%5D%5D%7D"
data-tracking-manual="true"
>
<div class="m014-share-shariff shariff "
data-lang="de"
data-services="[ &quot;pinterest&quot; ]"
data-tracking-share="[]"
>
</div>
</div>
</a>
</div>
</div>
<div class="r01-default-column r01-default-column--2 has-no-gap">
<div class="m051-starrating js-m051-starrating js-bootstrap"
data-bootstrap="base/modules/m051-starrating/js/m051-starrating1::initialize"
data-getrating-url="/de-de/recipe-rating/?eID=oetGetRecipeRating&amp;uid=bb94e27c5a5346d1c1257356004184a3"
data-recipe-uid="bb94e27c5a5346d1c1257356004184a3"
data-rate-url="/de-de/recipe-rating/?eID=oetRateRecipe&amp;uid=bb94e27c5a5346d1c1257356004184a3&amp;rating="
data-rate-feedback="Jetzt bewerten!"
data-rated-feedback="Danke für die Bewertung!">
<div class="m051-starrating-indicator js-m051-starrating-indicator">
<ol class="e011-rating">
<li class="e011-rating-item">
<div class="e011-rating-icon"></div>
</li>
<li class="e011-rating-item">
<div class="e011-rating-icon"></div>
</li>
<li class="e011-rating-item">
<div class="e011-rating-icon "></div>
</li>
<li class="e011-rating-item">
<div class="e011-rating-icon "></div>
</li>
<li class="e011-rating-item">
<div class="e011-rating-icon "></div>
</li>
</ol>
<div class="e011-rating-label">
(0)
</div>
</div>
<div itemprop="aggregateRating" itemscope="" itemtype="http://schema.org/AggregateRating" class="m051-starrating-google-tags">
<span style="display: none" itemprop="ratingValue">5</span>
<span style="display: none" itemprop="ratingCount">1</span>
<span style="display: none" itemprop="bestRating">5</span>
</div>
<div class="m051-starrating-feedback js-m051-starrating-feedback">
<a href="javascript:void(0);" class="e001-link e001-link--neutral m051-starrating-trigger js-m051-starrating-trigger" title="">
<span class="e001-link-text">Jetzt bewerten!</span>
</a>
<span class="m051-starrating-user-feedback js-m051-starrating-user-feedback">Danke für die Bewertung!</span>
</div>
<div class="m051-starrating-container js-m051-starrating-container">
<ol class="m051-starrating-list">
<li class="m051-starrating-value js-m051-starrating-value" data-rate-value="1">
<ol class="e011-rating">
<li class="e011-rating-item">
<div class="e011-rating-icon e011-rating-icon--active "></div>
</li>
<li class="e011-rating-item">
<div class="e011-rating-icon "></div>
</li>
<li class="e011-rating-item">
<div class="e011-rating-icon "></div>
</li>
<li class="e011-rating-item">
<div class="e011-rating-icon "></div>
</li>
<li class="e011-rating-item">
<div class="e011-rating-icon "></div>
</li>
</ol>
<div class="e011-rating-label">
(Nicht mein Fall)
</div>
</li>
<li class="m051-starrating-value js-m051-starrating-value" data-rate-value="2">
<ol class="e011-rating">
<li class="e011-rating-item">
<div class="e011-rating-icon e011-rating-icon--active "></div>
</li>
<li class="e011-rating-item">
<div class="e011-rating-icon e011-rating-icon--active "></div>
</li>
<li class="e011-rating-item">
<div class="e011-rating-icon "></div>
</li>
<li class="e011-rating-item">
<div class="e011-rating-icon "></div>
</li>
<li class="e011-rating-item">
<div class="e011-rating-icon "></div>
</li>
</ol>
<div class="e011-rating-label">
(Könnte besser sein)
</div>
</li>
<li class="m051-starrating-value js-m051-starrating-value" data-rate-value="3">
<ol class="e011-rating">
<li class="e011-rating-item">
<div class="e011-rating-icon e011-rating-icon--active "></div>
</li>
<li class="e011-rating-item">
<div class="e011-rating-icon e011-rating-icon--active "></div>
</li>
<li class="e011-rating-item">
<div class="e011-rating-icon e011-rating-icon--active "></div>
</li>
<li class="e011-rating-item">
<div class="e011-rating-icon "></div>
</li>
<li class="e011-rating-item">
<div class="e011-rating-icon "></div>
</li>
</ol>
<div class="e011-rating-label">
(Okay)
</div>
</li>
<li class="m051-starrating-value js-m051-starrating-value" data-rate-value="4">
<ol class="e011-rating">
<li class="e011-rating-item">
<div class="e011-rating-icon e011-rating-icon--active "></div>
</li>
<li class="e011-rating-item">
<div class="e011-rating-icon e011-rating-icon--active "></div>
</li>
<li class="e011-rating-item">
<div class="e011-rating-icon e011-rating-icon--active "></div>
</li>
<li class="e011-rating-item">
<div class="e011-rating-icon e011-rating-icon--active "></div>
</li>
<li class="e011-rating-item">
<div class="e011-rating-icon "></div>
</li>
</ol>
<div class="e011-rating-label">
(Lecker)
</div>
</li>
<li class="m051-starrating-value js-m051-starrating-value" data-rate-value="5">
<ol class="e011-rating">
<li class="e011-rating-item">
<div class="e011-rating-icon e011-rating-icon--active "></div>
</li>
<li class="e011-rating-item">
<div class="e011-rating-icon e011-rating-icon--active "></div>
</li>
<li class="e011-rating-item">
<div class="e011-rating-icon e011-rating-icon--active "></div>
</li>
<li class="e011-rating-item">
<div class="e011-rating-icon e011-rating-icon--active "></div>
</li>
<li class="e011-rating-item">
<div class="e011-rating-icon e011-rating-icon--active "></div>
</li>
</ol>
<div class="e011-rating-label">
(Perfekt)
</div>
</li>
</ol>
</div>
</div>
<div class="g007-recipe-meta recipe-meta_new">
<div class="m065-recipemeta">
<div class="m065-recipemeta-text" itemprop="description">
<p>Ein festliches Dessert mit Erdbeeren</p>
</div>
<p class="m065-recipemeta-servings" itemprop="recipeYield">etwa 6 Portionen</p>
<meta itemprop="totalTime" content="PT40M">
<div class="m065-recipemeta-attributes">
<span class="e007-recipeicons-group">
<span class="e007-recipeicons e007-recipeicons--difficulty e007-recipeicons--extended">
<span class="e007-recipeicons-icon ">
</span>
<span class="e007-recipeicons-icon ">
</span>
<span class="e007-recipeicons-icon is-disabled">
</span>
<span class="e007-recipeicons-label">etwas Übung erforderlich</span>
</span>
<span class="e007-recipeicons e007-recipeicons--preparationtime e007-recipeicons--extended"
title="40">
<span class="e007-recipeicons-icon is-40"></span>
<span class="e007-recipeicons-label">
40 Minuten
</span>
</span>
</span>
</div>
</div>
<div class="m030-bookmark m030-bookmark--recipe g007-recipe-bookmark g007-recipe-bookmark--hidden-sm sidebar_custome js-bootstrap"
data-bootstrap="base/modules/m030-bookmark/js/m030-bookmark::initialize" >
<div class="m030-bookmark-link">
<div class="e005-button-outer ">
<a class="e005-button e005-button--icon has-size-xl has-iconPosition-left js-e005-button"
href="/de-de/recipe/recipe-pdf/oetker.de/schokopudding-mit-vanille-herzen.pdf"
data-track-event="printRecipe"
data-track-category="recipe"
data-track-action="click"
data-track-label="print recipe">
<span class="e005-button-icon is-secondary icon-text" data-text="PDF"></span>
<span class="e005-button-text">Drucken</span>
</a>
</div>
<div class="e005-button-outer">
<a class="e005-button e005-button--icon has-size-xl has-iconPosition-left js-m030-bookmark-notepad js-e005-button"
href="javascript:void(0);"
data-notepad="{
'guid':'recipe_158537',
'title':'Schokopudding mit Vanille-Herzen',
'ingredients': [{&quot;AmountAndUnitSummaryText&quot;:&quot;1 Pck.&quot;,&quot;ArticleSummaryText&quot;:&quot;Dr. Oetker Gala Feines Schokoladen-Puddingpulver&quot;},{&quot;AmountAndUnitSummaryText&quot;:&quot;50 g&quot;,&quot;ArticleSummaryText&quot;:&quot;Zucker&quot;},{&quot;AmountAndUnitSummaryText&quot;:&quot;500 ml&quot;,&quot;ArticleSummaryText&quot;:&quot;Milch&quot;},{&quot;AmountAndUnitSummaryText&quot;:&quot;1 Pck.&quot;,&quot;ArticleSummaryText&quot;:&quot;Dr. Oetker Gala Puddingpulver Bourbon-Vanille&quot;},{&quot;AmountAndUnitSummaryText&quot;:&quot;30 g&quot;,&quot;ArticleSummaryText&quot;:&quot;Zucker&quot;},{&quot;AmountAndUnitSummaryText&quot;:&quot;375 ml&quot;,&quot;ArticleSummaryText&quot;:&quot;Milch&quot;},{&quot;AmountAndUnitSummaryText&quot;:&quot;einige&quot;,&quot;ArticleSummaryText&quot;:&quot;Erdbeeren zum Garnieren&quot;},{&quot;AmountAndUnitSummaryText&quot;:null,&quot;ArticleSummaryText&quot;:&quot;Herzausstecher&quot;}],
'image':'//www.oetker.de/Recipe/Recipes/oetker.de/de-de/dessert/image-thumb__31848__RecipeList/schokopudding-mit-vanille-herzen.jpg',
'link':'https://www.oetker.de/rezepte/r/schokopudding-mit-vanille-herzen'
}"
data-track-event="addToNotepad"
data-track-category="recipe"
data-track-action="click"
data-track-label="add to notepad">
<span class="e005-button-icon is-secondary icon-plus" data-text=""></span>
<span class="e005-button-text">Merken</span>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="r01-default-row r01-default-row--2-1">
<div class="r01-default-column r01-default-column--1 ">
<div class="m073-contentbox is-content-box is-content-container ">
<div class="m014-share js-highlight js-bootstrap js-m014-share"
data-bootstrap="base/modules/m014-share/js/m014-share::initialize"
data-tracking-event="click"
data-tracking-type="custom"
data-tracking-config="%7B%22Target%22%3A%5B%5B%22Earned%20Media%22%2C%22__network__%20ContentShare%22%2C%221%22%2C%22a%22%5D%5D%7D"
data-tracking-manual="true"
>
<div class="m014-share-shariff shariff"
data-lang="de"
data-services="[ &quot;facebook&quot;,&quot;twitter&quot;,&quot;pinterest&quot;,&quot;whatsapp&quot; ]"
data-tracking-share="[{&quot;attribute&quot;:&quot;data-track-event&quot;,&quot;value&quot;:&quot;socialMediaShare&quot;},{&quot;attribute&quot;:&quot;data-track-category&quot;,&quot;value&quot;:&quot;recipe&quot;},{&quot;attribute&quot;:&quot;data-track-label&quot;,&quot;value&quot;:&quot;schokopudding mit vanille herzen&quot;}]"
>
</div>
</div>
<!--googleon: all-->
<div class="m022-accordion is-closed is-mobile-open m022-accordion--mobile-only js-m022-accordion js-bootstrap"
data-bootstrap="base/modules/m022-accordion/js/m022-accordion::initialize">
<div class="m022-accordion-head js-m022-accordion-head" >
<h2 class="m022-accordion-title">Zutaten</h2>
<span class="m022-accordion-icon"></span>
</div>
<div class="m022-accordion-content clearfix">
<div class="m053-ingredients"
data-track-event="ingredientsView"
data-track-category="recipe"
data-track-action="view"
data-track-label="ingredients">
<h2 class="m053-ingredients-headline">Zutaten</h2>
<p>für das Rezept&nbsp;Schokopudding mit Vanille-Herzen</p>
<h3 class="m053-ingredients-subline">Gala Schokoladen-Pudding:</h3>
<table class="m053-ingredients-table">
<tbody>
<tr itemprop="ingredients">
<td class="m053-ingredients-table-value">
1 Pck.
</td>
<td class="m053-ingredients-table-name">
<a href="/unsere-produkte/gala/gala-feiner-schokoladen-pudding"
data-track-event="recipeIngredients"
data-track-category="recipe.ingredients"
data-track-action="click"
data-track-label="productKey; input.pdp">
Dr. Oetker Gala Feines Schokoladen-Puddingpulver
</a>
</td>
</tr>
<tr itemprop="ingredients">
<td class="m053-ingredients-table-value">
50 g
</td>
<td class="m053-ingredients-table-name">
Zucker
</td>
</tr>
<tr itemprop="ingredients">
<td class="m053-ingredients-table-value">
500 ml
</td>
<td class="m053-ingredients-table-name">
Milch
</td>
</tr>
</tbody>
</table>
<h3 class="m053-ingredients-subline">Gala Bourbon-Vanille-Pudding:</h3>
<table class="m053-ingredients-table">
<tbody>
<tr itemprop="ingredients">
<td class="m053-ingredients-table-value">
1 Pck.
</td>
<td class="m053-ingredients-table-name">
<a href="/unsere-produkte/gala/gala-bourbon-vanille"
data-track-event="recipeIngredients"
data-track-category="recipe.ingredients"
data-track-action="click"
data-track-label="productKey; input.pdp">
Dr. Oetker Gala Puddingpulver Bourbon-Vanille
</a>
</td>
</tr>
<tr itemprop="ingredients">
<td class="m053-ingredients-table-value">
30 g
</td>
<td class="m053-ingredients-table-name">
Zucker
</td>
</tr>
<tr itemprop="ingredients">
<td class="m053-ingredients-table-value">
375 ml
</td>
<td class="m053-ingredients-table-name">
Milch
</td>
</tr>
</tbody>
</table>
<h3 class="m053-ingredients-subline">Außerdem:</h3>
<table class="m053-ingredients-table">
<tbody>
<tr itemprop="ingredients">
<td class="m053-ingredients-table-value">
einige
</td>
<td class="m053-ingredients-table-name">
Erdbeeren zum Garnieren
</td>
</tr>
<tr itemprop="ingredients">
<td class="m053-ingredients-table-value">
</td>
<td class="m053-ingredients-table-name">
Herzausstecher
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="m022-accordion is-closed is-mobile-open m022-accordion--mobile-only js-m022-accordion js-bootstrap"
data-bootstrap="base/modules/m022-accordion/js/m022-accordion::initialize">
<div class="m022-accordion-head js-m022-accordion-head" >
<h2 class="m022-accordion-title">Zubereitung</h2>
<span class="m022-accordion-icon"></span>
</div>
<div class="m022-accordion-content clearfix">
<div class="m056-preparation"
itemprop="recipeInstructions"
data-track-event="preparationView"
data-track-category="recipe"
data-track-action="view"
data-track-label="preparation">
<h2 class="m056-preparation-headline">Zubereitung</h2>
<div class="m056-preparation-step">
<span class="m056-preparation-step-icon">1</span>
<h4 class="m056-preparation-stepHeadline">Gala Schokoladen-Pudding </h4>
<div class="m056-preparation-copy ">
<div><p>Puddingpulver mit Zucker mischen und nach und nach mit mind. 6 EL der Milch glatt rühren. Übrige Milch in einem Topf zum Kochen bringen. Topf vom Herd nehmen, angerührtes Puddingpulver zufügen und unter Rühren mind. 1 Min. kochen lassen. Pudding in kleine, kalt ausgespülte Sturzförmchen füllen und mind. 4 Std. in den Kühlschrank stellen.</p></div>
</div>
</div>
<div class="m056-preparation-step">
<span class="m056-preparation-step-icon">2</span>
<h4 class="m056-preparation-stepHeadline">Gala Bourbon-Vanille-Pudding </h4>
<div class="m056-preparation-copy ">
<div><p>Puddingpulver mit Zucker mischen und nach und nach mit mind. 6 EL der Milch glatt rühren. Übrige Milch in einem Topf zum Kochen bringen. Topf vom Herd nehmen, angerührtes Puddingpulver zufügen und unter Rühren kurz aufkochen lassen. Pudding in eine kalt ausgespülte, flache Form füllen und mind. 4 Std. in den Kühlschrank stellen.</p></div>
</div>
</div>
<div class="m056-preparation-step">
<span class="m056-preparation-step-icon">3</span>
<div class="m056-preparation-copy has-no-title ">
<div><p>Inzwischen Erdbeeren waschen und putzen.</p></div>
</div>
</div>
<div class="m056-preparation-step">
<span class="m056-preparation-step-icon">4</span>
<div class="m056-preparation-copy has-no-title ">
<div><p>Schokoladenpudding vorsichtig am Rand lösen und aus den Förmchen auf eine Platte stürzen. </p></div>
</div>
</div>
<div class="m056-preparation-step">
<span class="m056-preparation-step-icon">5</span>
<div class="m056-preparation-copy has-no-title ">
<div><p>Vanillepudding auf ein mit Wasser befeuchtetes Schneidebrett stürzen und Herzen ausstechen oder ausschneiden.</p></div>
</div>
</div>
<div class="m056-preparation-step">
<span class="m056-preparation-step-icon">6</span>
<div class="m056-preparation-copy has-no-title ">
<div><p>Gestürzten Schokoladenpudding mit Vanille-Herzen und vorbereiteten Erdbeeren garniert servieren.</p></div>
</div>
</div>
</div>
</div>
</div>
<div class="m022-accordion is-closed is-mobile-open g007-recipe-nutritional js-m022-accordion js-bootstrap"
data-bootstrap="base/modules/m022-accordion/js/m022-accordion::initialize" >
<div class="m022-accordion-head js-m022-accordion-head" >
<h2 class="m022-accordion-title">
Brenn- und Nährwertangaben für das Rezept Schokopudding mit Vanille-Herzen
</h2>
<span class="m022-accordion-icon"></span>
</div>
<div class="m022-accordion-content clearfix"
data-track-event="nutritionalInfoView"
data-track-category="recipe"
data-track-action="view"
data-track-label="nutritional info">
<div class="m033-nutritiontable js-m033-nutritiontable js-bootstrap"
data-bootstrap="/base/modules/m033-nutritiontable/js/m033-nutritiontable.js::initialize">
<div class="e003-form-select m003-nutritiontable-select js-m003-nutritiontable-select js-e003-form-select js-bootstrap">
<div class="e003-form-select-value js-e003-form-select-value"></div>
<div class="e003-form-select-button"></div>
<select>
<option value="1">Pro Portion / Stück</option>
<option value="2">Pro 100 g / ml</option>
</select>
</div>
<table class="m033-nutritiontable-table"
itemprop="nutrition"
itemscope=""
itemtype="http://schema.org/NutritionInformation">
<thead class="js-m033-nutritiontable-header">
<tr>
<th class="m033-nutritiontable-header js-m033-nutritiontable-column"></th>
<th class="m033-nutritiontable-header js-m033-nutritiontable-column"
data-column="1">
Pro Portion / Stück
</th>
<th class="m033-nutritiontable-header js-m033-nutritiontable-column"
data-column="2"
itemprop="servingSize">
Pro 100 g / ml
</th>
</tr>
</thead>
<tbody>
<!-- Calories -->
<tr class="m033-nutritiontable-row is-even">
<td class="m033-nutritiontable-name">Energie</td>
<td class="m033-nutritiontable-value js-m033-nutritiontable-column" data-column="1">
<span class="amount">
833
</span>
<span class="unit">kJ</span>
<br />
<span class="amount" itemprop="calories">
199
</span>
<span class="unit">kcal</span>
</td>
<td class="m033-nutritiontable-value js-m033-nutritiontable-column" data-column="2">
<span class="amount">
452
</span>
<span class="unit">kJ</span>
<br />
<span class="amount">
108
</span>
<span class="unit">kcal</span>
</td>
</tr>
<!-- Fat -->
<tr class="m033-nutritiontable-row is-odd">
<td class="m033-nutritiontable-name">Fett</td>
<td class="m033-nutritiontable-value js-m033-nutritiontable-column" data-column="1">
<span class="amount">
5.59
</span>
<span class="unit">g</span>
</td>
<td class="m033-nutritiontable-value js-m033-nutritiontable-column" data-column="2">
<span class="amount" itemprop="fatContent">
3.04
</span>
<span class="unit">g</span>
</td>
</tr>
<!-- Carbohydrate -->
<tr class="m033-nutritiontable-row is-even">
<td class="m033-nutritiontable-name">Kohlenhydrate</td>
<td class="m033-nutritiontable-value js-m033-nutritiontable-column" data-column="1">
<span class="amount">
31.25
</span>
<span class="unit">g</span>
</td>
<td class="m033-nutritiontable-value js-m033-nutritiontable-column" data-column="2">
<span class="amount" itemprop="carbohydrateContent">
16.98
</span>
<span class="unit">g</span>
</td>
</tr>
<!-- Protein -->
<tr class="m033-nutritiontable-row is-odd">
<td class="m033-nutritiontable-name">Eiweiß</td>
<td class="m033-nutritiontable-value js-m033-nutritiontable-column" data-column="1">
<span class="amount">
5.49
</span>
<span class="unit">g</span>
</td>
<td class="m033-nutritiontable-value js-m033-nutritiontable-column" data-column="2">
<span class="amount" itemprop="proteinContent">
2.98
</span>
<span class="unit">g</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>

View File

@@ -11,6 +11,7 @@ class TestBase(TestCase):
guest_client_1 = None
guest_client_2 = None
superuser_client = None
anonymous_client = None
def create_login_user(self, name, group):
client = Client()
@@ -36,3 +37,9 @@ class TestBase(TestCase):
user = self.create_login_user('superuser_client', 'admin')
user.is_superuser = True
user.save()
def batch_requests(self, clients, url, method='get', payload={}, content_type=''):
for c in clients:
if method == 'get':
r = c[0].get(url)
self.assertEqual(r.status_code, c[1], msg=f'GET request failed for user {auth.get_user(c[0])} when testing url {url}')

View File

@@ -6,32 +6,53 @@ from cookbook.tests.views.test_views import TestViews
class TestViewsGeneral(TestViews):
def test_index(self):
r = self.user_client_1.get(reverse('index'))
self.assertEqual(r.status_code, 302)
# TODO add appropriate test
pass
r = self.anonymous_client.get(reverse('index'))
self.assertEqual(r.status_code, 302)
def test_search(self):
# TODO add appropriate test
pass
def test_view(self):
# TODO add appropriate test
pass
def test_books(self):
url = reverse('view_books')
r = self.user_client_1.get(url)
self.assertEqual(r.status_code, 200)
r = self.anonymous_client.get(url)
self.assertEqual(r.status_code, 302)
self.batch_requests([(self.anonymous_client, 302), (self.guest_client_1, 302), (self.user_client_1, 200), (self.admin_client_1, 200), (self.superuser_client, 200)], url)
def test_plan(self):
url = reverse('view_plan')
r = self.user_client_1.get(url)
self.assertEqual(r.status_code, 200)
self.batch_requests([(self.anonymous_client, 302), (self.guest_client_1, 302), (self.user_client_1, 200), (self.admin_client_1, 200), (self.superuser_client, 200)], url)
r = self.anonymous_client.get(url)
self.assertEqual(r.status_code, 302)
def test_plan_entry(self):
# TODO add appropriate test
pass
def test_shopping(self):
url = reverse('view_shopping')
r = self.user_client_1.get(url)
self.assertEqual(r.status_code, 200)
self.batch_requests([(self.anonymous_client, 302), (self.guest_client_1, 302), (self.user_client_1, 200), (self.admin_client_1, 200), (self.superuser_client, 200)], url)
r = self.anonymous_client.get(url)
self.assertEqual(r.status_code, 302)
def test_settings(self):
url = reverse('view_settings')
self.batch_requests([(self.anonymous_client, 302), (self.guest_client_1, 200), (self.user_client_1, 200), (self.admin_client_1, 200), (self.superuser_client, 200)], url)
def test_history(self):
url = reverse('view_history')
self.batch_requests([(self.anonymous_client, 302), (self.guest_client_1, 200), (self.user_client_1, 200), (self.admin_client_1, 200), (self.superuser_client, 200)], url)
def test_system(self):
url = reverse('view_system')
self.batch_requests([(self.anonymous_client, 302), (self.guest_client_1, 302), (self.user_client_1, 302), (self.admin_client_1, 200), (self.superuser_client, 200)], url)
def test_setup(self):
url = reverse('view_setup')
self.batch_requests([(self.anonymous_client, 302), (self.guest_client_1, 302), (self.user_client_1, 302), (self.admin_client_1, 302), (self.superuser_client, 302)], url)
def test_markdown_info(self):
url = reverse('docs_markdown')
self.batch_requests([(self.anonymous_client, 200), (self.guest_client_1, 200), (self.user_client_1, 200), (self.admin_client_1, 200), (self.superuser_client, 200)], url)
def test_api_info(self):
url = reverse('docs_api')
self.batch_requests([(self.anonymous_client, 302), (self.guest_client_1, 200), (self.user_client_1, 200), (self.admin_client_1, 200), (self.superuser_client, 200)], url)

View File

@@ -0,0 +1,44 @@
import uuid
from django.contrib import auth
from django.urls import reverse
from cookbook.helper.permission_helper import share_link_valid
from cookbook.models import Recipe, ShareLink
from cookbook.tests.views.test_views import TestViews
class TestViewsGeneral(TestViews):
def test_share(self):
internal_recipe = Recipe.objects.create(
name='Test',
internal=True,
created_by=auth.get_user(self.user_client_1)
)
url = reverse('view_recipe', kwargs={'pk': internal_recipe.pk})
r = self.user_client_1.get(url)
self.assertEqual(r.status_code, 200)
r = self.anonymous_client.get(url)
self.assertEqual(r.status_code, 302)
url = reverse('new_share_link', kwargs={'pk': internal_recipe.pk})
r = self.user_client_1.get(url)
self.assertEqual(r.status_code, 302)
share = ShareLink.objects.filter(recipe=internal_recipe).first()
self.assertIsNotNone(share)
self.assertTrue(share_link_valid(internal_recipe, share.uuid))
url = reverse('view_recipe', kwargs={'pk': internal_recipe.pk, 'share': share.uuid})
r = self.anonymous_client.get(url)
self.assertEqual(r.status_code, 200)
url = reverse('view_recipe', kwargs={'pk': (internal_recipe.pk + 1), 'share': share.uuid})
r = self.anonymous_client.get(url)
self.assertEqual(r.status_code, 404)
url = reverse('view_recipe', kwargs={'pk': internal_recipe.pk, 'share': uuid.uuid4()})
r = self.anonymous_client.get(url)
self.assertEqual(r.status_code, 302)

View File

@@ -1,11 +1,23 @@
from pydoc import locate
from django.urls import path
from django.urls import path, include
from rest_framework import routers
from rest_framework.schemas import get_schema_view
from .views import *
from cookbook.views import api, import_export
from cookbook.helper import dal
router = routers.DefaultRouter()
router.register(r'user-preference', api.UserPreferenceViewSet)
router.register(r'recipe', api.RecipeViewSet)
router.register(r'ingredient', api.IngredientViewSet)
router.register(r'recipe-ingredient', api.RecipeIngredientViewSet)
router.register(r'meal-plan', api.MealPlanViewSet)
router.register(r'meal-type', api.MealTypeViewSet)
router.register(r'view-log', api.ViewLogViewSet)
router.register(r'user-name', api.UserNameViewSet, basename='username')
urlpatterns = [
path('', views.index, name='index'),
path('setup/', views.setup, name='view_setup'),
@@ -17,13 +29,16 @@ urlpatterns = [
path('shopping/', views.shopping_list, name='view_shopping'),
path('settings/', views.user_settings, name='view_settings'),
path('history/', views.history, name='view_history'),
path('test/', views.test, name='view_test'),
path('import/', import_export.import_recipe, name='view_import'),
path('export/', import_export.export_recipe, name='view_export'),
path('view/recipe/<int:pk>', views.recipe_view, name='view_recipe'),
path('view/recipe/<int:pk>/<slug:share>', views.recipe_view, name='view_recipe'),
path('new/recipe_import/<int:import_id>/', new.create_new_external_recipe, name='new_recipe_import'),
path('new/recipe-import/<int:import_id>/', new.create_new_external_recipe, name='new_recipe_import'),
path('new/share-link/<int:pk>/', new.share_link, name='new_share_link'),
path('edit/recipe/<int:pk>/', edit.switch_recipe, name='edit_recipe'),
path('edit/recipe/internal/<int:pk>/', edit.internal_recipe_update, name='edit_internal_recipe'), # for internal use only
@@ -40,17 +55,30 @@ urlpatterns = [
path('data/batch/import', data.batch_import, name='data_batch_import'),
path('data/sync/wait', data.sync_wait, name='data_sync_wait'),
path('data/statistics', data.statistics, name='data_stats'),
path('data/import/url', data.import_url, name='data_import_url'),
path('api/get_external_file_link/<int:recipe_id>/', api.get_external_file_link, name='api_get_external_file_link'),
path('api/get_recipe_file/<int:recipe_id>/', api.get_recipe_file, name='api_get_recipe_file'),
path('api/sync_all/', api.sync_all, name='api_sync'),
path('api/log_cooking/<int:recipe_id>/', api.log_cooking, name='api_log_cooking'),
path('api/plan-ical/<slug:html_week>/', api.get_plan_ical, name='api_get_plan_ical'),
path('api/recipe-from-url/<path:url>/', api.recipe_from_url, name='api_recipe_from_url'),
path('dal/keyword/', dal.KeywordAutocomplete.as_view(), name='dal_keyword'),
path('dal/ingredient/', dal.IngredientsAutocomplete.as_view(), name='dal_ingredient'),
path('dal/unit/', dal.UnitAutocomplete.as_view(), name='dal_unit'),
path('docs/markdown/', views.markdown_info, name='docs_markdown'),
path('docs/api/', views.api_info, name='docs_api'),
path('openapi', get_schema_view(
title="Django Recipes",
version=VERSION_NUMBER
), name='openapi-schema'),
path('api/', include((router.urls, 'api'))),
path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
]
generic_models = (Recipe, RecipeImport, Storage, RecipeBook, MealPlan, SyncLog, Sync, Comment, RecipeBookEntry, Keyword, Ingredient)

View File

@@ -1,19 +1,163 @@
import os
import io
import json
import re
import requests
from annoying.decorators import ajax_request
from annoying.functions import get_object_or_None
from django.contrib import messages
from django.http import HttpResponse, FileResponse
from django.contrib.auth.models import User
from django.db.models import Q
from django.http import HttpResponse, FileResponse, JsonResponse
from django.shortcuts import redirect
from django.utils.translation import gettext as _
from icalendar import Calendar, Event
from rest_framework import viewsets, permissions
from rest_framework.exceptions import APIException
from rest_framework.mixins import RetrieveModelMixin, UpdateModelMixin, ListModelMixin
from cookbook.helper.permission_helper import group_required
from cookbook.models import Recipe, Sync, Storage, CookLog
from cookbook.helper.permission_helper import group_required, CustomIsOwner, CustomIsAdmin, CustomIsUser
from cookbook.helper.recipe_url_import import get_from_html
from cookbook.models import Recipe, Sync, Storage, CookLog, MealPlan, MealType, ViewLog, UserPreference, RecipeBook, RecipeIngredient, Ingredient
from cookbook.provider.dropbox import Dropbox
from cookbook.provider.nextcloud import Nextcloud
from cookbook.serializer import MealPlanSerializer, MealTypeSerializer, RecipeSerializer, ViewLogSerializer, UserNameSerializer, UserPreferenceSerializer, RecipeBookSerializer, RecipeIngredientSerializer, IngredientSerializer
class UserNameViewSet(viewsets.ModelViewSet):
"""
list:
optional parameters
- **filter_list**: array of user id's to get names for
"""
queryset = User.objects.all()
serializer_class = UserNameSerializer
permission_classes = [permissions.IsAuthenticated]
http_method_names = ['get']
def get_queryset(self):
queryset = User.objects.all()
try:
filter_list = self.request.query_params.get('filter_list', None)
if filter_list is not None:
queryset = queryset.filter(pk__in=json.loads(filter_list))
except ValueError as e:
raise APIException(_('Parameter filter_list incorrectly formatted'))
return queryset
class UserPreferenceViewSet(viewsets.ModelViewSet):
queryset = UserPreference.objects.all()
serializer_class = UserPreferenceSerializer
permission_classes = [CustomIsOwner, ]
def perform_create(self, serializer):
if UserPreference.objects.filter(user=self.request.user).exists():
raise APIException(_('Preference for given user already exists'))
serializer.save(user=self.request.user)
def get_queryset(self):
if self.request.user.is_superuser:
return self.queryset
return self.queryset.filter(user=self.request.user)
class RecipeBookViewSet(RetrieveModelMixin, UpdateModelMixin, ListModelMixin, viewsets.GenericViewSet):
queryset = RecipeBook.objects.all()
serializer_class = RecipeBookSerializer
permission_classes = [CustomIsOwner, CustomIsAdmin]
def get_queryset(self):
if self.request.user.is_superuser:
return self.queryset
return self.queryset.filter(created_by=self.request.user)
class MealPlanViewSet(viewsets.ModelViewSet):
"""
list:
optional parameters
- **html_week**: filter for a calendar week (format 2020-W24 as html input type week)
"""
queryset = MealPlan.objects.all()
serializer_class = MealPlanSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
queryset = MealPlan.objects.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).distinct().all()
week = self.request.query_params.get('html_week', None)
if week is not None:
y, w = week.replace('-W', ' ').split()
queryset = queryset.filter(date__week=w, date__year=y)
return queryset
class MealTypeViewSet(viewsets.ModelViewSet):
"""
list:
returns list of meal types created by the requesting user ordered by the order field
"""
queryset = MealType.objects.order_by('order').all()
serializer_class = MealTypeSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
queryset = MealType.objects.order_by('order', 'id').filter(created_by=self.request.user).all()
return queryset
class RecipeViewSet(viewsets.ModelViewSet):
"""
list:
optional parameters
- **query**: search a recipe for a string contained in the recipe name (case in-sensitive)
- **limit**: limits the amount of returned recipes
"""
queryset = Recipe.objects.all()
serializer_class = RecipeSerializer
permission_classes = [permissions.IsAuthenticated] # TODO split read and write permission for meal plan guest
def get_queryset(self):
queryset = Recipe.objects.all()
query = self.request.query_params.get('query', None)
if query is not None:
queryset = queryset.filter(name__icontains=query)
limit = self.request.query_params.get('limit', None)
if limit is not None:
queryset = queryset[:int(limit)]
return queryset
class RecipeIngredientViewSet(viewsets.ModelViewSet):
queryset = RecipeIngredient.objects.all()
serializer_class = RecipeIngredientSerializer
permission_classes = [CustomIsUser]
class IngredientViewSet(viewsets.ModelViewSet):
queryset = Ingredient.objects.all()
serializer_class = IngredientSerializer
permission_classes = [CustomIsUser]
class ViewLogViewSet(viewsets.ModelViewSet):
queryset = ViewLog.objects.all()
serializer_class = ViewLogSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
queryset = ViewLog.objects.filter(created_by=self.request.user).all()[:5]
return queryset
# -------------- non django rest api views --------------------
def get_recipe_provider(recipe):
if recipe.storage.method == Storage.DROPBOX:
return Dropbox
@@ -88,3 +232,40 @@ def log_cooking(request, recipe_id):
return {'msg': 'updated successfully'}
return {'error': 'recipe does not exist'}
@group_required('user')
def get_plan_ical(request, html_week):
queryset = MealPlan.objects.filter(Q(created_by=request.user) | Q(shared=request.user)).distinct().all()
y, w = html_week.replace('-W', ' ').split()
queryset = queryset.filter(date__week=w, date__year=y)
cal = Calendar()
for p in queryset:
event = Event()
event['uid'] = p.id
event.add('dtstart', p.date)
event.add('dtend', p.date)
event['summary'] = f'{p.meal_type.name}: {p.get_label()}'
event['description'] = p.note
cal.add_component(event)
response = FileResponse(io.BytesIO(cal.to_ical()))
response["Content-Disposition"] = f'attachment; filename=meal_plan_{html_week}.ics'
return response
@group_required('user')
def recipe_from_url(request, url):
headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Safari/537.36'}
try:
response = requests.get(url, headers=headers)
except requests.exceptions.ConnectionError:
return JsonResponse({'error': True, 'msg': _('The requested page could not be found.')}, status=400)
if response.status_code == 403:
return JsonResponse({'error': True, 'msg': _('The requested page refused to provide any information (Status Code 403).')}, status=400)
return get_from_html(response.text, url)

View File

@@ -1,13 +1,20 @@
import json
from datetime import datetime
from io import BytesIO
import requests
from PIL import Image
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.files import File
from django.utils.translation import gettext as _
from django.http import HttpResponseRedirect, HttpResponse
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.translation import ngettext
from django_tables2 import RequestConfig
from cookbook.forms import SyncForm, BatchEditForm
from cookbook.helper.permission_helper import group_required
from cookbook.helper.permission_helper import group_required, has_group_permission
from cookbook.models import *
from cookbook.tables import SyncTable
@@ -15,6 +22,9 @@ from cookbook.tables import SyncTable
@group_required('user')
def sync(request):
if request.method == "POST":
if not has_group_permission(request.user, ['admin']):
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!'))
return HttpResponseRedirect(reverse('data_sync'))
form = SyncForm(request.POST)
if form.is_valid():
new_path = Sync()
@@ -83,6 +93,63 @@ def batch_edit(request):
return render(request, 'batch/edit.html', {'form': form})
@group_required('user')
def import_url(request):
if request.method == 'POST':
data = json.loads(request.body)
recipe = Recipe.objects.create(
name=data['name'],
instructions=data['recipeInstructions'],
waiting_time=data['cookTime'],
working_time=data['prepTime'],
internal=True,
created_by=request.user,
)
for kw in data['keywords']:
if kw['id'] != "null" and (k := Keyword.objects.filter(id=kw['id']).first()):
recipe.keywords.add(k)
elif data['all_keywords']:
k = Keyword.objects.create(name=kw['text'])
recipe.keywords.add(k)
for ing in data['recipeIngredient']:
i, i_created = Ingredient.objects.get_or_create(name=ing['ingredient']['text'])
if ing['unit']:
u, u_created = Unit.objects.get_or_create(name=ing['unit']['text'])
else:
u = Unit.objects.get(name=request.user.userpreference.default_unit)
if isinstance(ing['amount'], str):
try:
ing['amount'] = float(ing['amount'].replace(',', '.'))
except ValueError:
# TODO return proper error
pass
RecipeIngredient.objects.create(recipe=recipe, ingredient=i, unit=u, amount=ing['amount'])
if data['image'] != '':
response = requests.get(data['image'])
img = Image.open(BytesIO(response.content))
# todo move image processing to dedicated function
basewidth = 720
wpercent = (basewidth / float(img.size[0]))
hsize = int((float(img.size[1]) * float(wpercent)))
img = img.resize((basewidth, hsize), Image.ANTIALIAS)
im_io = BytesIO()
img.save(im_io, 'PNG', quality=70)
recipe.image = File(im_io, name=f'{uuid.uuid4()}_{recipe.pk}.png')
recipe.save()
return HttpResponse(reverse('view_recipe', args=[recipe.pk]))
return render(request, 'url_import.html', {})
class Object(object):
pass

View File

@@ -3,7 +3,7 @@ from datetime import datetime
from django.contrib import messages
from django.http import HttpResponseRedirect
from django.shortcuts import render, redirect
from django.shortcuts import render, redirect, get_object_or_404
from django.urls import reverse_lazy, reverse
from django.utils.translation import gettext as _
from django.views.generic import CreateView
@@ -11,7 +11,7 @@ from django.views.generic import CreateView
from cookbook.forms import ImportRecipeForm, RecipeImport, KeywordForm, Storage, StorageForm, InternalRecipeForm, \
RecipeBookForm, MealPlanForm
from cookbook.helper.permission_helper import GroupRequiredMixin, group_required
from cookbook.models import Keyword, Recipe, RecipeBook, MealPlan
from cookbook.models import Keyword, Recipe, RecipeBook, MealPlan, ShareLink
class RecipeCreate(GroupRequiredMixin, CreateView):
@@ -36,6 +36,13 @@ class RecipeCreate(GroupRequiredMixin, CreateView):
return context
@group_required('user')
def share_link(request, pk):
recipe = get_object_or_404(Recipe, pk=pk)
link = ShareLink.objects.create(recipe=recipe, created_by=request.user)
return HttpResponseRedirect(reverse('view_recipe', kwargs={'pk': pk, 'share': link.uuid}))
class KeywordCreate(GroupRequiredMixin, CreateView):
groups_required = ['user']
template_name = "generic/new_template.html"
@@ -127,7 +134,7 @@ class MealPlanCreate(GroupRequiredMixin, CreateView):
def get_initial(self):
return dict(
meal=self.request.GET['meal'] if 'meal' in self.request.GET else None,
meal_type=self.request.GET['meal'] if 'meal' in self.request.GET else None,
date=datetime.strptime(self.request.GET['date'], '%Y-%m-%d') if 'date' in self.request.GET else None,
shared=self.request.user.userpreference.plan_share.all() if self.request.user.userpreference.plan_share else None
)

View File

@@ -1,4 +1,5 @@
import copy
import os
from datetime import datetime, timedelta
from django.contrib import messages
@@ -15,10 +16,11 @@ from django_tables2 import RequestConfig
from django.utils.translation import gettext as _
from django.conf import settings
from rest_framework.authtoken.models import Token
from cookbook.filters import RecipeFilter
from cookbook.forms import *
from cookbook.helper.permission_helper import group_required
from cookbook.helper.permission_helper import group_required, share_link_valid
from cookbook.tables import RecipeTable, RecipeTableSmall, CookLogTable, ViewLogTable
from recipes.version import *
@@ -70,13 +72,21 @@ def search(request):
return render(request, 'index.html')
@group_required('guest')
def recipe_view(request, pk):
def recipe_view(request, pk, share=None):
recipe = get_object_or_404(Recipe, pk=pk)
ingredients = RecipeIngredient.objects.filter(recipe=recipe)
if not request.user.is_authenticated and not share_link_valid(recipe, share):
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!'))
return HttpResponseRedirect(reverse('index'))
ingredients = RecipeIngredient.objects.filter(recipe=recipe).all()
comments = Comment.objects.filter(recipe=recipe)
if request.method == "POST":
if not request.user.is_authenticated:
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to perform this action!'))
return HttpResponseRedirect(reverse('view_recipe', kwargs={'pk': recipe.pk, 'share': share}))
comment_form = CommentForm(request.POST, prefix='comment')
if comment_form.is_valid():
comment = Comment()
@@ -138,30 +148,7 @@ def get_days_from_week(start, end):
@group_required('user')
def meal_plan(request):
js_week = datetime.now().strftime("%Y-W%V")
if request.method == "POST":
js_week = request.POST['week']
year, week = js_week.split('-')
first_day, last_day = get_start_end_from_week(year, week.replace('W', ''))
surrounding_weeks = {'next': (last_day + timedelta(3)).strftime("%Y-W%V"), 'prev': (first_day - timedelta(3)).strftime("%Y-W%V")}
days = get_days_from_week(first_day, last_day)
days_dict = {}
for d in days:
days_dict[d] = []
plan = {}
for t in MealPlan.MEAL_TYPES:
plan[t[0]] = {'type_name': t[1], 'days': copy.deepcopy(days_dict)}
for d in days:
plan_day = MealPlan.objects.filter(date=d).filter(Q(created_by=request.user) | Q(shared=request.user)).distinct().all()
for p in plan_day:
plan[p.meal]['days'][d].append(p)
return render(request, 'meal_plan.html', {'js_week': js_week, 'plan': plan, 'days': days, 'surrounding_weeks': surrounding_weeks})
return render(request, 'meal_plan.html', {})
@group_required('user')
@@ -172,7 +159,7 @@ def meal_plan_entry(request, pk):
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!'))
return HttpResponseRedirect(reverse_lazy('index'))
same_day_plan = MealPlan.objects.filter(date=plan.date).exclude(pk=plan.pk).filter(Q(created_by=request.user) | Q(shared=request.user)).order_by('meal').all()
same_day_plan = MealPlan.objects.filter(date=plan.date).exclude(pk=plan.pk).filter(Q(created_by=request.user) | Q(shared=request.user)).order_by('meal_type').all()
return render(request, 'meal_plan_entry.html', {'plan': plan, 'same_day_plan': same_day_plan})
@@ -223,6 +210,7 @@ def user_settings(request):
user_name_form = UserNameForm(instance=request.user)
password_form = PasswordChangeForm(request.user)
password_form.fields['old_password'].widget.attrs.pop("autofocus", None)
if request.method == "POST":
if 'preference_form' in request.POST:
@@ -230,6 +218,7 @@ def user_settings(request):
if form.is_valid():
if not up:
up = UserPreference(user=request.user)
up.theme = form.cleaned_data['theme']
up.nav_color = form.cleaned_data['nav_color']
up.default_unit = form.cleaned_data['default_unit']
@@ -237,6 +226,8 @@ def user_settings(request):
up.show_recent = form.cleaned_data['show_recent']
up.search_style = form.cleaned_data['search_style']
up.plan_share.set(form.cleaned_data['plan_share'])
up.ingredient_decimals = form.cleaned_data['ingredient_decimals']
up.comments = form.cleaned_data['comments']
up.save()
if 'user_name_form' in request.POST:
@@ -257,7 +248,10 @@ def user_settings(request):
else:
preference_form = UserPreferenceForm()
return render(request, 'settings.html', {'preference_form': preference_form, 'user_name_form': user_name_form, 'password_form': password_form})
if (api_token := Token.objects.filter(user=request.user).first()) is None:
api_token = Token.objects.create(user=request.user)
return render(request, 'settings.html', {'preference_form': preference_form, 'user_name_form': user_name_form, 'password_form': password_form, 'api_token': api_token})
@group_required('guest')
@@ -271,7 +265,9 @@ def history(request):
def system(request):
postgres = False if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql_psycopg2' else True
return render(request, 'system.html', {'gunicorn_media': settings.GUNICORN_MEDIA, 'debug': settings.DEBUG, 'postgres': postgres, 'version': VERSION_NUMBER, 'ref': BUILD_REF})
secret_key = False if os.getenv('SECRET_KEY') else True
return render(request, 'system.html', {'gunicorn_media': settings.GUNICORN_MEDIA, 'debug': settings.DEBUG, 'postgres': postgres, 'version': VERSION_NUMBER, 'ref': BUILD_REF, 'secret_key': secret_key})
def setup(request):
@@ -307,3 +303,15 @@ def setup(request):
def markdown_info(request):
return render(request, 'markdown_info.html', {})
@group_required('guest')
def api_info(request):
return render(request, 'api_info.html', {})
def test(request):
if not settings.DEBUG:
return HttpResponseRedirect(reverse('index'))
return render(request, 'test.html', {'test': None})

View File

@@ -0,0 +1,52 @@
Many people appear to host this application on their Synology NAS. The following documentation was provided by
@therealschimmi in [this issue discussion](https://github.com/vabene1111/recipes/issues/98#issuecomment-643062907).
There are, as always, most likely other ways to do this but this can be used as a starting point for your
setup. Since i cannot test it myself feedback and improvements are always very welcome.
## Instructions
Basic guide to setup vabenee1111/recipes docker container on Synology NAS
1. Login to Synology DSM through your browser
- Install Docker through package center
- Optional: Create a shared folder for your docker projects, they have to store data somewhere outside the containers
- Create a folder somewhere, i suggest naming it 'recipes' and storing it in the dedicated docker folder
- Within, create the necessary folder structure. You will need these folders:
![grafik](https://user-images.githubusercontent.com/66269214/84472395-63042580-ac87-11ea-8779-37555210e47a.png)
2. Download templates
- vabene1111 gives you a few samples for various setups to work with. I chose to use the plain setup for now.
- Open https://github.com/vabene1111/recipes/tree/develop/docs/docker
- Download docker-compose.yml to your recipes folder
- Open https://github.com/vabene1111/recipes/tree/develop/docs/docker/plain/nginx/conf.d
- Download Recipes.conf to your conf.d folder
- Open https://github.com/vabene1111/recipes/blob/develop/.env.template
- Copy the text and save it as 'env' to your recipes folder (no filename extension!)
- Once done, it should look like this:
![grafik](https://user-images.githubusercontent.com/66269214/84471828-75319400-ac86-11ea-97e1-42bcb166720e.png)
3. Edit docker-compose.yml
- Open docker-compose.yml in a text editor
- This file tells docker how to setup recipes. Docker will create three containers for recipes to work, recipes, nginx and postgresql. They are all required and need to store and share data through the folders you created before.
- Edit line 26, this line specifies which external synology port will point to which internal docker port. Chose a free port to use and replace the first number with it. You will open recipes by browsing to http://your.synology.ip:chosen.port, e.g. http://192.168.1.1:2000
- If you want to use port 2000 you would edit to 2000:80
4. SSH into your Synology
- You need to access your Synology through SSH
- execute following commands
- `ssh root@your.synology.ip` connect to your synology. root password is the same as admin password, sometimes root access is not possible for whatever reason, then replace root with admin
- `cd /volume1/docker/recipes` access the folder where you store docker-compose.yml
- `docker-compose up -d` this starts your containers according to your docker-compose.yml. if you logged in with admin you will have to use `sudo docker-compose up -d` instead, it will ask for the admin password again.
- This output tells you all 3 containers have been setup
```
...
Creating recipes_nginx_recipes_1 ... done
Creating recipes_db_recipes_1 ... done
Creating recipes_web_recipes_1 ... done
```
- Browse to 192.168.1.1:2000 or whatever your IP and port are
- While the containers are starting and doing whatever they need to do, you might still get HTTP errors e.g. 500 or 502. Just be patient and try again in a moment

View File

@@ -10,6 +10,9 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/2.0/ref/settings/
"""
import os
import random
import string
from django.contrib import messages
from dotenv import load_dotenv
from django.utils.translation import gettext_lazy as _
@@ -17,7 +20,7 @@ from django.utils.translation import gettext_lazy as _
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Get vars from .env files
SECRET_KEY = os.getenv('SECRET_KEY') if os.getenv('SECRET_KEY') else '728f4t5438rz0748fa89esf9e'
SECRET_KEY = os.getenv('SECRET_KEY') if os.getenv('SECRET_KEY') else 'INSECURE_STANDARD_KEY_SET_IN_ENV'
DEBUG = bool(int(os.getenv('DEBUG', True)))
@@ -25,8 +28,12 @@ GUNICORN_MEDIA = bool(int(os.getenv('GUNICORN_MEDIA', True)))
REVERSE_PROXY_AUTH = bool(int(os.getenv('REVERSE_PROXY_AUTH', False)))
COMMENT_PREF_DEFAULT = bool(int(os.getenv('COMMENT_PREF_DEFAULT', True)))
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS').split(',') if os.getenv('ALLOWED_HOSTS') else ['*']
CORS_ORIGIN_ALLOW_ALL = True
LOGIN_REDIRECT_URL = "index"
LOGOUT_REDIRECT_URL = "index"
@@ -57,6 +64,7 @@ INSTALLED_APPS = [
'crispy_forms',
'emoji_picker',
'rest_framework',
'rest_framework.authtoken',
'django_cleanup.apps.CleanupConfig',
'cookbook.apps.CookbookConfig',
]
@@ -81,12 +89,23 @@ if REVERSE_PROXY_AUTH:
MIDDLEWARE.append('recipes.middleware.CustomRemoteUser')
AUTHENTICATION_BACKENDS.append('django.contrib.auth.backends.RemoteUserBackend')
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.BasicAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
]
}
ROOT_URLCONF = 'recipes.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')],
'DIRS': [os.path.join(BASE_DIR, 'templates'), os.path.join(BASE_DIR, 'cookbook', 'templates')],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [

View File

@@ -20,3 +20,8 @@ simplejson==3.17.0
six==1.15.0
webdavclient3==3.14.4
whitenoise==5.1.0
icalendar==4.0.6
pyyaml==5.3.1
uritemplate==3.0.1
beautifulsoup4==4.9.1
microdata==0.7.1