mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2025-12-27 04:00:48 -05:00
Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6979bf34d9 | ||
|
|
2c5e44d73c | ||
|
|
b6d98397b5 | ||
|
|
525ad6dd98 | ||
|
|
166833fe83 | ||
|
|
752854028a | ||
|
|
6197cab1ed | ||
|
|
73f2240763 | ||
|
|
3ef82aee9c | ||
|
|
e49e53e2b2 | ||
|
|
0266476aef | ||
|
|
5ed369ba69 | ||
|
|
98c278fe60 | ||
|
|
8594346488 | ||
|
|
dc91e1e8ed | ||
|
|
976dd13a31 | ||
|
|
8cca272bb9 | ||
|
|
f066b7097c | ||
|
|
71b41a9ca2 | ||
|
|
9e748552b2 | ||
|
|
743d7bf608 | ||
|
|
315b4521b6 | ||
|
|
b558ba55b4 | ||
|
|
0368630c92 | ||
|
|
02c1ba0c71 | ||
|
|
83cc8832cb | ||
|
|
14a5d43dc8 | ||
|
|
bea079dd05 | ||
|
|
df8170fa55 | ||
|
|
2904d5938d | ||
|
|
18bfecb026 | ||
|
|
4ee5a4fd9f | ||
|
|
bbaedfad33 | ||
|
|
de413f1473 | ||
|
|
d012385088 | ||
|
|
d18a330135 | ||
|
|
a8f7ef8ef7 | ||
|
|
d6972cacfb | ||
|
|
3b21e44422 | ||
|
|
1a78ca68bb | ||
|
|
fac7b8cd5b | ||
|
|
8f780545a4 | ||
|
|
218f7d92d7 | ||
|
|
621bacff1c | ||
|
|
9a849a979c | ||
|
|
e8366e5280 | ||
|
|
0a8270e7cf | ||
|
|
aad8b220d1 | ||
|
|
d5e0a0a623 | ||
|
|
8cd94d49e8 | ||
|
|
08b805a547 | ||
|
|
ecac30136b | ||
|
|
d694408af6 | ||
|
|
6e284f6ae8 | ||
|
|
62c049a6de | ||
|
|
dee7249347 | ||
|
|
17946c8dac | ||
|
|
fa2326949e | ||
|
|
8177d9ba0f | ||
|
|
8781a6572d | ||
|
|
c7d518071c | ||
|
|
ea96c63289 | ||
|
|
8485a64726 | ||
|
|
e89bd44412 | ||
|
|
2e0e48bb38 | ||
|
|
040fa7c192 | ||
|
|
7000097602 | ||
|
|
3cbc6b5609 | ||
|
|
8ff52f542e | ||
|
|
8cc0fcaed2 | ||
|
|
d4197773bf | ||
|
|
f530b3dc7a | ||
|
|
d1bf4d4bbb | ||
|
|
d584a3db25 | ||
|
|
aaa3737ae0 | ||
|
|
5072859e57 | ||
|
|
ead3c6ef76 | ||
|
|
d734cb813e | ||
|
|
8aa24d4771 | ||
|
|
c714ff4dbe | ||
|
|
a32545c1dc | ||
|
|
dfe8e1fd42 | ||
|
|
729d573460 | ||
|
|
8472b541aa | ||
|
|
7e95e985ec | ||
|
|
f7e2aa9b83 | ||
|
|
99cf428470 | ||
|
|
60a533f9c8 | ||
|
|
2bda5bbbf7 | ||
|
|
a743a4e202 | ||
|
|
ffa7513f9e | ||
|
|
8cb6ed2f60 | ||
|
|
2e255aba0d | ||
|
|
a136a18a8e | ||
|
|
3aedbfbdc3 | ||
|
|
b95c3f6685 | ||
|
|
3b5b505116 | ||
|
|
aea3f62f9b | ||
|
|
201c493658 | ||
|
|
8ffc6a0236 | ||
|
|
84ad88b30b | ||
|
|
233f2a911f | ||
|
|
989d8765d7 | ||
|
|
2fcd207dc7 | ||
|
|
a3dc5f283a |
@@ -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
51
.github/workflows/codeql-analysis.yml
vendored
Normal 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
|
||||
2
.idea/dictionaries/vabene1111_PC.xml
generated
2
.idea/dictionaries/vabene1111_PC.xml
generated
@@ -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
2
.idea/recipes.iml
generated
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
2
boot.sh
2
boot.sh
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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.'),
|
||||
|
||||
10
cookbook/helper/permission_config.py
Normal file
10
cookbook/helper/permission_config.py
Normal 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],
|
||||
}
|
||||
@@ -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'])
|
||||
|
||||
|
||||
184
cookbook/helper/recipe_url_import.py
Normal file
184
cookbook/helper/recipe_url_import.py
Normal 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)
|
||||
27
cookbook/migrations/0046_auto_20200602_1133.py
Normal file
27
cookbook/migrations/0046_auto_20200602_1133.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
51
cookbook/migrations/0047_auto_20200602_1133.py
Normal file
51
cookbook/migrations/0047_auto_20200602_1133.py
Normal 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),
|
||||
]
|
||||
23
cookbook/migrations/0048_auto_20200602_1140.py
Normal file
23
cookbook/migrations/0048_auto_20200602_1140.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
21
cookbook/migrations/0049_mealtype_created_by.py
Normal file
21
cookbook/migrations/0049_mealtype_created_by.py
Normal 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),
|
||||
),
|
||||
]
|
||||
30
cookbook/migrations/0050_auto_20200611_1509.py
Normal file
30
cookbook/migrations/0050_auto_20200611_1509.py
Normal 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),
|
||||
]
|
||||
21
cookbook/migrations/0051_auto_20200611_1518.py
Normal file
21
cookbook/migrations/0051_auto_20200611_1518.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0053_auto_20200611_2217.py
Normal file
18
cookbook/migrations/0053_auto_20200611_2217.py
Normal 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),
|
||||
),
|
||||
]
|
||||
27
cookbook/migrations/0054_sharelink.py
Normal file
27
cookbook/migrations/0054_sharelink.py
Normal 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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
24
cookbook/migrations/0055_auto_20200616_1236.py
Normal file
24
cookbook/migrations/0055_auto_20200616_1236.py
Normal 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')),
|
||||
),
|
||||
]
|
||||
25
cookbook/migrations/0056_auto_20200625_2157.py
Normal file
25
cookbook/migrations/0056_auto_20200625_2157.py
Normal 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)
|
||||
]
|
||||
@@ -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
128
cookbook/serializer.py
Normal 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__'
|
||||
1
cookbook/static/css/vue-multiselect.min.css
vendored
Normal file
1
cookbook/static/css/vue-multiselect.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
2
cookbook/static/js/Sortable.min.js
vendored
Normal file
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
1
cookbook/static/js/js.cookie.min.js
vendored
Normal 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(){})});
|
||||
1
cookbook/static/js/js.cookie.min.js.map
Normal file
1
cookbook/static/js/js.cookie.min.js.map
Normal 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"}
|
||||
2
cookbook/static/js/moment-with-locales.min.js
vendored
Normal file
2
cookbook/static/js/moment-with-locales.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
137
cookbook/static/js/redoc.standalone.js
Normal file
137
cookbook/static/js/redoc.standalone.js
Normal file
File diff suppressed because one or more lines are too long
1
cookbook/static/js/vue-draggable.min.js
vendored
Normal file
1
cookbook/static/js/vue-draggable.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
cookbook/static/js/vue-multiselect.min.js
vendored
Normal file
1
cookbook/static/js/vue-multiselect.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
cookbook/static/js/vue-resource.js
Normal file
7
cookbook/static/js/vue-resource.js
Normal file
File diff suppressed because one or more lines are too long
11965
cookbook/static/js/vue.js
Normal file
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
6
cookbook/static/js/vue.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
cookbook/static/js/vuedraggable.umd.min.js
vendored
Normal file
2
cookbook/static/js/vuedraggable.umd.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
cookbook/static/js/vuedraggable.umd.min.js.map
Normal file
1
cookbook/static/js/vuedraggable.umd.min.js.map
Normal file
File diff suppressed because one or more lines are too long
38
cookbook/templates/404.html
Normal file
38
cookbook/templates/404.html
Normal 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 %}
|
||||
16
cookbook/templates/api_info.html
Normal file
16
cookbook/templates/api_info.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
13
cookbook/templates/include/vue_base.html
Normal file
13
cookbook/templates/include/vue_base.html
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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">×</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">×</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">×</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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
23
cookbook/templates/rest_framework/api.html
Normal file
23
cookbook/templates/rest_framework/api.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{{ test }}
|
||||
385
cookbook/templates/url_import.html
Normal file
385
cookbook/templates/url_import.html
Normal 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 %}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
0
cookbook/tests/api/__init__.py
Normal file
0
cookbook/tests/api/__init__.py
Normal file
86
cookbook/tests/api/test_api_userpreference.py
Normal file
86
cookbook/tests/api/test_api_userpreference.py
Normal 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)
|
||||
0
cookbook/tests/other/__init__.py
Normal file
0
cookbook/tests/other/__init__.py
Normal file
28
cookbook/tests/other/test_edits_recipe.py
Normal file
28
cookbook/tests/other/test_edits_recipe.py
Normal 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()
|
||||
1
cookbook/tests/resources/websites/ld_json_1.html
Normal file
1
cookbook/tests/resources/websites/ld_json_1.html
Normal file
File diff suppressed because one or more lines are too long
7
cookbook/tests/resources/websites/ld_json_2.html
Normal file
7
cookbook/tests/resources/websites/ld_json_2.html
Normal 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>
|
||||
4
cookbook/tests/resources/websites/ld_json_3.html
Normal file
4
cookbook/tests/resources/websites/ld_json_3.html
Normal 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>
|
||||
8
cookbook/tests/resources/websites/ld_json_4.html
Normal file
8
cookbook/tests/resources/websites/ld_json_4.html
Normal 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>
|
||||
|
||||
16
cookbook/tests/resources/websites/ld_json_invalid.html
Normal file
16
cookbook/tests/resources/websites/ld_json_invalid.html
Normal 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>
|
||||
1517
cookbook/tests/resources/websites/ld_json_itemList.html
Normal file
1517
cookbook/tests/resources/websites/ld_json_itemList.html
Normal file
File diff suppressed because one or more lines are too long
2
cookbook/tests/resources/websites/ld_json_multiple.html
Normal file
2
cookbook/tests/resources/websites/ld_json_multiple.html
Normal 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>
|
||||
8
cookbook/tests/resources/websites/micro_data_1.html
Normal file
8
cookbook/tests/resources/websites/micro_data_1.html
Normal 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 & 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>
|
||||
1004
cookbook/tests/resources/websites/micro_data_2.html
Normal file
1004
cookbook/tests/resources/websites/micro_data_2.html
Normal file
File diff suppressed because it is too large
Load Diff
221
cookbook/tests/resources/websites/micro_data_3.html
Normal file
221
cookbook/tests/resources/websites/micro_data_3.html
Normal 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ür Schokopudding. Jetzt nachkochen/ nachbacken oder von weiteren kö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ü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ährwerte</a>
|
||||
</h2>
|
||||
<p class="added-info-subline">Hier finden Sie die Nä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%"> </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>
|
||||
853
cookbook/tests/resources/websites/micro_data_4.html
Normal file
853
cookbook/tests/resources/websites/micro_data_4.html
Normal 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. 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="[ "pinterest" ]"
|
||||
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&uid=bb94e27c5a5346d1c1257356004184a3"
|
||||
data-recipe-uid="bb94e27c5a5346d1c1257356004184a3"
|
||||
data-rate-url="/de-de/recipe-rating/?eID=oetRateRecipe&uid=bb94e27c5a5346d1c1257356004184a3&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': [{"AmountAndUnitSummaryText":"1 Pck.","ArticleSummaryText":"Dr. Oetker Gala Feines Schokoladen-Puddingpulver"},{"AmountAndUnitSummaryText":"50 g","ArticleSummaryText":"Zucker"},{"AmountAndUnitSummaryText":"500 ml","ArticleSummaryText":"Milch"},{"AmountAndUnitSummaryText":"1 Pck.","ArticleSummaryText":"Dr. Oetker Gala Puddingpulver Bourbon-Vanille"},{"AmountAndUnitSummaryText":"30 g","ArticleSummaryText":"Zucker"},{"AmountAndUnitSummaryText":"375 ml","ArticleSummaryText":"Milch"},{"AmountAndUnitSummaryText":"einige","ArticleSummaryText":"Erdbeeren zum Garnieren"},{"AmountAndUnitSummaryText":null,"ArticleSummaryText":"Herzausstecher"}],
|
||||
'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="[ "facebook","twitter","pinterest","whatsapp" ]"
|
||||
data-tracking-share="[{"attribute":"data-track-event","value":"socialMediaShare"},{"attribute":"data-track-category","value":"recipe"},{"attribute":"data-track-label","value":"schokopudding mit vanille herzen"}]"
|
||||
>
|
||||
</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 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>
|
||||
@@ -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}')
|
||||
|
||||
@@ -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)
|
||||
|
||||
44
cookbook/tests/views/test_views_recipe_share.py
Normal file
44
cookbook/tests/views/test_views_recipe_share.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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})
|
||||
|
||||
52
docs/docker/synology/README.md
Normal file
52
docs/docker/synology/README.md
Normal 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:
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
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
|
||||
@@ -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': [
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user