Compare commits

...

55 Commits
0.6.4 ... 0.7.1

Author SHA1 Message Date
vabene1111
dfb1d80ca0 fixed duplicates in recent view 2020-05-27 09:38:57 +02:00
vabene1111
744fbc7a46 revert psql distinct change 2020-05-15 13:12:52 +02:00
vabene1111
cd11cc58cf possible duplicate fix 2020-05-15 12:49:31 +02:00
vabene1111
569e385915 Revert "Create FUNDING.yml"
This reverts commit abf552cd18.
2020-05-13 13:27:46 +02:00
vabene1111
abf552cd18 Create FUNDING.yml 2020-05-13 13:22:26 +02:00
vabene1111
c6959488dc fixed typo on search page 2020-05-11 13:08:49 +02:00
vabene1111
85e3155b50 added group required filter to history view 2020-05-11 13:08:00 +02:00
vabene1111
f6aa50bbfc added history page 2020-05-11 12:59:54 +02:00
vabene1111
5ad27c015e markdown info central blockquote css 2020-05-11 12:44:31 +02:00
vabene1111
4a68a99907 show last viewd recipes on search page 2020-05-11 12:42:55 +02:00
vabene1111
123dc1a74d meal plan entry view 2020-05-08 00:10:23 +02:00
vabene1111
2e23fcfd5d added sharing to meal plan + fixed meal plan visibility 2020-05-07 23:16:24 +02:00
vabene1111
edbc21df19 Update README.md 2020-05-06 08:21:53 +02:00
vabene1111
f0e1c901c6 fixed print button tooltip messing up print 2020-05-04 20:48:17 +02:00
vabene1111
22e403e0ff added basic markdown doc 2020-05-03 00:43:13 +02:00
vabene1111
6a7b02b700 add special type of ingredients to allow headers 2020-05-02 23:46:57 +02:00
vabene1111
4aa2983681 order recipe ingredients 2020-05-02 23:05:36 +02:00
vabene1111
18888bc3ae search image heigth fixes 2020-05-02 22:10:02 +02:00
vabene1111
07a0a3f598 recipe book improvements 2020-05-02 21:59:32 +02:00
vabene1111
76e1274ba5 rating/last cooked display 2020-05-02 21:54:38 +02:00
vabene1111
598387efc8 fixed duplicate recipe books when sharing 2020-05-02 21:46:03 +02:00
vabene1111
f00ee7d9fa display log info 2020-05-02 21:44:12 +02:00
vabene1111
6abe6f2ee4 re added mistakingly deleted file 2020-05-02 21:43:53 +02:00
vabene1111
bd69f2d103 log button in view 2020-05-02 17:55:14 +02:00
vabene1111
6a963c26b2 recipe rating 2020-05-02 17:31:35 +02:00
vabene1111
4c08ade3ee fixed markdown bleach renderer again 2020-05-02 15:10:15 +02:00
vabene1111
37f7326f4c minor mealplan cleanups 2020-05-02 14:58:23 +02:00
vabene1111
c398fda15c Merge branch 'feature/plan-title' into develop 2020-05-02 14:53:26 +02:00
vabene1111
e9da17151a added title field and custom validation 2020-05-02 14:53:09 +02:00
vabene1111
fd4354f16d Merge pull request #57 from tourn/mealplan-recipes-optional
Allow mealplan items to have no recipes
2020-05-02 14:44:46 +02:00
vabene1111
0d0c6c9066 Merge branch 'feature/plan-title' into mealplan-recipes-optional 2020-05-02 14:44:15 +02:00
vabene1111
4620c78f5a user preference fixes and improvements 2020-05-02 14:41:54 +02:00
vabene1111
349b9629f8 added sharing to recipe books 2020-05-02 14:15:56 +02:00
vabene1111
64ee18c4d8 improved recipe book design 2020-05-02 13:58:42 +02:00
vabene1111
3a9e5a80ba final style touches + settings 2020-05-02 12:48:22 +02:00
vabene1111
de85a6b334 further search style improvements 2020-05-02 12:07:03 +02:00
vabene1111
25318b691d new search design improvements 2020-05-02 01:04:45 +02:00
vabene1111
77e778caac new search design basics + Boostrap fixes 2020-05-02 00:49:29 +02:00
vabene1111
b53f83a76c improved stats page 2020-04-29 17:18:12 +02:00
vabene1111
2304c43a60 fixed ingredient calculator rounding error 2020-04-29 16:21:45 +02:00
vabene1111
16963c17dc add default roles to existing users 2020-04-27 18:22:29 +02:00
vabene1111
1d9dc0f952 api permission tests 2020-04-27 17:57:43 +02:00
vabene1111
a9fe821067 added test for comments 2020-04-27 17:48:11 +02:00
vabene1111
c7b1b08516 updated tests 2020-04-27 17:13:43 +02:00
vabene1111
1617fa7a3f fixed permissions comments, books 2020-04-27 16:50:05 +02:00
vabene1111
ad467fae28 added basic group permission system 2020-04-26 17:21:44 +02:00
vabene1111
c7046bc705 fixed markdown urlize 2020-04-26 15:52:07 +02:00
vabene1111
52946a8e4c fixed broken emoji 2020-04-26 00:29:56 +02:00
vabene1111
dd6b77e029 added screenshots + refactor preview + moved docu 2020-04-26 00:28:14 +02:00
vabene1111
396c1f3d5f added tooltips to recipe view 2020-04-25 23:35:01 +02:00
vabene1111
379d5a5177 import export cleanup + features 2020-04-25 23:32:15 +02:00
vabene1111
85a4d5d432 basic import export working 2020-04-25 22:26:59 +02:00
vabene1111
43eb10e488 added basic exporting capability 2020-04-25 22:05:55 +02:00
vabene1111
d702c08a12 fixed urlize breaking markdown links 2020-04-25 10:46:27 +02:00
tourn
08cccfa133 Allow mealplan items to have no recipes
And display first line of notes in plan
2020-04-13 21:29:22 +02:00
62 changed files with 2512 additions and 595 deletions

View File

@@ -3,6 +3,8 @@ Recipes is a Django application to manage, tag and search recipes using either b
![Preview](docs/preview.png)
[More Screenshots](https://imgur.com/a/V01151p)
### Features
- :package: **Sync** files with Dropbox and Nextcloud (more can easily be added)
@@ -12,13 +14,15 @@ Recipes is a Django application to manage, tag and search recipes using either b
- :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
- :person_with_blond_hair: **Share** recipes with friends and comment on them to suggest or remember changes you made
- :family: **Share** recipes with friends and comment on them to suggest or remember changes you made
- :whale: Easy setup with **Docker**
- :art: Customize your interface with **themes**
- :envelope: Export and import recipes from other users
- :heavy_plus_sign: Many more like recipe scaling, image compression, cookbooks, printing views, ...
This application is meant for people with a collection of recipes they want to share with family and friends or simply store them in a nicely organized way. A basic permission system exists but this application is not meant to be run as a public page.
This application is meant for people with a collection of recipes they want to share with family and friends or simply
store them in a nicely organized way. A basic permission system exists but this application is not meant to be run as a public page.
Some Documentation can be found [here](https://github.com/vabene1111/recipes/wiki)
# Installation
The docker image (`vabene1111/recipes`) simply exposes the application on port `8080`. You may choose any preferred installation method, the following are just examples to make it easier.
@@ -31,6 +35,8 @@ The docker image (`vabene1111/recipes`) simply exposes the application on port `
4. Create a default user by running `docker-compose exec web_recipes createsuperuser`.
### Manual
**Python >= 3.8** is required to run this!
Copy `.env.template` to `.env` and fill in the missing values accordingly.
Make sure all variables are available to whatever serves your application.
@@ -38,7 +44,6 @@ Otherwise simply follow the instructions for any django based deployment
(for example [this one](http://uwsgi-docs.readthedocs.io/en/latest/tutorials/Django_and_nginx.html)).
## Updating
While intermediate updates can be skipped when updating please make sure to **read the release notes** in case some special action is required to update.
0. Before updating it is recommended to **create a backup!**
@@ -50,31 +55,6 @@ While intermediate updates can be skipped when updating please make sure to **re
You can find a basic kubernetes setup [here](docs/k8s/). Please see the README in the folder for more detail.
# Documentation
Most things should be straight forward but there are some more complicated things.
##### Storage Backends
A `Storage Backend` is a remote storage location where PDF files are read from. To add a new backend click on `Storage Data` and then on `Storage Backends`. There click the plus button.
Enter a name (just a display name for you to identify it) and an API access Token for the account you want to use.
Dropboxes API tokens can be found on the [Dropboxes API explorer](https://dropbox.github.io/dropbox-api-v2-explorer/#auth_token/from_oauth1)
with the button on the top right. For Nextcloud you can use a App apssword created in the settings.
##### Adding Synced Paths
To add a new path from your Storage backend to the sync list, go to `Storage Data >> Configure Sync` and select the storage backend you want to use.
Then enter the path you want to monitor starting at the storage root (e.g. `/Folder/RecipesFolder`) and save it.
##### Syncing Data
To sync the recipes app with the storage backends press `Sync now` under `Storage Data >> Configure Sync`.
##### Import Recipes
All files found by the sync can be found under `Manage Data >> Import recipes`. There you can either import all at once without modifying them or import one by one, adding tags while importing.
##### Batch Edit
If you have many untagged recipes, you may want to edit them all at once. To do so, go to
`Storage Data >> Batch Edit`. Enter a word which should be contained in the recipe name and select the tags you want to apply.
When clicking submit, every recipe containing the word will be updated (tags are added).
> Currently the only option is word contains, maybe some more SQL like operators will be added later.
## Contributing
Pull Requests and ideas are welcome, feel free to contribute in any way.
For any questions on how to work with django please refer to their excellent [documentation](https://www.djangoproject.com/start/).

View File

@@ -3,7 +3,7 @@ from .models import *
class UserPreferenceAdmin(admin.ModelAdmin):
list_display = ('name', 'theme', 'nav_color')
list_display = ('name', 'theme', 'nav_color', 'default_page', 'search_style')
@staticmethod
def name(obj):
@@ -80,7 +80,7 @@ class RecipeBookAdmin(admin.ModelAdmin):
@staticmethod
def user_name(obj):
return obj.user.get_user_name()
return obj.created_by.get_user_name()
admin.site.register(RecipeBook, RecipeBookAdmin)
@@ -98,7 +98,7 @@ class MealPlanAdmin(admin.ModelAdmin):
@staticmethod
def user(obj):
return obj.user.get_user_name()
return obj.created_by.get_user_name()
admin.site.register(MealPlan, MealPlanAdmin)

View File

@@ -1,6 +1,6 @@
from dal import autocomplete
from django import forms
from django.forms import widgets
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext as _
from emoji_picker.widgets import EmojiPickerTextInput
@@ -31,11 +31,16 @@ class UserPreferenceForm(forms.ModelForm):
class Meta:
model = UserPreference
fields = ('default_unit', 'theme', 'nav_color', 'default_page')
fields = ('default_unit', 'theme', 'nav_color', 'default_page', 'search_style', 'plan_share')
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.')
'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.')
}
widgets = {
'plan_share': MultiSelectWidget
}
@@ -86,6 +91,9 @@ class InternalRecipeForm(forms.ModelForm):
'waiting_time': _('Waiting time (cooking/baking) in minutes'),
}
widgets = {'keywords': MultiSelectWidget}
help_texts = {
'instructions': _('You can use markdown to format this field. See the <a href="/docs/markdown/">docs here</a>')
}
class ShoppingForm(forms.Form):
@@ -100,6 +108,25 @@ class ShoppingForm(forms.Form):
)
class ExportForm(forms.Form):
recipe = forms.ModelChoiceField(
queryset=Recipe.objects.filter(internal=True).all(),
widget=SelectWidget
)
image = forms.BooleanField(
help_text=_('Export Base64 encoded image?'),
required=False
)
download = forms.BooleanField(
help_text=_('Download export directly or show on page?'),
required=False
)
class ImportForm(forms.Form):
recipe = forms.CharField(widget=forms.Textarea, help_text=_('Simply paste a JSON export into this textarea and click import.'))
class UnitMergeForm(forms.Form):
prefix = 'unit'
@@ -219,12 +246,27 @@ class ImportRecipeForm(forms.ModelForm):
class RecipeBookForm(forms.ModelForm):
class Meta:
model = RecipeBook
fields = ('name',)
fields = ('name', 'icon', 'description', 'shared')
widgets = {'icon': EmojiPickerTextInput, 'shared': MultiSelectWidget}
class MealPlanForm(forms.ModelForm):
def clean(self):
cleaned_data = super(MealPlanForm, self).clean()
if cleaned_data['title'] == '' and cleaned_data['recipe'] is None:
raise forms.ValidationError(_('You must provide at least a recipe or a title.'))
return cleaned_data
class Meta:
model = MealPlan
fields = ('recipe', 'meal', 'note', 'date')
fields = ('recipe', 'title', 'meal', 'note', 'date', 'shared')
widgets = {'recipe': SelectWidget, 'date': DateWidget}
help_texts = {
'shared': _('You can list default users to share recipes with in the settings.'),
'note': _('You can use markdown to format this field. See the <a href="/docs/markdown/">docs here</a>')
}
widgets = {'recipe': SelectWidget, 'date': DateWidget, 'shared': MultiSelectWidget}

View File

@@ -0,0 +1,81 @@
"""A more liberal autolinker
Inspired by Django's urlize function.
Positive examples:
>>> import markdown
>>> md = markdown.Markdown(extensions=['urlize'])
>>> md.convert('http://example.com/')
u'<p><a href="http://example.com/">http://example.com/</a></p>'
>>> md.convert('go to http://example.com')
u'<p>go to <a href="http://example.com">http://example.com</a></p>'
>>> md.convert('example.com')
u'<p><a href="http://example.com">example.com</a></p>'
>>> md.convert('example.net')
u'<p><a href="http://example.net">example.net</a></p>'
>>> md.convert('www.example.us')
u'<p><a href="http://www.example.us">www.example.us</a></p>'
>>> md.convert('(www.example.us/path/?name=val)')
u'<p>(<a href="http://www.example.us/path/?name=val">www.example.us/path/?name=val</a>)</p>'
>>> md.convert('go to <http://example.com> now!')
u'<p>go to <a href="http://example.com">http://example.com</a> now!</p>'
Negative examples:
>>> md.convert('del.icio.us')
u'<p>del.icio.us</p>'
"""
import markdown
# Global Vars
URLIZE_RE = '(%s)' % '|'.join([
r'<(?:f|ht)tps?://[^>]*>',
r'\b(?:f|ht)tps?://[^)<>\s]+[^.,)<>\s]',
r'\bwww\.[^)<>\s]+[^.,)<>\s]',
r'[^(<\s]+\.(?:com|net|org)\b',
])
class UrlizePattern(markdown.inlinepatterns.Pattern):
""" Return a link Element given an autolink (`http://example/com`). """
def handleMatch(self, m):
url = m.group(2)
if url.startswith('<'):
url = url[1:-1]
text = url
if not url.split('://')[0] in ('http','https','ftp'):
if '@' in url and not '/' in url:
url = 'mailto:' + url
else:
url = 'http://' + url
el = markdown.util.etree.Element("a")
el.set('href', url)
el.text = markdown.util.AtomicString(text)
return el
class UrlizeExtension(markdown.Extension):
""" Urlize Extension for Python-Markdown. """
def extendMarkdown(self, md, md_globals):
""" Replace autolink with UrlizePattern """
md.inlinePatterns['autolink'] = UrlizePattern(URLIZE_RE, md)
def makeExtension(*args, **kwargs):
return UrlizeExtension(*args, **kwargs)
if __name__ == "__main__":
import doctest
doctest.testmod()

View File

@@ -0,0 +1,68 @@
"""
Source: https://djangosnippets.org/snippets/1703/
"""
from django.contrib import messages
from django.contrib.auth.decorators import user_passes_test
from django.utils.translation import gettext as _
from django.http import HttpResponseRedirect
from django.urls import reverse_lazy, reverse
def get_allowed_groups(groups_required):
groups_allowed = tuple(groups_required)
if 'guest' in groups_required:
groups_allowed = groups_allowed + ('user', 'admin')
if 'user' in groups_required:
groups_allowed = groups_allowed + ('admin',)
return groups_allowed
def group_required(*groups_required):
"""Requires user membership in at least one of the groups passed in."""
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 user_passes_test(in_groups, login_url='index')
class GroupRequiredMixin(object):
"""
groups_required - list of strings, required param
"""
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'))
return super(GroupRequiredMixin, self).dispatch(request, *args, **kwargs)
class OwnerRequiredMixin(object):
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:
obj = self.get_object()
if not (obj.created_by == request.user or request.user.is_superuser):
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)

View File

@@ -7,25 +7,25 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-03-18 12:13+0100\n"
"PO-Revision-Date: 2020-03-18 12:19+0100\n"
"POT-Creation-Date: 2020-04-25 23:31+0200\n"
"PO-Revision-Date: 2020-04-25 23:31+0200\n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"Last-Translator: \n"
"Language-Team: \n"
"X-Generator: Poedit 2.3\n"
#: .\cookbook\filters.py:15 .\cookbook\templates\base.html:98
#: .\cookbook\filters.py:15 .\cookbook\templates\base.html:99
#: .\cookbook\templates\forms\edit_internal_recipe.html:28
#: .\cookbook\templates\forms\ingredients.html:34
#: .\cookbook\templates\recipe_view.html:104 .\cookbook\views\lists.py:45
#: .\cookbook\templates\recipe_view.html:110 .\cookbook\views\lists.py:45
msgid "Ingredients"
msgstr "Zutaten"
#: .\cookbook\forms.py:35
#: .\cookbook\forms.py:36
msgid ""
"Color of the top navigation bar. Not all colors work with all themes, just "
"try them out!"
@@ -33,36 +33,48 @@ msgstr ""
"Farbe der oberen Navigationsleiste. Nicht alle Farben passen, daher einfach "
"mal ausprobieren!"
#: .\cookbook\forms.py:49 .\cookbook\forms.py:67 .\cookbook\forms.py:196
#: .\cookbook\forms.py:37
msgid "Default Unit to be used when inserting a new ingredient into a recipe."
msgstr "Standard Einheit für neue Zutaten."
#: .\cookbook\forms.py:49
msgid ""
"Both fields are optional. If none are given the username will be displayed "
"instead"
msgstr ""
"Beide Felder sind optional, wenn keins von beiden gegeben ist wird der "
"Nutzername angezeigt"
#: .\cookbook\forms.py:63 .\cookbook\forms.py:81 .\cookbook\forms.py:229
msgid "Name"
msgstr "Name"
#: .\cookbook\forms.py:50 .\cookbook\forms.py:68 .\cookbook\forms.py:197
#: .\cookbook\forms.py:64 .\cookbook\forms.py:82 .\cookbook\forms.py:230
#: .\cookbook\templates\stats.html:22
msgid "Keywords"
msgstr "Schlagwörter"
#: .\cookbook\forms.py:51 .\cookbook\forms.py:70
#: .\cookbook\forms.py:65 .\cookbook\forms.py:84
msgid "Preparation time in minutes"
msgstr "Zubereitungszeit in Minuten"
#: .\cookbook\forms.py:52 .\cookbook\forms.py:71
#: .\cookbook\forms.py:66 .\cookbook\forms.py:85
msgid "Waiting time (cooking/baking) in minutes"
msgstr "Wartezeit (kochen/backen) in Minuten"
#: .\cookbook\forms.py:53 .\cookbook\forms.py:198
#: .\cookbook\forms.py:67 .\cookbook\forms.py:231
msgid "Path"
msgstr "Pfad"
#: .\cookbook\forms.py:54
#: .\cookbook\forms.py:68
msgid "Storage UID"
msgstr "Speicher ID"
#: .\cookbook\forms.py:69
#: .\cookbook\forms.py:83
msgid "Instructions"
msgstr "Anleitung"
#: .\cookbook\forms.py:82
#: .\cookbook\forms.py:96
msgid ""
"Include <code>- [ ]</code> in list for easier usage in markdown based "
"documents."
@@ -70,51 +82,63 @@ msgstr ""
"Füge <code>- [ ]</code> vor den Zutaten ein um sie besser in einem Markdown "
"Dokument zu verwenden."
#: .\cookbook\forms.py:94
#: .\cookbook\forms.py:108
msgid "Export Base64 encoded image?"
msgstr "Base64 kodiertes Bild exportieren ?"
#: .\cookbook\forms.py:112
msgid "Download export directly or show on page?"
msgstr "Direkter Download oder anzeige auf Seite ?"
#: .\cookbook\forms.py:118
msgid "Simply paste a JSON export into this textarea and click import."
msgstr "Einfach JSON in die Textbox einfügen und importieren klicken."
#: .\cookbook\forms.py:127
msgid "New Unit"
msgstr "Neue Einheit"
#: .\cookbook\forms.py:95
#: .\cookbook\forms.py:128
msgid "New unit that other gets replaced by."
msgstr "Neue Einheit die die alte ersetzt."
#: .\cookbook\forms.py:100
#: .\cookbook\forms.py:133
msgid "Old Unit"
msgstr "Alte Einheit"
#: .\cookbook\forms.py:101
#: .\cookbook\forms.py:134
msgid "Unit that should be replaced."
msgstr "Einheit die ersetzt werden soll."
#: .\cookbook\forms.py:111
#: .\cookbook\forms.py:144
msgid "New Ingredient"
msgstr "Neue Zutat"
#: .\cookbook\forms.py:112
#: .\cookbook\forms.py:145
msgid "New ingredient that other gets replaced by."
msgstr "Neue Zutat die die alte ersetzt."
#: .\cookbook\forms.py:117
#: .\cookbook\forms.py:150
msgid "Old Ingredient"
msgstr "Alte Zutat"
#: .\cookbook\forms.py:118
#: .\cookbook\forms.py:151
msgid "Ingredient that should be replaced."
msgstr "Zutat die ersetzt werden soll."
#: .\cookbook\forms.py:130
#: .\cookbook\forms.py:163
msgid "Add your comment: "
msgstr "Schreibe einen Kommentar:"
msgstr "Schreibe einen Kommentar: "
#: .\cookbook\forms.py:155
#: .\cookbook\forms.py:188
msgid "Leave empty for dropbox and enter app password for nextcloud."
msgstr "Für Dropbox leer lassen, bei Nextcloud App-Passwort eingeben."
#: .\cookbook\forms.py:158
#: .\cookbook\forms.py:191
msgid "Leave empty for nextcloud and enter api token for dropbox."
msgstr "Bei Nextcloud leer lassen, bei Dropbox API Token eingeben."
#: .\cookbook\forms.py:166
#: .\cookbook\forms.py:199
msgid ""
"Leave empty for dropbox and enter only base url for nextcloud (<code>/remote."
"php/webdav/</code> is added automatically)"
@@ -122,119 +146,128 @@ msgstr ""
"Bei Dropbox leer lassen, bei Nextcloud Server URL angeben (<code>/remote.php/"
"webdav/</code> wird automatisch hinzugefügt)"
#: .\cookbook\forms.py:185
#: .\cookbook\forms.py:218
msgid "Search String"
msgstr "Such Wort"
#: .\cookbook\forms.py:199
#: .\cookbook\forms.py:232
msgid "File ID"
msgstr "Datei ID"
#: .\cookbook\models.py:190
#: .\cookbook\models.py:49
msgid "Search"
msgstr "Suche"
#: .\cookbook\models.py:49 .\cookbook\templates\base.html:93
#: .\cookbook\templates\meal_plan.html:4 .\cookbook\templates\meal_plan.html:32
#: .\cookbook\views\delete.py:136 .\cookbook\views\edit.py:286
#: .\cookbook\views\new.py:138
msgid "Meal-Plan"
msgstr "Plan"
#: .\cookbook\models.py:49 .\cookbook\templates\base.html:90
msgid "Books"
msgstr "Bücher"
#: .\cookbook\models.py:210
msgid "Breakfast"
msgstr "Frühstück"
#: .\cookbook\models.py:190
#: .\cookbook\models.py:210
msgid "Lunch"
msgstr "Mittagessen"
#: .\cookbook\models.py:190
#: .\cookbook\models.py:210
msgid "Dinner"
msgstr "Abendessen"
#: .\cookbook\models.py:190
#: .\cookbook\models.py:210
msgid "Other"
msgstr "Andere"
#: .\cookbook\tables.py:83
#: .\cookbook\templates\forms\edit_internal_recipe.html:49
#: .\cookbook\templates\forms\edit_internal_recipe.html:160
#: .\cookbook\templates\forms\edit_internal_recipe.html:50
#: .\cookbook\templates\forms\edit_internal_recipe.html:161
#: .\cookbook\templates\generic\delete_template.html:5
#: .\cookbook\templates\generic\delete_template.html:13
#: .\cookbook\templates\generic\edit_template.html:25
msgid "Delete"
msgstr "Löschen"
#: .\cookbook\templates\base.html:70 .\cookbook\templates\base.html:78
#: .\cookbook\templates\base.html:70 .\cookbook\templates\base.html:79
#: .\cookbook\templates\forms\ingredients.html:7
#: .\cookbook\templates\index.html:7 .\cookbook\templates\shopping_list.html:7
msgid "Cookbook"
msgstr "Kochbuch"
#: .\cookbook\templates\base.html:85
#: .\cookbook\templates\base.html:86
msgid "Utensils"
msgstr "Utensilien"
#: .\cookbook\templates\base.html:89
msgid "Books"
msgstr "Bücher"
#: .\cookbook\templates\base.html:92 .\cookbook\templates\meal_plan.html:4
#: .\cookbook\templates\meal_plan.html:13 .\cookbook\views\delete.py:136
#: .\cookbook\views\edit.py:283 .\cookbook\views\new.py:130
msgid "Meal-Plan"
msgstr "Plan"
#: .\cookbook\templates\base.html:95
#: .\cookbook\templates\base.html:96
msgid "Shopping"
msgstr "Einkaufsliste"
#: .\cookbook\templates\base.html:105
#: .\cookbook\templates\base.html:106
msgid "Tags"
msgstr "Schlagwörter"
#: .\cookbook\templates\base.html:109 .\cookbook\views\delete.py:70
#: .\cookbook\views\edit.py:159 .\cookbook\views\lists.py:18
#: .\cookbook\views\new.py:46
#: .\cookbook\templates\base.html:110 .\cookbook\views\delete.py:70
#: .\cookbook\views\edit.py:162 .\cookbook\views\lists.py:18
#: .\cookbook\views\new.py:47
msgid "Keyword"
msgstr "Schlagwort"
#: .\cookbook\templates\base.html:111
#: .\cookbook\templates\base.html:112
msgid "Batch Edit"
msgstr "Massenbearbeitung"
#: .\cookbook\templates\base.html:116
#: .\cookbook\templates\base.html:117
msgid "Storage Data"
msgstr "Datenquellen"
#: .\cookbook\templates\base.html:120
#: .\cookbook\templates\base.html:121
msgid "Storage Backends"
msgstr "Speicher Quellen"
#: .\cookbook\templates\base.html:122
#: .\cookbook\templates\base.html:123
msgid "Configure Sync"
msgstr "Sync Einstellen"
#: .\cookbook\templates\base.html:124
msgid "Import Recipes"
msgstr "Importierte Rezepte"
#: .\cookbook\templates\base.html:125
msgid "Discovered Recipes"
msgstr "Entdeckte Rezepte"
#: .\cookbook\templates\base.html:126 .\cookbook\views\lists.py:26
msgid "Import Log"
msgstr "Import Log"
#: .\cookbook\templates\base.html:127
msgid "Discovery Log"
msgstr "Entdeckungs Log"
#: .\cookbook\templates\base.html:128 .\cookbook\templates\stats.html:10
#: .\cookbook\templates\base.html:129 .\cookbook\templates\stats.html:10
msgid "Statistics"
msgstr "Statistiken"
#: .\cookbook\templates\base.html:130
#: .\cookbook\templates\base.html:131
msgid "Units & Ingredients"
msgstr "Einheiten & Zutaten"
#: .\cookbook\templates\base.html:145 .\cookbook\templates\settings.html:6
#: .\cookbook\templates\base.html:133
msgid "Import Recipe"
msgstr "Importier Rezept"
#: .\cookbook\templates\base.html:149 .\cookbook\templates\settings.html:6
#: .\cookbook\templates\settings.html:11
msgid "Settings"
msgstr "Einstellungen"
#: .\cookbook\templates\base.html:148
#: .\cookbook\templates\base.html:152
msgid "Admin"
msgstr "Admin"
#: .\cookbook\templates\base.html:152
#: .\cookbook\templates\base.html:156
msgid "Logout"
msgstr "Ausloggen"
#: .\cookbook\templates\base.html:157
#: .\cookbook\templates\base.html:161
#: .\cookbook\templates\registration\login.html:44
msgid "Login"
msgstr "Einloggen"
@@ -253,7 +286,7 @@ msgstr ""
"Ausgewählte Schlagwörter zu allen Rezepten die das Suchwort enthalten "
"hinzufügen"
#: .\cookbook\templates\batch\monitor.html:6 .\cookbook\views\edit.py:143
#: .\cookbook\templates\batch\monitor.html:6 .\cookbook\views\edit.py:146
msgid "Sync"
msgstr "Synchronisieren"
@@ -302,17 +335,45 @@ msgstr "Neues Buch"
msgid "There are no recipes in this book yet."
msgstr "In diesem Buch sind bisher keine Rezepte."
#: .\cookbook\templates\export.html:6
msgid "Export Recipes"
msgstr "Exportier Rezepte"
#: .\cookbook\templates\export.html:19
msgid "Export"
msgstr "Export"
#: .\cookbook\templates\export.html:31
msgid "Exported Recipe"
msgstr "Exportierte Rezepte"
#: .\cookbook\templates\export.html:42
msgid "Copy to clipboard"
msgstr "In Zwischenablage kopieren"
#: .\cookbook\templates\export.html:54
#: .\cookbook\templates\shopping_list.html:48
msgid "Copied!"
msgstr "Kopiert!"
#: .\cookbook\templates\export.html:61
#: .\cookbook\templates\shopping_list.html:37
#: .\cookbook\templates\shopping_list.html:55
msgid "Copy list to clipboard"
msgstr "Kopiere Liste in Zwischenablage"
#: .\cookbook\templates\forms\edit_import_recipe.html:5
#: .\cookbook\templates\forms\edit_import_recipe.html:9
msgid "Import new Recipe"
msgstr "Rezept Importieren"
#: .\cookbook\templates\forms\edit_import_recipe.html:14
#: .\cookbook\templates\forms\edit_internal_recipe.html:47
#: .\cookbook\templates\forms\edit_internal_recipe.html:48
#: .\cookbook\templates\generic\edit_template.html:23
#: .\cookbook\templates\generic\new_template.html:23
#: .\cookbook\templates\recipe_view.html:340
#: .\cookbook\templates\settings.html:33 .\cookbook\templates\settings.html:47
#: .\cookbook\templates\recipe_view.html:357
#: .\cookbook\templates\settings.html:22 .\cookbook\templates\settings.html:28
#: .\cookbook\templates\settings.html:50 .\cookbook\templates\settings.html:64
msgid "Save"
msgstr "Speichern"
@@ -321,7 +382,7 @@ msgstr "Speichern"
msgid "Edit Recipe"
msgstr "Rezept bearbeiten"
#: .\cookbook\templates\forms\edit_internal_recipe.html:37
#: .\cookbook\templates\forms\edit_internal_recipe.html:38
msgid ""
"Use <b>Ctrl</b>+<b>Space</b> to insert new Ingredient!<br/>You can also save "
"the recipe using <b>Ctrl</b>+<b>Shift</b>+<b>S</b>."
@@ -329,36 +390,35 @@ msgstr ""
"Benutze <b>Strg</b>+<b>Leertaste</b> um eine neue Zutat einzufügen!<br/"
">Rezepte können mit<b>Strg</b>+<b>Shift</b>+<b>S</b> gespeichert werden."
#: .\cookbook\templates\forms\edit_internal_recipe.html:51
#: .\cookbook\templates\forms\edit_internal_recipe.html:52
#: .\cookbook\templates\generic\edit_template.html:27
#: .\cookbook\templates\recipe_view.html:7
msgid "View"
msgstr "Angucken"
#: .\cookbook\templates\forms\edit_internal_recipe.html:55
#: .\cookbook\templates\forms\edit_internal_recipe.html:56
#: .\cookbook\templates\generic\edit_template.html:30
msgid "Delete original file"
msgstr "Original löschen"
#: .\cookbook\templates\forms\edit_internal_recipe.html:142
#: .\cookbook\templates\forms\edit_internal_recipe.html:189
#: .\cookbook\views\delete.py:81 .\cookbook\views\edit.py:175
#: .\cookbook\templates\forms\edit_internal_recipe.html:143
#: .\cookbook\templates\forms\edit_internal_recipe.html:190
#: .\cookbook\views\delete.py:81 .\cookbook\views\edit.py:178
msgid "Ingredient"
msgstr "Zutat"
#: .\cookbook\templates\forms\edit_internal_recipe.html:147
#: .\cookbook\templates\forms\edit_internal_recipe.html:148
msgid "Amount"
msgstr "Menge"
#: .\cookbook\templates\forms\edit_internal_recipe.html:149
#: .\cookbook\templates\forms\edit_internal_recipe.html:150
msgid "Unit"
msgstr "Einheit"
#: .\cookbook\templates\forms\edit_internal_recipe.html:154
#: .\cookbook\templates\forms\edit_internal_recipe.html:155
msgid "Note"
msgstr "Notiz "
msgstr "Notiz"
#: .\cookbook\templates\forms\edit_internal_recipe.html:163
#: .\cookbook\templates\forms\edit_internal_recipe.html:164
msgid "Are you sure that you want to delete this ingredient?"
msgstr "Bist du sicher das du diese Zutat löschen willst?"
@@ -404,7 +464,7 @@ msgstr "Bist du sicher diese beiden Zutaten zusammengeführt werden sollen ?"
#: .\cookbook\templates\generic\delete_template.html:18
#, python-format
msgid "Are you sure you want to delete the %(title)s: <b>%(object)s</b> "
msgstr "Bist du sicher das %(title)s: <b>%(object)s</b> gelöscht werden soll"
msgstr "Bist du sicher das %(title)s: <b>%(object)s</b> gelöscht werden soll "
#: .\cookbook\templates\generic\delete_template.html:21
msgid "Confirm"
@@ -441,9 +501,18 @@ msgstr "vorherige"
msgid "next"
msgstr "nächste"
#: .\cookbook\templates\import.html:6
msgid "Import Recipes"
msgstr "Importierte Rezepte"
#: .\cookbook\templates\import.html:14 .\cookbook\views\delete.py:48
#: .\cookbook\views\edit.py:254
msgid "Import"
msgstr "Rezept Importieren"
#: .\cookbook\templates\include\recipe_open_modal.html:28
#: .\cookbook\views\delete.py:21 .\cookbook\views\edit.py:315
#: .\cookbook\views\new.py:34
#: .\cookbook\views\delete.py:21 .\cookbook\views\edit.py:318
#: .\cookbook\views\new.py:35
msgid "Recipe"
msgstr "Rezept"
@@ -501,47 +570,47 @@ msgstr "Suche zurücksetzen"
msgid "Log in to view Recipies"
msgstr "Bitte einloggen um Rezepte zu sehen"
#: .\cookbook\templates\meal_plan.html:20
#: .\cookbook\templates\meal_plan.html:39
msgid "Week"
msgstr "Woche"
#: .\cookbook\templates\recipe_view.html:67
#: .\cookbook\templates\recipe_view.html:71
msgid "in"
msgstr "in"
#: .\cookbook\templates\recipe_view.html:72
#: .\cookbook\templates\recipe_view.html:293
#: .\cookbook\templates\recipe_view.html:76
#: .\cookbook\templates\recipe_view.html:310
msgid "by"
msgstr "von"
#: .\cookbook\templates\recipe_view.html:84
#: .\cookbook\templates\recipe_view.html:89
msgid "Preparation time ca."
msgstr "Zubereitungszeit ca."
#: .\cookbook\templates\recipe_view.html:89
#: .\cookbook\templates\recipe_view.html:95
msgid "Waiting time ca."
msgstr "Wartezeit ca."
#: .\cookbook\templates\recipe_view.html:170
#: .\cookbook\templates\recipe_view.html:186
msgid "Recipe Image"
msgstr "Rezept Bild"
#: .\cookbook\templates\recipe_view.html:193
#: .\cookbook\templates\recipe_view.html:227
#: .\cookbook\templates\recipe_view.html:209
#: .\cookbook\templates\recipe_view.html:243
msgid "View external recipe"
msgstr "Externes Rezept ansehen"
#: .\cookbook\templates\recipe_view.html:205
#: .\cookbook\templates\recipe_view.html:221
msgid "Cloud not show a file preview. Maybe its not a PDF ?"
msgstr ""
"Datei konnte nicht angezeigt werden. Direkte anzeige funktioniert nur mit "
"PDF Dateien."
#: .\cookbook\templates\recipe_view.html:212
#: .\cookbook\templates\recipe_view.html:228
msgid "External recipe"
msgstr "Externes Rezept"
#: .\cookbook\templates\recipe_view.html:214
#: .\cookbook\templates\recipe_view.html:230
msgid ""
"\n"
" This is an external recipe, which means "
@@ -562,16 +631,16 @@ msgstr ""
"bleibt weiterhin verfügbar.\n"
" "
#: .\cookbook\templates\recipe_view.html:225
#: .\cookbook\templates\recipe_view.html:241
msgid "Convert now!"
msgstr "Jetzt umwandeln!"
#: .\cookbook\templates\recipe_view.html:289
#: .\cookbook\templates\recipe_view.html:305
msgid "Comments"
msgstr "Kommentare"
#: .\cookbook\templates\recipe_view.html:309 .\cookbook\views\delete.py:103
#: .\cookbook\views\edit.py:234
#: .\cookbook\templates\recipe_view.html:326 .\cookbook\views\delete.py:103
#: .\cookbook\views\edit.py:237
msgid "Comment"
msgstr "Kommentar"
@@ -580,10 +649,14 @@ msgid "Your username and password didn't match. Please try again."
msgstr "Nutzername oder Passwort falsch. Bitte versuch es erneut."
#: .\cookbook\templates\settings.html:17
msgid "Account"
msgstr "Account"
#: .\cookbook\templates\settings.html:34
msgid "Language"
msgstr "Sprache"
#: .\cookbook\templates\settings.html:42
#: .\cookbook\templates\settings.html:59
msgid "Style"
msgstr "Stil"
@@ -595,15 +668,6 @@ msgstr "Einkaufsliste"
msgid "Load"
msgstr "Laden"
#: .\cookbook\templates\shopping_list.html:37
#: .\cookbook\templates\shopping_list.html:55
msgid "Copy list to clipboard"
msgstr "Kopiere Liste in Zwischenablage"
#: .\cookbook\templates\shopping_list.html:48
msgid "Copied!"
msgstr "Kopiert!"
#: .\cookbook\templates\stats.html:4
msgid "Stats"
msgstr "Statistiken"
@@ -644,22 +708,17 @@ msgstr[0] "Massenbearbeitung erfolgreich. %(count)d Rezept wurde aktualisiert."
msgstr[1] ""
"Massenbearbeitung erfolgreich. %(count)d Rezepte wurden aktualisiert."
#: .\cookbook\views\delete.py:48 .\cookbook\views\edit.py:251
#: .\cookbook\views\lists.py:35
msgid "Import"
msgstr "Rezept Importieren"
#: .\cookbook\views\delete.py:59
msgid "Monitor"
msgstr "Monitor"
#: .\cookbook\views\delete.py:92 .\cookbook\views\lists.py:53
#: .\cookbook\views\new.py:64
#: .\cookbook\views\new.py:65
msgid "Storage Backend"
msgstr "Speicher Quelle"
#: .\cookbook\views\delete.py:114 .\cookbook\views\edit.py:267
#: .\cookbook\views\new.py:112
#: .\cookbook\views\delete.py:114 .\cookbook\views\edit.py:270
#: .\cookbook\views\new.py:114
msgid "Recipe Book"
msgstr "Rezeptbuch"
@@ -667,58 +726,82 @@ msgstr "Rezeptbuch"
msgid "Bookmarks"
msgstr "Lesezeichen"
#: .\cookbook\views\edit.py:117
#: .\cookbook\views\edit.py:104
msgid "There was an error converting your ingredients amount to a number: "
msgstr "Es gab einen Fehler beim umwandeln der Menge in eine Zahl: "
#: .\cookbook\views\edit.py:120
msgid "Recipe saved!"
msgstr "Rezept gespeichert"
msgstr "Rezept gespeichert!"
#: .\cookbook\views\edit.py:119
#: .\cookbook\views\edit.py:122
msgid "There was an error saving this recipe!"
msgstr "Es gab einen Fehler beim Speichern des Rezepts"
msgstr "Es gab einen Fehler beim Speichern des Rezepts!"
#: .\cookbook\views\edit.py:184
#: .\cookbook\views\edit.py:187
msgid "You cannot edit this storage!"
msgstr "Du kannst diese Speicherquelle nicht bearbeiten!"
#: .\cookbook\views\edit.py:203
#: .\cookbook\views\edit.py:206
msgid "Storage saved!"
msgstr "Speicherquelle gespeichert"
msgstr "Speicherquelle gespeichert!"
#: .\cookbook\views\edit.py:205
msgid "There was an error updating this storage backend.!"
msgstr "Es gab einen Fehler beim aktualisierung dieser Speicher Quelle"
#: .\cookbook\views\edit.py:208
msgid "There was an error updating this storage backend!"
msgstr "Es gab einen Fehler beim aktualisierung dieser Speicher Quelle!"
#: .\cookbook\views\edit.py:225
#: .\cookbook\views\edit.py:228
msgid "You cannot edit this comment!"
msgstr "Du kannst diesen Kommentar nicht bearbeiten!"
#: .\cookbook\views\edit.py:303
#: .\cookbook\views\edit.py:306
msgid "Changes saved!"
msgstr "Änderungen gespeichert"
msgstr "Änderungen gespeichert!"
#: .\cookbook\views\edit.py:307
#: .\cookbook\views\edit.py:310
msgid "Error saving changes!"
msgstr "Fehler beim Speichern der Daten."
msgstr "Fehler beim Speichern der Daten!"
#: .\cookbook\views\edit.py:337
#: .\cookbook\views\edit.py:340
msgid "Units merged!"
msgstr "Einheiten zusammengeführt"
msgstr "Einheiten zusammengeführt!"
#: .\cookbook\views\edit.py:350
#: .\cookbook\views\edit.py:353
msgid "Ingredients merged!"
msgstr "Zutaten zusammengeführt"
msgstr "Zutaten zusammengeführt!"
#: .\cookbook\views\new.py:86
#: .\cookbook\views\import_export.py:57
msgid "Recipe imported successfully!"
msgstr "Rezept erfolgreich importiert!"
#: .\cookbook\views\import_export.py:103
msgid ""
"External recipes cannot be exported, please share the file directly or "
"select an internal recipe."
msgstr ""
"Externe Rezepte können nicht exportiert werden, bitte Datei direkt teilen "
"oder ein Internes Rezept auswählen."
#: .\cookbook\views\lists.py:26
msgid "Import Log"
msgstr "Import Log"
#: .\cookbook\views\lists.py:35
msgid "Discovery"
msgstr "Entdeckung"
#: .\cookbook\views\new.py:88
msgid "Imported new recipe!"
msgstr "Importier neue Rezepte"
msgstr "Importier neue Rezepte!"
#: .\cookbook\views\new.py:89
#: .\cookbook\views\new.py:91
msgid "There was an error importing this recipe!"
msgstr "Beim importieren des Rezeptes ist ein Fehler aufgetreten"
msgstr "Beim importieren des Rezeptes ist ein Fehler aufgetreten!"
#: .\cookbook\views\views.py:44
#: .\cookbook\views\views.py:63
msgid "Comment saved!"
msgstr "Kommentar gespeichert"
msgstr "Kommentar gespeichert!"
#: .\cookbook\views\views.py:54
#: .\cookbook\views\views.py:73
msgid "Bookmark saved!"
msgstr "Lesezeichen gespeichert"
msgstr "Lesezeichen gespeichert!"

View File

@@ -0,0 +1,22 @@
# Generated by Django 3.0.5 on 2020-04-26 14:14
from django.db import migrations
def apply_migration(apps, schema_editor):
Group = apps.get_model('auth', 'Group')
Group.objects.bulk_create([
Group(name=u'guest'),
Group(name=u'user'),
Group(name=u'admin'),
])
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0033_userpreference_default_page'),
]
operations = [
migrations.RunPython(apply_migration)
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 3.0.5 on 2020-04-27 14:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0034_auto_20200426_1614'),
]
operations = [
migrations.RenameField(
model_name='mealplan',
old_name='user',
new_name='created_by',
),
migrations.RenameField(
model_name='recipebook',
old_name='user',
new_name='created_by',
),
migrations.AlterField(
model_name='userpreference',
name='default_page',
field=models.CharField(choices=[('SEARCH', 'Search'), ('PLAN', 'Meal-Plan'), ('BOOKS', 'Books')], default='SEARCH', max_length=64),
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 3.0.5 on 2020-04-27 16:00
from django.db import migrations
def apply_migration(apps, schema_editor):
Group = apps.get_model('auth', 'Group')
User = apps.get_model('auth', 'User')
for u in User.objects.all():
if u.groups.count() < 1:
u.groups.add(Group.objects.get(name='admin'))
u.save()
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0035_auto_20200427_1637'),
]
operations = [
migrations.RunPython(apply_migration)
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.0.5 on 2020-05-02 10:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0036_auto_20200427_1800'),
]
operations = [
migrations.AddField(
model_name='userpreference',
name='search_style',
field=models.CharField(choices=[('SMALL', 'Small'), ('LARGE', 'Large')], default='LARGE', max_length=64),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 3.0.5 on 2020-05-02 10:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0037_userpreference_search_style'),
]
operations = [
migrations.AddField(
model_name='recipebook',
name='description',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='recipebook',
name='icon',
field=models.CharField(blank=True, max_length=16, null=True),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 3.0.5 on 2020-05-02 12:04
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('cookbook', '0038_auto_20200502_1259'),
]
operations = [
migrations.AddField(
model_name='recipebook',
name='shared',
field=models.ManyToManyField(blank=True, related_name='shared_with', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 3.0.5 on 2020-05-02 12:33
import annoying.fields
from django.conf import settings
from django.db import migrations
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('cookbook', '0039_recipebook_shared'),
]
operations = [
migrations.AlterField(
model_name='userpreference',
name='user',
field=annoying.fields.AutoOneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 3.0.5 on 2020-05-02 12:46
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0040_auto_20200502_1433'),
]
operations = [
migrations.AddField(
model_name='mealplan',
name='title',
field=models.CharField(blank=True, default='', max_length=64),
),
migrations.AlterField(
model_name='mealplan',
name='recipe',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.Recipe'),
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 3.0.5 on 2020-05-02 14:47
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', '0041_auto_20200502_1446'),
]
operations = [
migrations.CreateModel(
name='CookLog',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('rating', models.IntegerField(null=True)),
('servings', models.IntegerField(default=0)),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.Recipe')),
],
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 3.0.5 on 2020-05-07 21:02
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('cookbook', '0042_cooklog'),
]
operations = [
migrations.AddField(
model_name='mealplan',
name='shared',
field=models.ManyToManyField(blank=True, related_name='plan_share', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='userpreference',
name='plan_share',
field=models.ManyToManyField(blank=True, related_name='plan_share_default', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 3.0.5 on 2020-05-11 10:21
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', '0043_auto_20200507_2302'),
]
operations = [
migrations.CreateModel(
name='ViewLog',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.Recipe')),
],
),
]

View File

@@ -1,5 +1,6 @@
import re
from annoying.fields import AutoOneToOneField
from django.contrib import auth
from django.contrib.auth.models import User
from django.utils.translation import gettext as _
@@ -46,16 +47,24 @@ class UserPreference(models.Model):
PLAN = 'PLAN'
BOOKS = 'BOOKS'
PAGES = ((SEARCH, _('Search')), (PLAN, _('Meal-Plan')), (BOOKS, _('Books')), )
PAGES = ((SEARCH, _('Search')), (PLAN, _('Meal-Plan')), (BOOKS, _('Books')),)
user = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True)
# Search Style
SMALL = 'SMALL'
LARGE = 'LARGE'
SEARCH_STYLE = ((SMALL, _('Small')), (LARGE, _('Large')),)
user = AutoOneToOneField(User, on_delete=models.CASCADE, primary_key=True)
theme = models.CharField(choices=THEMES, max_length=128, default=FLATLY)
nav_color = models.CharField(choices=COLORS, max_length=128, default=PRIMARY)
default_unit = models.CharField(max_length=32, default='g')
default_page = models.CharField(choices=PAGES, max_length=64, default=SEARCH)
search_style = models.CharField(choices=SEARCH_STYLE, max_length=64, default=LARGE)
plan_share = models.ManyToManyField(User, blank=True, related_name='plan_share_default')
def __str__(self):
return self.user
return str(self.user)
class Storage(models.Model):
@@ -154,8 +163,8 @@ class Ingredient(models.Model):
class RecipeIngredient(models.Model):
ingredient = models.ForeignKey(Ingredient, on_delete=models.PROTECT)
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)
note = models.CharField(max_length=64, null=True, blank=True)
@@ -188,7 +197,10 @@ class RecipeImport(models.Model):
class RecipeBook(models.Model):
name = models.CharField(max_length=128)
user = models.ForeignKey(User, on_delete=models.CASCADE)
description = models.TextField(blank=True)
icon = models.CharField(max_length=16, blank=True, null=True)
shared = models.ManyToManyField(User, blank=True, related_name='shared_with')
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
def __str__(self):
return self.name
@@ -209,11 +221,39 @@ class MealPlan(models.Model):
OTHER = 'OTHER'
MEAL_TYPES = ((BREAKFAST, _('Breakfast')), (LUNCH, _('Lunch')), (DINNER, _('Dinner')), (OTHER, _('Other')),)
user = models.ForeignKey(User, on_delete=models.CASCADE)
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
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)
note = models.TextField(blank=True)
date = models.DateField()
def get_label(self):
if self.title:
return self.title
return str(self.recipe)
def get_meal_name(self):
meals = dict(self.MEAL_TYPES)
return meals.get(self.meal)
class CookLog(models.Model):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
rating = models.IntegerField(null=True)
servings = models.IntegerField(default=0)
def __str__(self):
return self.meal + ' (' + str(self.date) + ') ' + str(self.recipe)
return self.recipe.name
class ViewLog(models.Model):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.recipe.name

View File

@@ -0,0 +1,21 @@
/* css classes needed to render markdown blockquotes */
blockquote {
background: #f9f9f9;
border-left: 4px solid #ccc;
margin: 1.5em 10px;
padding: .5em 10px;
quotes: none;
}
blockquote:before {
color: #ccc;
content: open-quote;
font-size: 4em;
line-height: .1em;
margin-right: .25em;
vertical-align: -.4em;
}
blockquote p {
display: inline;
}

View File

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
aria-hidden="true"
focusable="false"
data-prefix="fas"
data-icon="pizza-slice"
class="svg-inline--fa fa-pizza-slice fa-w-16"
role="img"
viewBox="0 0 512 512"
version="1.1"
id="svg4"
sodipodi:docname="recipe_no_image.svg"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)">
<metadata
id="metadata10">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs8" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="3840"
inkscape:window-height="2066"
id="namedview6"
showgrid="false"
inkscape:zoom="0.921875"
inkscape:cx="309.52383"
inkscape:cy="214.71807"
inkscape:window-x="2869"
inkscape:window-y="54"
inkscape:window-maximized="1"
inkscape:current-layer="svg4" />
<rect
style="fill:#f5f5f6;fill-opacity:1;stroke:#d8dde0;stroke-width:3.77952766;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect817"
width="1717.1526"
height="1092.339"
x="-602.57629"
y="-290.16949" />
<path
d="m 198.99508,105.32039 c -9.4835,-0.89523 -18.30973,4.9591 -20.73342,14.20588 l -8.69126,33.14115 c 110.10488,3.23343 184.58794,76.92493 189.24752,186.70242 l 33.41527,-9.29389 c 9.22528,-2.56789 14.95881,-11.59086 13.8614,-21.1439 C 393.84116,202.45866 305.74904,115.43295 198.99508,105.32039 Z m -34.31314,65.96427 -58.59701,223.50695 a 9.5128449,9.5471493 0 0 0 11.73701,11.63209 l 222.4163,-61.9004 C 337.73239,241.51893 268.0087,172.46259 164.68194,171.30821 Z m 16.19707,178.95751 a 18.779213,18.846933 0 1 1 18.77921,-18.84693 18.779213,18.846933 0 0 1 -18.77921,18.84693 z m 28.16882,-89.52293 a 18.779213,18.846933 0 1 1 18.77921,-18.84693 18.779213,18.846933 0 0 1 -18.77921,18.84693 z m 61.03245,61.25253 a 18.779213,18.846933 0 1 1 18.77921,-18.84693 18.779213,18.846933 0 0 1 -18.77921,18.84693 z"
id="path2"
style="fill:#d9cfbe;fill-opacity:1;stroke-width:0.58790755"
inkscape:connector-curvature="0" />
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -6,7 +6,14 @@ from django_tables2.utils import A # alias for Accessor
from .models import *
class RecipeTable(tables.Table):
class ImageUrlColumn(tables.Column):
def render(self, value):
if value.url:
return value.url
return None
class RecipeTableSmall(tables.Table):
id = tables.LinkColumn('edit_recipe', args=[A('id')])
name = tables.LinkColumn('view_recipe', args=[A('id')])
all_tags = tables.Column(
@@ -18,6 +25,19 @@ class RecipeTable(tables.Table):
fields = ('id', 'name', 'all_tags')
class RecipeTable(tables.Table):
edit = tables.TemplateColumn("<a style='color: inherit' href='{% url 'edit_recipe' record.id %}' >" + _('Edit') + "</a>")
name = tables.LinkColumn('view_recipe', args=[A('id')])
all_tags = tables.Column(
attrs={'td': {'class': 'd-none d-lg-table-cell'}, 'th': {'class': 'd-none d-lg-table-cell'}})
image = ImageUrlColumn()
class Meta:
model = Recipe
template_name = 'recipes_table.html'
fields = ('id', 'name', 'all_tags', 'image', 'instructions', 'working_time', 'waiting_time', 'internal')
class KeywordTable(tables.Table):
id = tables.LinkColumn('edit_keyword', args=[A('id')])
@@ -86,3 +106,21 @@ class RecipeImportTable(tables.Table):
model = RecipeImport
template_name = 'generic/table_template.html'
fields = ('id', 'name', 'file_path')
class ViewLogTable(tables.Table):
recipe = tables.LinkColumn('view_recipe', args=[A('recipe_id')])
class Meta:
model = ViewLog
template_name = 'generic/table_template.html'
fields = ('recipe', 'created_at')
class CookLogTable(tables.Table):
recipe = tables.LinkColumn('view_recipe', args=[A('recipe_id')])
class Meta:
model = CookLog
template_name = 'generic/table_template.html'
fields = ('recipe', 'rating', 'serving', 'created_at')

View File

@@ -54,20 +54,10 @@
{% block extra_head %} <!-- block for templates to put stuff into header -->
{% endblock %}
<style>
@media (max-width: 1025px) {
.container {
width: 95% !important;
margin-left: 20px !important;
margin-right: 20px !important;
max-width: 1200px !important;
}
}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-{% nav_color request %}" id="id_main_nav">
<!--<a class="navbar-brand" href="{% url 'index' %}">{% trans 'Cookbook' %}</a>-->
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText"
aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
@@ -75,11 +65,12 @@
<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 %}">
<a class="nav-link" href="{% url 'view_search' %}"><i class="fas fa-book"></i> {% trans 'Cookbook' %}<span
<a class="nav-link" href="{% url 'view_search' %}"><i
class="fas fa-book"></i> {% trans 'Cookbook' %}<span
class="sr-only">(current)</span></a>
</li>
<li class="nav-item dropdown {% if request.resolver_match.url_name in 'view_books,view_plan,view_shopping,list_ingredient' %}active{% endif %}">
<li class="nav-item dropdown {% if request.resolver_match.url_name in 'view_books,view_plan,view_shopping,list_ingredient,view_plan_entry' %}active{% endif %}">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
<i class="fas fa-mortar-pestle"></i> {% trans 'Utensils' %}
@@ -121,13 +112,15 @@
<a class="dropdown-item" href="{% url 'data_sync' %}"><i
class="fas fa-sync-alt fa-fw"></i> {% trans 'Configure Sync' %}</a>
<a class="dropdown-item" href="{% url 'list_recipe_import' %}"><i
class="far fa-file-alt fa-fw"></i> {% trans 'Import Recipes' %}</a>
class="far fa-file-alt fa-fw"></i> {% trans 'Discovered Recipes' %}</a>
<a class="dropdown-item" href="{% url 'list_sync_log' %}"><i
class="fas fa-history fa-fw"></i> {% trans 'Import Log' %}</a>
class="fas fa-history fa-fw"></i> {% trans 'Discovery Log' %}</a>
<a class="dropdown-item" href="{% url 'data_stats' %}"><i
class="fas fa-chart-line fa-fw"></i> {% trans 'Statistics' %}</a>
<a class="dropdown-item" href="{% url 'edit_ingredient' %}"><i
class="fas fa-balance-scale fa-fw"></i> {% trans 'Units & Ingredients' %}</a>
<a class="dropdown-item" href="{% url 'view_import' %}"><i
class="fas fa-file-import"></i> {% trans 'Import Recipe' %}</a>
</div>
</li>
@@ -135,14 +128,16 @@
<ul class="navbar-nav ml-auto">
{% if user.is_authenticated %}
<li class="nav-item dropdown {% if request.resolver_match.url_name in 'view_settings' %}active{% endif %}">
<li class="nav-item dropdown {% if request.resolver_match.url_name in 'view_settings,view_history' %}active{% endif %}">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false"><i class="fas fa-user-alt"></i> {{ user.get_user_name }}
aria-haspopup="true" aria-expanded="false"><i
class="fas fa-user-alt"></i> {{ user.get_user_name }}
</a>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdownMenuLink">
<a class="dropdown-item" href="{% url 'view_settings' %}"><i
class="fas fa-user-cog fa-fw"></i> {% trans 'Settings' %}</a>
<a class="dropdown-item" href="{% url 'view_history' %}"><i class="fas fa-history"></i> {% trans 'History' %}</a>
{% if user.is_superuser %}
<a class="dropdown-item" href="{% url 'admin:index' %}"><i
class="fas fa-user-shield fa-fw"></i> {% trans 'Admin' %}</a>
@@ -164,13 +159,16 @@
<br/>
<div class="container">
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
<div class="row">
<div class="col col-md-12">
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
</div>
</div>
{% endfor %}

View File

@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% load custom_tags %}
{% load i18n %}
{% block title %}{% trans 'Recipe Books' %}{% endblock %}
@@ -16,46 +17,65 @@
</div>
<br/>
<br/>
{% for b in book_list %}
<div class="row">
<div class="col col-md-10">
<a data-toggle="collapse" href="#collapse_{{ b.book.pk }}" role="button" aria-expanded="false"
aria-controls="collapse_{{ b.book.pk }}"><h4>{{ b.book.name }}</h4></a>
</div>
<div class="col col-md-2" style="text-align: right">
<h4>
<div class="col-12">
<div class="card" style="margin-top: 2px">
<div class="card-body">
<h5 class="card-title">{% if b.book.icon %}{{ b.book.icon }} {% endif %}{{ b.book.name }}</h5>
<h6 class="card-subtitle mb-2 text-muted">{% if b.book.created_by != request.user %}
{% trans 'by' %} {{ b.book.created_by.get_user_name }}
{% endif %}</h6>
<a href="{% url 'edit_recipe_book' b.book.pk %}"> <i class="fas fa-pencil-alt"></i></a>
<a href="{% url 'delete_recipe_book' b.book.pk %}"><i class="fas fa-trash-alt"></i></a>
</h4>
</div>
<hr/>
</div>
{% if b.book.description %}
<p class="card-text">{{ b.book.description }}</p>
{% endif %}
<a data-toggle="collapse" href="#collapse_{{ b.book.pk }}" role="button" aria-expanded="false"
aria-controls="collapse_{{ b.book.pk }}" class="card-link">{% trans 'Toggle Recipes' %}</a>
{% if b.book.created_by == request.user or request.user.is_superuser %}
<a href="{% url 'edit_recipe_book' b.book.pk %}" class="card-link">{% trans 'Edit' %}</a>
<a href="{% url 'delete_recipe_book' b.book.pk %}"
class="card-link">{% trans 'Delete' %}</a>
{% endif %}
</div>
<div class="collapse" id="collapse_{{ b.book.pk }}">
{% if b.recipes %}
<ul class="list-group list-group-flush">
{% for r in b.recipes %}
<li class="list-group-item">
<div class="row">
<div class="col-10">
{% recipe_last r.recipe request.user as last_cooked %}
<a href="{% url 'view_recipe' r.recipe.pk %}">{{ r.recipe.name }}</a>
{% recipe_rating r.recipe request.user as rating %}
{{ rating|safe }}
{% if last_cooked %}
&nbsp;
<span class="badge badge-primary">{% trans 'Last cooked' %} {{ last_cooked|date }}</span>
{% endif %}
<div class="row">
<div class="col col-md-12">
<div class="collapse" id="collapse_{{ b.book.pk }}">
{% if b.recipes %}
<ul>
{% for r in b.recipes %}
<div class="row">
<div class="col col-md-10">
<li><a href="{% url 'view_recipe' r.recipe.pk %}">{{ r.recipe.name }}</a></li>
</div>
<div class="col col-md-2" style="text-align: right">
<a href="{% url 'delete_recipe_book_entry' r.pk %}"><i class="fas fa-trash-alt"></i></a>
</div>
</div>
{% endfor %}
</ul>
{% else %}
{% trans 'There are no recipes in this book yet.' %}
{% endif %}
</div>
{% if b.book.created_by == request.user or request.user.is_superuser %}
<div class="col-2" style="text-align: right">
<a href="{% url 'delete_recipe_book_entry' r.pk %}"
class="pull-right"><i class="fas fa-trash-alt"></i></a>
</div>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="card-body">
<p>
{% trans 'There are no recipes in this book yet.' %}
</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<br/>
{% endfor %}
{% endblock %}

View File

@@ -0,0 +1,70 @@
{% extends "base.html" %}
{% load i18n %}
{% load crispy_forms_filters %}
{% load static %}
{% block title %}{% trans 'Export Recipes' %}{% endblock %}
{% block extra_head %}
{{ form.media }}
{% endblock %}
{% block content %}
<div class="row">
<div class="col col-md-12">
<form action="." method="post">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-success" type="submit"><i class="fas fa-file-export"></i> {% trans 'Export' %}
</button>
</form>
</div>
</div>
{% if export %}
<br/>
<div class="row">
<div class="col col-md-12">
<label for="id_export">
{% trans 'Exported Recipe' %}</label>
<textarea id="id_export" class="form-control" rows="12">
{{ export }}
</textarea>
</div>
</div>
<br/>
<div class="row">
<div class="col col-md-12 text-center">
<button class="btn btn-success" onclick="copy()" style="width: 15vw" data-toggle="tooltip"
data-placement="right" title="{% trans 'Copy to clipboard' %}" id="id_btn_copy"
onmouseout="resetTooltip()"><i
class="far fa-copy"></i></button>
</div>
</div>
<script type="text/javascript">
function copy() {
let json = $('#id_export');
json.select();
$('#id_btn_copy').attr('data-original-title', '{% trans 'Copied!' %}').tooltip('show');
document.execCommand("copy");
}
function resetTooltip() {
setTimeout(function () {
$('#id_btn_copy').attr('data-original-title', '{% trans 'Copy list to clipboard' %}');
}, 300);
}
$(function () {
$('[data-toggle="tooltip"]').tooltip()
})
</script>
{% endif %}
{% endblock %}

View File

@@ -32,6 +32,8 @@
<div class="table-controls" style="text-align: center">
<button class="btn btn-success" id="new_empty" type="button" style="min-width: 20vw"><i
class="fas fa-plus-circle"></i></button>
<button class="btn btn-warning" id="new_header" type="button" data-toggle="tooltip"
data-placement="top" title="{% trans 'Insert a header between the ingredients.' %}"><i class="fas fa-heading"></i></button>
<button type="button" class="btn btn-secondary" data-container="body" data-toggle="popover"
data-placement="right" data-html="true" data-trigger="focus"
@@ -57,7 +59,7 @@
{% endif %}
</form>
<script>
<script type="application/javascript">
$(function () {
$('[data-toggle="popover"]').popover()
@@ -199,6 +201,20 @@
input.select();
}
function addHeaderRow(type) {
data.push({
ingredient__name: '{% trans 'Header' %}',
amount: "0",
unit__name: "Special:Header",
note: "{% trans 'write header here' %}",
id: Math.floor(Math.random() * 10000000),
delete: false,
});
input = table.rowManager.rows[((table.rowManager.rows).length) - 1].cells[4].getElement()
input.focus();
input.select();
}
document.onkeyup = function (e) {
if (e.shiftKey && e.ctrlKey && (e.which === 83 || e.keyCode === 83)) {
$('#id_form').submit()
@@ -208,7 +224,12 @@
};
document.getElementById("new_empty").addEventListener("click", addIngredientRow);
document.getElementById("new_header").addEventListener("click", addHeaderRow);
});
$(function () {
$('[data-toggle="tooltip"]').tooltip()
})
</script>
{% endblock %}

View File

@@ -66,7 +66,7 @@
{% block pagination %}
{% if table.page and table.paginator.num_pages > 1 %}
<nav aria-label="Table navigation">
<ul class="pagination justify-content-center">
<ul class="pagination justify-content-center flex-wrap">
{% if table.page.has_previous %}
{% block pagination.previous %}
<li class="previous page-item">

View File

@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% load django_tables2 %}
{% load static %}
{% load i18n %}
{% block title %}{% trans "History" %}{% endblock %}
{% block extra_head %}
{% endblock %}
{% block content %}
<h3>{% trans 'History' %}</h3>
<br/>
<ul class="nav nav-tabs" id="id_tab_nav" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="home-tab" data-toggle="tab" href="#view_log" role="tab" aria-controls="view_log"
aria-selected="true">{% trans 'View Log' %}</a>
</li>
<li class="nav-item">
<a class="nav-link" id="profile-tab" data-toggle="tab" href="#cook_log" role="tab" aria-controls="cook_log"
aria-selected="false">{% trans 'Cook Log' %}</a>
</li>
</ul>
<div class="tab-content" id="id_tab_content">
<div class="tab-pane fade show active" id="view_log" role="tabpanel" aria-labelledby="view-log">
{% render_table view_log %}
</div>
<div class="tab-pane fade" id="cook_log" role="tabpanel" aria-labelledby="profile-tab">
{% render_table cook_log %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% load i18n %}
{% load crispy_forms_tags %}
{% load static %}
{% block title %}{% trans 'Import Recipes' %}{% endblock %}
{% block content %}
<div class="row">
<div class="col col-md-12">
<form action="." method="post">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-success" type="submit"><i class="fas fa-file-import"></i> {% trans 'Import' %}
</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,78 @@
{% load i18n %}
<div class="modal" tabindex="-1" role="dialog" id="id_modal_cook_log">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{% trans 'Log Recipe Cooking' %}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>{% trans 'All fields are optional and can be left empty.' %}</p>
<form>
<label for="id_log_servings">{% trans 'Servings' %} </label>
<input class="form-control" type="number" id="id_log_servings">
<br/>
<label for="id_log_rating">{% trans 'Rating' %} - <span id="id_rating_show">0/5</span></label>
<input type="range" class="custom-range" min="0" max="5" id="id_log_rating" name="log_rating"
value="0">
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">{% trans 'Close' %}</button>
<button type="button" class="btn btn-primary" onclick="logCook()">{% trans 'Save' %}</button>
</div>
</div>
</div>
</div>
<script type="application/javascript">
let modal = $('#id_modal_cook_log')
let rating = $('#id_log_rating')
function openCookLogModal(id) {
modal.data('recipe_id', id)
modal.modal('show')
}
//TODO there is definitely a nicer way to do this than this ugly shit
function logCook() {
let id = modal.data('recipe_id');
let url = "{% url 'api_log_cooking' recipe_id=12345 %}".replace(/12345/, id);
let val_servings = $('#id_log_servings').val()
if (val_servings !== '' && val_servings !== 0) {
url += '?s=' + val_servings
}
let val_rating = rating.val()
if (val_rating !== '' && val_rating !== 0) {
if (val_servings !== '' && val_servings !== 0) {
url += '&'
}else {
url += '?'
}
url += 'r=' + val_rating
}
let request = new XMLHttpRequest();
request.onreadystatechange = function () {
};
request.open("GET", url, true);
request.send();
modal.modal('hide')
}
rating.on("input", () => {
$('#id_rating_show').html(rating.val() + '/5')
});
</script>

View File

@@ -18,59 +18,71 @@
{% block content %}
{% if filter %}
<form action="" method="get" id="search_form">
{% csrf_token %}
{{ form.non_field_errors }}
<div class="row">
<div class="col md-12">
<div class="input-group">
<input type="text" class="form-control" placeholder="{% trans 'Search recipe ...' %}"
id="{{ filter.form.name.id_for_label }}" name="{{ filter.form.name.name }}"
aria-describedby="button-addon4">
<div class="row">
<div class="col">
<form action="" method="get" id="search_form">
{% csrf_token %}
{{ form.non_field_errors }}
<div class="row">
<div class="col md-12">
<div class="input-group">
<input type="text" class="form-control" placeholder="{% trans 'Search recipe ...' %}"
id="{{ filter.form.name.id_for_label }}" name="{{ filter.form.name.name }}"
aria-describedby="button-addon4">
<div class="input-group-append">
<button class="btn btn-primary" type="submit"><i class="fas fa-search"></i></button>
<button type="button" class="btn btn-light dropdown-toggle dropdown-toggle-split dropdown-toggle-no-arrow"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fas fa-ellipsis-v"></i>
<span class="sr-only">Toggle Dropdown</span>
</button>
<div class="dropdown-menu">
<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 data-toggle="collapse" href="#collapse_adv_search"
role="button" class="dropdown-item"
aria-expanded="false" type="button"
aria-controls="collapse_adv_search"><i
class="fas fa-search-plus fa-fw"></i> {% trans 'Advanced Search' %}
</button>
<button class="dropdown-item" type="button"
onclick="window.location = window.location.pathname;"><i
class="fas fa-sync fa-fw"></i> {% trans 'Reset Search' %}</button>
<div class="input-group-append">
<button class="btn btn-primary" type="submit"><i class="fas fa-search"></i></button>
<button type="button"
class="btn btn-light dropdown-toggle dropdown-toggle-split dropdown-toggle-no-arrow"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fas fa-ellipsis-v"></i>
<span class="sr-only">Toggle Dropdown</span>
</button>
<div class="dropdown-menu">
<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 data-toggle="collapse" href="#collapse_adv_search"
role="button" class="dropdown-item"
aria-expanded="false" type="button"
aria-controls="collapse_adv_search"><i
class="fas fa-search-plus fa-fw"></i> {% trans 'Advanced Search' %}
</button>
<button class="dropdown-item" type="button"
onclick="window.location = window.location.pathname;"><i
class="fas fa-sync fa-fw"></i> {% trans 'Reset Search' %}</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="collapse col-md-12" id="collapse_adv_search">
<div style="margin-top: 1vh">
{{ filter.form.keywords | as_crispy_field }}
<div class="row">
<div class="collapse col-md-12" id="collapse_adv_search">
<div style="margin-top: 1vh">
{{ filter.form.keywords | as_crispy_field }}
</div>
<div>
{{ filter.form.ingredients | as_crispy_field }}
</div>
<div>
{{ filter.form.internal | as_crispy_field }}
</div>
</div>
</div>
<div>
{{ filter.form.ingredients | as_crispy_field }}
</div>
<div>
{{ filter.form.internal | as_crispy_field }}
</div>
</div>
</form>
</div>
</form>
</div>
{% endif %}
<br/>
{% if last_viewed %}
<h4>{% trans 'Last viewed' %}</h4>
{% render_table last_viewed %}
<h4>{% trans 'Recipes' %}</h4>
{% endif %}
{% if user.is_authenticated and recipes %}
{% render_table recipes %}
{% else %}
@@ -79,4 +91,5 @@
</div>
{% endif %}
{% include 'include/log_cooking.html' %}
{% endblock %}

View File

@@ -0,0 +1,189 @@
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% block title %}{% trans "Markdown Info" %}{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{% static 'custom/css/markdown_blockquote.css' %}">
{% endblock %}
{% block content %}
<h1>{% trans 'Markdown Info' %}</h1>
{% blocktrans %}
Markdown is lightweight markup language that can be used to format plain text easily.
This site uses the <a href="https://python-markdown.github.io/" target="_blank">Python Markdown</a> library to
convert your text into nice looking html. Its full markdown documentation can be found
<a href="https://daringfireball.net/projects/markdown/syntax" target="_blank">here</a>.
An incomplete but most likely sufficient documentation can be found below.
{% endblocktrans %}
<br/>
<br/>
<h2>{% trans 'Headers' %}</h2>
<pre class="intro-code code-block"><code>
# Header 1
## Header 2
### Header 3
#### Header 4
##### Header 5
###### Header 6
</code></pre>
<div style="text-align: center">
<i class="fas fa-arrow-down fa-2x"></i>
<br/>
<br/>
</div>
<div class="card">
<div class="card-body">
<h1>Header 1</h1>
<h2>Header 2</h2>
<h3>Header 3</h3>
<h4>Header 4</h4>
<h5>Header 5</h5>
<h6>Header 6</h6>
</div>
</div>
<br/>
<h2>{% trans 'Formatting' %}</h2>
<pre class="intro-code code-block"><code>
{% trans 'Line breaks are inserted by adding two spaces after the end of a line' %}
{% trans 'or by leaving a blank line inbetween.' %}
**{% trans 'This text is bold' %}**
*{% trans 'This text is in italics' %}*
> {% trans 'Blockquotes are also possible' %}
</code></pre>
<div style="text-align: center">
<i class="fas fa-arrow-down fa-2x"></i>
<br/>
<br/>
</div>
<div class="card">
<div class="card-body">
{% trans 'Line breaks are inserted by adding two spaces after the end of a line' %}<br/>
{% trans 'or by leaving a blank line inbetween.' %}<br/><br/>
<b>{% trans 'This text is bold' %}</b><br/>
<i>{% trans 'This text is in italics' %}</i>
<blockquote>
<p>{% trans 'Blockquotes are also possible' %}</p>
</blockquote>
</div>
</div>
<br/>
<h2>{% trans 'Lists' %}</h2>
{% trans 'Lists can ordered or unorderd. It is <b>important to leave a blank line before the list!</b>' %}
<pre class="intro-code code-block"><code>
{% trans 'Ordered List' %}
- {% trans 'unordered list item' %}
- {% trans 'unordered list item' %}
- {% trans 'unordered list item' %}
{% trans 'Unordered List' %}
1. {% trans 'ordered list item' %}
2. {% trans 'ordered list item' %}
3. {% trans 'ordered list item' %}
</code></pre>
<div style="text-align: center">
<i class="fas fa-arrow-down fa-2x"></i>
<br/>
<br/>
</div>
<div class="card">
<div class="card-body">
{% trans 'Ordered List' %}
<ul>
<li>{% trans 'unordered list item' %}</li>
<li>{% trans 'unordered list item' %}</li>
<li>{% trans 'unordered list item' %}</li>
</ul>
{% trans 'Unordered List' %}
<ol>
<li>{% trans 'ordered list item' %}</li>
<li>{% trans 'ordered list item' %}</li>
<li>{% trans 'ordered list item' %}</li>
</ol>
</div>
</div>
<br/>
<h2>{% trans 'Images & Links' %}</h2>
{% trans 'Links can be formatted with Markdown. This applicaiton also allows to paste links directly into markdown fields without any formatting.' %}
<pre class="intro-code code-block"><code>
https://github.com/vabene1111/recipes
[](https://github.com/vabene1111/recipes)
[GitHub](https://github.com/vabene1111/recipes)
![{% trans 'This will become and Image' %}]({% static 'favicon.png' %})
</code></pre>
<div style="text-align: center">
<i class="fas fa-arrow-down fa-2x"></i>
<br/>
<br/>
</div>
<div class="card">
<div class="card-body">
<a href="https://github.com/vabene1111/recipes">https://github.com/vabene1111/recipes</a> <br/>
<a href="https://github.com/vabene1111/recipes">GitHub</a> <br/>
<img src="{% static 'favicon.png' %}" class="img-fluid" alt="{% trans 'This will become and Image' %}"
style="height: 3vw">
</div>
</div>
<br/>
<h2>{% trans 'Tables' %}</h2>
{% trans 'Markdown tables are hard to create by hand. It is recommended to use a table editor like <a href="https://www.tablesgenerator.com/markdown_tables" target="_blank">this</a> one.' %}
<pre class="intro-code code-block"><code>
| {% trans 'Table' %} | {% trans 'Header' %} |
|--------|---------|
| {% trans 'Table' %} | {% trans 'Cell' %} |
</code></pre>
<div style="text-align: center">
<i class="fas fa-arrow-down fa-2x"></i>
<br/>
<br/>
</div>
<div class="card">
<div class="card-body">
<table class="table table-bordered">
<thead>
<tr>
<th>{% trans 'Table' %}</th>
<th>{% trans 'Header' %}</th>
</tr>
</thead>
<tbody>
<tr>
<td>{% trans 'Table' %}</td>
<td>{% trans 'Cell' %}</td>
</tr>
</tbody>
</table>
</div>
</div>
<br/>
<br/>
{% endblock %}

View File

@@ -9,19 +9,19 @@
{% block content %}
<style>
.mealplan-cell .mealplan-add-button{
.mealplan-cell .mealplan-add-button {
text-align: center;
display: block;
}
@media (hover: hover) {
.mealplan-cell .mealplan-add-button{
.mealplan-cell .mealplan-add-button {
visibility: hidden;
float: right;
display: inline;
}
.mealplan-cell:hover .mealplan-add-button{
.mealplan-cell:hover .mealplan-add-button {
visibility: initial;
}
}
@@ -73,10 +73,17 @@
<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>
<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 'edit_meal_plan' mp.pk %}"><i class="fas fa-edit"></i></a>
<a href="{% url 'view_recipe' mp.recipe.id %}">{{ mp.recipe.name }}</a><br/>
<a href="{% url 'view_plan_entry' mp.pk %}">
{% if mp.recipe %}
{{ mp.recipe }}
{% else %}
{{ mp.title }}
{% endif %}
</a><br/>
{% endfor %}
</td>
{% endfor %}

View File

@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% load static %}
{% load custom_tags %}
{% load i18n %}
{% block title %}{% trans 'Meal Plan View' %}{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{% static 'custom/css/markdown_blockquote.css' %}">
{% endblock %}
{% 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 %}"
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>
{% if plan.shared.all %}
<br/><small class="text-muted">{% trans 'Shared with' %}
{% for x in plan.shared.all %}{{ x.get_user_name }}{% if not forloop.last %}, {% endif %} {% endfor %}</small>
{% endif %}
</div>
</div>
<br/>
<br/>
{% if plan.title %}
<div class="row">
<div class="col col-12">
<h4>{{ plan.title }}</h4>
</div>
</div>
{% endif %}
{% if plan.recipe %}
<div class="row">
<div class="col col-12">
<div class="card">
<div class="card-body">
{% recipe_rating plan.recipe request.user as rating %}
<h5 class="card-title"><a
href="{% url 'view_recipe' plan.recipe.pk %}">{{ plan.recipe }}</a> {{ rating|safe }}
</h5>
{% recipe_last plan.recipe request.user as last_cooked %}
{% if last_cooked %}
{% trans 'Last cooked' %} {{ last_cooked|date }}
{% else %}
{% trans 'Never cooked before.' %}
{% endif %}
{% if plan.recipe.keywords %}
<br/>
<br/>
{{ plan.recipe.all_tags }}
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
{% if plan.note %}
<br/>
<div class="row">
<div class="col col-12">
{{ plan.note | markdown | safe }}
</div>
</div>
{% endif %}
{% if same_day_plan %}
<br/>
<h4>{% trans 'Other meals on this day' %}</h4>
<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>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% load static %}
{% load crispy_forms_tags %}
{% load i18n %}
{% load l10n %}
@@ -10,9 +11,17 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pretty-checkbox@3.0/dist/pretty-checkbox.min.css"
integrity="sha384-ICB8i/maQ/5+tGLDUEcswB7Ch+OO9Oj8Z4Ov/Gs0gxqfTgLLkD3F43MhcEJ2x6/D" crossorigin="anonymous">
<link rel="stylesheet" href="{% static 'custom/css/markdown_blockquote.css' %}">
<!-- prevent weired character stuff escaping the pdf box -->
<style>
/* fixes print layout being disturbed by print button tooltip */
@media print {
.tooltip {
display: none;
}
}
/* prevent weired character stuff escaping the pdf box */
.textLayer > span {
color: transparent;
position: absolute;
@@ -20,48 +29,39 @@
cursor: text;
transform-origin: 0% 0%;
}
blockquote {
background: #f9f9f9;
border-left: 4px solid #ccc;
margin: 1.5em 10px;
padding: .5em 10px;
quotes: none;
}
blockquote:before {
color: #ccc;
content: open-quote;
font-size: 4em;
line-height: .1em;
margin-right: .25em;
vertical-align: -.4em;
}
blockquote p {
display: inline;
}
</style>
{% endblock %}
{% block content %}
<div class="row">
<div class="col col-md-9">
<h3>{{ recipe.name }} <a href="{% url 'edit_recipe' recipe.pk %}" class="d-print-none"><i
<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-3 d-print-none" style="text-align: right">
<button class="btn btn-success" onclick="$('#bookmarkModal').modal({'show':true})"><i
<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-warning" href="{% url 'view_shopping' %}?r={{ recipe.pk }}"><i
<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 }}"><i
<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>
<a class="btn btn-light" onclick="window.print()"><i
<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>
</div>
</div>
@@ -92,8 +92,12 @@
class="badge badge-secondary"><i
class="far fa-clock"></i> {% trans 'Waiting time ca.' %} {{ recipe.waiting_time }} min </span>
{% endif %}
{% recipe_last recipe request.user as last_cooked %}
{% if last_cooked %}
<span class="badge badge-primary">{% trans 'Last cooked' %} {{ last_cooked|date }}</span>
{% endif %}
{% if recipe.waiting_time and recipe.waiting_time != 0 or recipe.working_time and recipe.working_time != 0 %}
{% if recipe.waiting_time and recipe.waiting_time != 0 or recipe.working_time and recipe.working_time != 0 or last_cooked %}
<br/>
<br/>
{% endif %}
@@ -121,48 +125,61 @@
<br/>
<table class="table table-sm">
{% for i in ingredients %}
<tr>
<td style="vertical-align: middle!important;">
<div class="pretty p-default p-curve">
<input type="checkbox"/>
<div class="state p-success">
<label>
{% if i.amount != 0 %}
<span id="ing_{{ i.pk }}">{{ i.amount.normalize }}</span>
{{ i.unit }}
{% else %}
<span>&#x2063;</span>
{% endif %}
</label>
</div>
</div>
{% if i.unit.name == 'Special:Header' %}
<tr>
<td style="padding-top: 8px!important; ">
<b>{{ i.note }}</b>
</td>
<td>
</td>
<td style="vertical-align: middle!important;">
{% if i.ingredient.recipe %}
<a href="{% url 'view_recipe' i.ingredient.recipe.pk %}" target="_blank">
{% endif %}
{{ i.ingredient.name }}
{% if i.ingredient.recipe %}
</a>
{% endif %}
</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 }}">
<i class="fas fa-info"></i>
</button>
<div class="d-none d-print-block">
<i class="far fa-comment-alt"></i> {{ i.note }}
</td>
<td></td>
</tr>
{% else %}
<tr>
<td style="vertical-align: middle!important;">
<div class="pretty p-default p-curve">
<input type="checkbox"/>
<div class="state p-success">
<label>
{% if i.amount != 0 %}
<span id="ing_{{ i.pk }}">{{ i.amount.normalize }}</span>
{{ i.unit }}
{% else %}
<span>&#x2063;</span>
{% endif %}
</label>
</div>
</div>
{% endif %}
</td>
</tr>
</td>
<td style="vertical-align: middle!important;">
{% if i.ingredient.recipe %}
<a href="{% url 'view_recipe' i.ingredient.recipe.pk %}"
target="_blank">
{% endif %}
{{ i.ingredient.name }}
{% if i.ingredient.recipe %}
</a>
{% endif %}
</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 }}">
<i class="fas fa-info"></i>
</button>
<div class="d-none d-print-block">
<i class="far fa-comment-alt"></i> {{ i.note }}
</div>
{% endif %}
</td>
</tr>
{% endif %}
{% endfor %}
<!-- Bottom border -->
<tr>
@@ -194,7 +211,7 @@
<div style="font-size: large">
{% if recipe.instructions %}
{{ recipe.instructions | markdown | safe | urlize }}
{{ recipe.instructions | markdown | safe }}
{% endif %}
</div>
@@ -359,6 +376,8 @@
</div>
</div>
{% include 'include/log_cooking.html' %}
<script type="text/javascript">
$(function () {
@@ -369,6 +388,10 @@
trigger: 'focus'
});
function roundToTwo(num) {
return +(Math.round(num + "e+2") + "e-2");
}
function reloadIngredients() {
factor = Number($('#in_factor').val());
ingredients = {
@@ -378,9 +401,13 @@
}
for (var key in ingredients) {
$('#ing_' + key).html(Math.round(ingredients[key] * factor))
$('#ing_' + key).html(roundToTwo(ingredients[key] * factor))
}
}
$(function () {
$('[data-toggle="tooltip"]').tooltip()
})
</script>
{% endblock %}

View File

@@ -0,0 +1,114 @@
{% load crispy_forms_tags %}
{% load i18n %}
{% load django_tables2 %}
{% load static %}
{% load custom_tags %}
{% block content %}
<div class="row">
<div class="col">
<div class="table-container">
{% block table %}
<table {% render_attrs table.attrs class="table" %}>
{% for row in table.paginated_rows %}
<div class="card" style="margin-top: 2px;">
<div class="row no-gutters">
<div class="col-md-4">
{% if row.cells.image|length > 1 %}
<img src=" {{ row.cells.image }}" alt="{% trans 'Recipe Image' %}"
class="card-img" style="object-fit: cover;height: 130px">
{% else %}
<img src="{% static 'recipe_no_image.svg' %}"
alt="{% trans 'Recipe Image' %}"
class="card-img d-none d-lg-block"
style="object-fit: inherit; height: 130px">
{% endif %}
</div>
<div class="col-md-8">
<div class="card-body">
<h5 class="card-title">{{ row.cells.name }}
{% recipe_rating row.record request.user as rating %}
{{ rating|safe }}
</h5>
<p class="card-text{% if not row.record.all_tags %} d-none d-lg-block{% endif %}">
{{ row.cells.all_tags }}
</p>
<p class="card-text"><small class="text-muted">
{% if row.cells.working_time != 0 %}
<span class="badge badge-secondary"><i
class="fas fa-user-clock"></i> {% trans 'Preparation time ca.' %} {{ row.cells.working_time }} min </span>
{% endif %}
{% if row.cells.waiting_time != 0 %}
<span
class="badge badge-secondary"><i
class="far fa-clock"></i> {% trans 'Waiting time ca.' %} {{ row.cells.waiting_time }} min </span>
{% endif %}
{% if not row.record.internal %}
<span class="badge badge-info">{% trans 'External' %} </span>
{% endif %}
{% recipe_last row.record request.user as last_cooked %}
{% if last_cooked %}
<span class="badge badge-primary">{% trans 'Last cooked' %} {{ last_cooked|date }}</span>
{% endif %}
<span class="badge badge-light">{{ row.cells.edit }}</span>
<span class="badge badge-warning"><a href="#" style="color: inherit"
onclick="openCookLogModal({{ row.record.pk }})">{% trans 'Log' %}</a></span>
</small></p>
</div>
</div>
</div>
</div>
{% endfor %}
</table>
{% endblock table %}
</div>
</div>
</div>
{% block pagination %}
{% if table.page and table.paginator.num_pages > 1 %}
<nav aria-label="Table navigation">
<ul class="pagination justify-content-center flex-wrap">
{% if table.page.has_previous %}
{% block pagination.previous %}
<li class="previous page-item">
<a href="{% querystring table.prefixed_page_field=table.page.previous_page_number %}"
class="page-link">
<span aria-hidden="true">&laquo;</span>
{% trans 'previous' %}
</a>
</li>
{% endblock pagination.previous %}
{% endif %}
{% if table.page.has_previous or table.page.has_next %}
{% block pagination.range %}
{% for p in table.page|table_page_range:table.paginator %}
<li class="page-item{% if table.page.number == p %} active{% endif %}">
<a class="page-link"
{% if p != '...' %}href="{% querystring table.prefixed_page_field=p %}"{% endif %}>
{{ p }}
</a>
</li>
{% endfor %}
{% endblock pagination.range %}
{% endif %}
{% if table.page.has_next %}
{% block pagination.next %}
<li class="next page-item">
<a href="{% querystring table.prefixed_page_field=table.page.next_page_number %}"
class="page-link">
{% trans 'next' %}
<span aria-hidden="true">&raquo;</span>
</a>
</li>
{% endblock pagination.next %}
{% endif %}
</ul>
</nav>
{% endif %}
{% endblock pagination %}
{% endblock content %}

View File

@@ -5,6 +5,11 @@
{% block title %}{% trans 'Settings' %}{% endblock %}
{% block extra_head %}
{{ preference_form.media }}
{% endblock %}
{% block content %}
<h3>

View File

@@ -34,7 +34,7 @@
<div class="row">
<div class="col col-md-12 text-center">
<button class="btn btn-success" onclick="copy()" style="width: 15vw" data-toggle="tooltip"
data-placement="top" title="{% trans 'Copy list to clipboard' %}" id="id_btn_copy" onmouseout="resetTooltip()"><i
data-placement="right" title="{% trans 'Copy list to clipboard' %}" id="id_btn_copy" onmouseout="resetTooltip()"><i
class="far fa-copy"></i></button>
</div>
</div>

View File

@@ -5,10 +5,12 @@
{% block content %}
<div class="row">
<div class="col col-12">
<h3>{% trans 'Statistics' %} </h3>
</div>
</div>
<h3>
{% trans 'Statistics' %}
</h3>
<div class="row">
<div class="col-md-6">
@@ -21,6 +23,10 @@
class="badge badge-pill badge-info">{{ counts.recipes }}</span></li>
<li class="list-group-item">{% trans 'Keywords' %} : <span
class="badge badge-pill badge-info">{{ counts.keywords }}</span></li>
<li class="list-group-item">{% trans 'Units' %} : <span
class="badge badge-pill badge-info">{{ counts.units }}</span></li>
<li class="list-group-item">{% trans 'Ingredients' %} : <span
class="badge badge-pill badge-info">{{ counts.ingredients }}</span></li>
<li class="list-group-item">{% trans 'Recipe Imports' %} : <span
class="badge badge-pill badge-info">{{ counts.recipe_import }}</span></li>
</ul>
@@ -33,8 +39,13 @@
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">{% trans 'Recipes without Keywords' %} : <span
class="badge badge-pill badge-info">{{ counts.recipes_no_keyword}}</span></li>
class="badge badge-pill badge-info">{{ counts.recipes_no_keyword }}</span></li>
<li class="list-group-item">{% trans 'External Recipes' %} : <span
class="badge badge-pill badge-info">{{ counts.recipes_external }}</span></li>
<li class="list-group-item">{% trans 'Internal Recipes' %} : <span
class="badge badge-pill badge-info">{{ counts.recipes_internal }}</span></li>
<li class="list-group-item">{% trans 'Comments' %} : <span
class="badge badge-pill badge-info">{{ counts.comments }}</span></li>
</ul>
</div>
</div>

View File

@@ -1,24 +1,224 @@
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% load theming_tags %}
{% load django_tables2 %}
{% load crispy_forms_tags %}
{% block content %}
<form>
<div class="form-group">
<label for="exampleInputEmail1">Email address</label>
<input type="email" class="form-control" id="exampleInputEmail1" aria-describedby="emailHelp"
placeholder="Enter email">
<small id="emailHelp" class="form-text text-muted">We'll never share your email with anyone else.</small>
<html>
<head>
<title>{% block title %}
{% endblock %}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Bootstrap 4 -->
<link id="id_main_css" href="{% static 'themes/bootstrap.min.css' %}" rel="stylesheet">
<script src="https://code.jquery.com/jquery-3.4.1.js"
integrity="sha256-WpOohJOqMqqyKL9FccASB9O0KwACQJpFTUBLTYOVvVU="
crossorigin="anonymous"></script>
<style>
@media (max-width: 1025px) {
.container {
width: 95% !important;
margin-left: 20px !important;
margin-right: 20px !important;
max-width: 1200px !important;
}
}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-{% nav_color request %}" id="id_main_nav">
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText"
aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</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 %}">
<a class="nav-link" href="{% url 'view_search' %}"><i
class="fas fa-book"></i> {% trans 'Cookbook' %}<span
class="sr-only">(current)</span></a>
</li>
<li class="nav-item dropdown {% if request.resolver_match.url_name in 'view_books,view_plan,view_shopping,list_ingredient' %}active{% endif %}">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
<i class="fas fa-mortar-pestle"></i> {% trans 'Utensils' %}
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
<a class="dropdown-item" href="{% url 'view_books' %}"><i
class="fas fa-bookmark fa-fw"></i> {% trans 'Books' %}
</a>
<a class="dropdown-item" href="{% url 'view_plan' %}"><i
class="fas fa-calendar fa-fw"></i> {% trans 'Meal-Plan' %}
</a>
<a class="dropdown-item" href="{% url 'view_shopping' %}"><i
class="fas fa-shopping-cart fa-fw"></i> {% trans 'Shopping' %}
</a>
<a class="dropdown-item" href="{% url 'list_ingredient' %}"><i
class="fas fa-leaf fa-fw"></i> {% trans 'Ingredients' %}
</a>
</div>
</li>
<li class="nav-item dropdown {% if request.resolver_match.url_name in 'list_keyword,data_batch_edit' %}active{% endif %}">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
<i class="fas fa-tags"></i> {% trans 'Tags' %}
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
<a class="dropdown-item" href="{% url 'list_keyword' %}"><i
class="fas fa-tags fa-fw"></i> {% trans 'Keyword' %}</a>
<a class="dropdown-item" href="{% url 'data_batch_edit' %}"><i
class="fas fa-edit fa-fw"></i> {% trans 'Batch Edit' %}</a>
</div>
</li>
<li class="nav-item dropdown {% if request.resolver_match.url_name in 'list_storage,data_sync,list_recipe_import,list_sync_log,data_stats,edit_ingredient' %}active{% endif %}">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false"><i class="fas fa-database"></i> {% trans 'Storage Data' %}
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
<a class="dropdown-item" href="{% url 'list_storage' %}"><i
class="fas fa-database fa-fw"></i> {% trans 'Storage Backends' %}</a>
<a class="dropdown-item" href="{% url 'data_sync' %}"><i
class="fas fa-sync-alt fa-fw"></i> {% trans 'Configure Sync' %}</a>
<a class="dropdown-item" href="{% url 'list_recipe_import' %}"><i
class="far fa-file-alt fa-fw"></i> {% trans 'Discovered Recipes' %}</a>
<a class="dropdown-item" href="{% url 'list_sync_log' %}"><i
class="fas fa-history fa-fw"></i> {% trans 'Discovery Log' %}</a>
<a class="dropdown-item" href="{% url 'data_stats' %}"><i
class="fas fa-chart-line fa-fw"></i> {% trans 'Statistics' %}</a>
<a class="dropdown-item" href="{% url 'edit_ingredient' %}"><i
class="fas fa-balance-scale fa-fw"></i> {% trans 'Units & Ingredients' %}</a>
<a class="dropdown-item" href="{% url 'view_import' %}"><i
class="fas fa-file-import"></i> {% trans 'Import Recipe' %}</a>
</div>
</li>
</ul>
<ul class="navbar-nav ml-auto">
{% if user.is_authenticated %}
<li class="nav-item dropdown {% if request.resolver_match.url_name in 'view_settings' %}active{% endif %}">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false"><i
class="fas fa-user-alt"></i> {{ user.get_user_name }}
</a>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdownMenuLink">
<a class="dropdown-item" href="{% url 'view_settings' %}"><i
class="fas fa-user-cog fa-fw"></i> {% trans 'Settings' %}</a>
{% if user.is_superuser %}
<a class="dropdown-item" href="{% url 'admin:index' %}"><i
class="fas fa-user-shield fa-fw"></i> {% trans 'Admin' %}</a>
{% endif %}
<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>
</div>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{% url 'login' %}">{% trans 'Login' %} <i class="fas fa-sign-in-alt"></i></a>
</li>
{% endif %}
</ul>
</div>
</nav>
<div class="container" style="background: #ffff00">
<div class="row">
<div class="col">
<form action="" method="get" id="search_form">
{% csrf_token %}
{{ form.non_field_errors }}
<div class="row">
<div class="col md-12">
<div class="input-group">
<input type="text" class="form-control" placeholder="{% trans 'Search recipe ...' %}"
id="{{ filter.form.name.id_for_label }}" name="{{ filter.form.name.name }}"
aria-describedby="button-addon4">
<div class="input-group-append">
<button class="btn btn-primary" type="submit"><i class="fas fa-search"></i></button>
<button type="button"
class="btn btn-light dropdown-toggle dropdown-toggle-split dropdown-toggle-no-arrow"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fas fa-ellipsis-v"></i>
<span class="sr-only">Toggle Dropdown</span>
</button>
<div class="dropdown-menu">
<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 data-toggle="collapse" href="#collapse_adv_search"
role="button" class="dropdown-item"
aria-expanded="false" type="button"
aria-controls="collapse_adv_search"><i
class="fas fa-search-plus fa-fw"></i> {% trans 'Advanced Search' %}
</button>
<button class="dropdown-item" type="button"
onclick="window.location = window.location.pathname;"><i
class="fas fa-sync fa-fw"></i> {% trans 'Reset Search' %}</button>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="collapse col-md-12" id="collapse_adv_search">
<div style="margin-top: 1vh">
{{ filter.form.keywords | as_crispy_field }}
</div>
<div>
{{ filter.form.ingredients | as_crispy_field }}
</div>
<div>
{{ filter.form.internal | as_crispy_field }}
</div>
</div>
</div>
</form>
</div>
<div class="form-group">
<label for="exampleInputPassword1">Password</label>
<input type="password" class="form-control" id="exampleInputPassword1" placeholder="Password">
</div>
<div class="row">
<div class="col">
<div class="table-container">
<table>
<div class="card">
<div class="row no-gutters">
<div class="col-md-4">
<img src="{% static 'recipe_no_image.svg' %}" class="card-img"
alt="{% trans 'Recipe Image' %}" style="height: 100%;">
</div>
<div class="col-md-8">
<div class="card-body">
<h5 class="card-title">Test</h5>
<p class="card-text">This is a wider card with supporting text below as a
natural
lead-in to additional content. This content is a little bit longer.</p>
<p class="card-text"><small class="text-muted">Last updated 3 mins
ago</small></p>
</div>
</div>
</div>
</div>
</table>
</div>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="exampleCheck1">
<label class="form-check-label" for="exampleCheck1">Check me out</label>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
{% endblock %}
</div>
</div>
</body>
</html>

View File

@@ -1,11 +1,13 @@
from django import template
import markdown as md
import bleach
from bleach_whitelist import markdown_tags, markdown_attrs, all_styles, print_attrs
import markdown as md
from bleach_whitelist import markdown_tags, markdown_attrs
from django import template
from django.db.models import Avg
from django.urls import reverse
from cookbook.helper.mdx_attributes import MarkdownFormatExtension
from cookbook.models import get_model_name
from cookbook.helper.mdx_urlize import UrlizeExtension
from cookbook.models import get_model_name, CookLog
register = template.Library()
@@ -28,5 +30,32 @@ def delete_url(model, pk):
@register.filter()
def markdown(value):
tags = markdown_tags + ['pre', 'table', 'td', 'tr', 'th', 'tbody', 'style', 'thead']
parsed_md = md.markdown(value, extensions=['markdown.extensions.fenced_code', 'tables', MarkdownFormatExtension()])
parsed_md = md.markdown(value, extensions=['markdown.extensions.fenced_code', 'tables', UrlizeExtension(), MarkdownFormatExtension()])
markdown_attrs['*'] = markdown_attrs['*'] + ['class']
return bleach.clean(parsed_md, tags, markdown_attrs)
@register.simple_tag
def recipe_rating(recipe, user):
rating = recipe.cooklog_set.filter(created_by=user).aggregate(Avg('rating'))
if rating['rating__avg']:
rating_stars = ''
for i in range(int(rating['rating__avg'])):
rating_stars = rating_stars + '<i class="fas fa-star fa-xs"></i>'
if rating['rating__avg'] % 1 >= 0.5:
rating_stars = rating_stars + '<i class="fas fa-star-half-alt fa-xs"></i>'
return rating_stars
else:
return ''
@register.simple_tag
def recipe_last(recipe, user):
last = recipe.cooklog_set.filter(created_by=user).last()
if last:
return last.created_at
else:
return ''

View File

@@ -8,41 +8,38 @@ register = template.Library()
@register.simple_tag
def theme_url(request):
try:
themes = {
UserPreference.BOOTSTRAP: 'themes/bootstrap.min.css',
UserPreference.FLATLY: 'themes/flatly.min.css',
UserPreference.DARKLY: 'themes/darkly.min.css',
UserPreference.SUPERHERO: 'themes/superhero.min.css',
}
if request.user.userpreference.theme in themes:
return static(themes[request.user.userpreference.theme])
else:
raise AttributeError
except AttributeError:
if not request.user.is_authenticated:
return static('themes/flatly.min.css')
themes = {
UserPreference.BOOTSTRAP: 'themes/bootstrap.min.css',
UserPreference.FLATLY: 'themes/flatly.min.css',
UserPreference.DARKLY: 'themes/darkly.min.css',
UserPreference.SUPERHERO: 'themes/superhero.min.css',
}
if request.user.userpreference.theme in themes:
return static(themes[request.user.userpreference.theme])
else:
raise AttributeError
@register.simple_tag
def nav_color(request):
try:
return request.user.userpreference.nav_color
except AttributeError:
if not request.user.is_authenticated:
return 'primary'
return request.user.userpreference.nav_color
@register.simple_tag
def tabulator_theme_url(request):
try:
themes = {
UserPreference.BOOTSTRAP: 'tabulator/tabulator_bootstrap4.min.css',
UserPreference.FLATLY: 'tabulator/tabulator_bootstrap4.min.css',
UserPreference.DARKLY: 'tabulator/tabulator_site.min.css',
UserPreference.SUPERHERO: 'tabulator/tabulator_site.min.css',
}
if request.user.userpreference.theme in themes:
return static(themes[request.user.userpreference.theme])
else:
raise AttributeError
except AttributeError:
if not request.user.is_authenticated:
return static('tabulator/tabulator_bootstrap4.min.css')
themes = {
UserPreference.BOOTSTRAP: 'tabulator/tabulator_bootstrap4.min.css',
UserPreference.FLATLY: 'tabulator/tabulator_bootstrap4.min.css',
UserPreference.DARKLY: 'tabulator/tabulator_site.min.css',
UserPreference.SUPERHERO: 'tabulator/tabulator_site.min.css',
}
if request.user.userpreference.theme in themes:
return static(themes[request.user.userpreference.theme])
else:
raise AttributeError

View File

@@ -0,0 +1,49 @@
from django.contrib import auth
from django.urls import reverse
from cookbook.models import Comment, Recipe
from cookbook.tests.views.test_views import TestViews
class TestEditsComment(TestViews):
def setUp(self):
super(TestEditsComment, self).setUp()
self.recipe = Recipe.objects.create(
internal=True,
instructions='Do something',
working_time=1,
waiting_time=1,
created_by=auth.get_user(self.user_client_1)
)
self.comment = Comment.objects.create(
text='TestStorage',
created_by=auth.get_user(self.guest_client_1),
recipe=self.recipe
)
self.url = reverse('edit_comment', args=[self.comment.pk])
def test_new_comment(self):
r = self.user_client_1.post(reverse('view_recipe', args=[self.recipe.pk]), {'comment-text': 'Test Comment Text', 'comment-recipe': self.recipe.pk})
self.assertEqual(r.status_code, 200)
def test_edit_comment_permissions(self):
r = self.anonymous_client.get(self.url)
self.assertEqual(r.status_code, 302)
r = self.guest_client_1.get(self.url)
self.assertEqual(r.status_code, 200)
r = self.guest_client_2.get(self.url)
self.assertEqual(r.status_code, 302)
r = self.user_client_1.get(self.url)
self.assertEqual(r.status_code, 302)
r = self.admin_client_1.get(self.url)
self.assertEqual(r.status_code, 302)
r = self.superuser_client.get(self.url)
self.assertEqual(r.status_code, 200)

View File

@@ -11,66 +11,66 @@ class TestEditsRecipe(TestViews):
internal_recipe = Recipe.objects.create(
name='Test',
internal=True,
created_by=auth.get_user(self.client)
created_by=auth.get_user(self.user_client_1)
)
external_recipe = Recipe.objects.create(
name='Test',
internal=False,
created_by=auth.get_user(self.client)
created_by=auth.get_user(self.user_client_1)
)
url = reverse('edit_recipe', args=[internal_recipe.pk])
r = self.client.get(url)
r = self.user_client_1.get(url)
self.assertEqual(r.status_code, 302)
r = self.client.get(r.url)
r = self.user_client_1.get(r.url)
self.assertTemplateUsed(r, 'forms/edit_internal_recipe.html')
url = reverse('edit_recipe', args=[external_recipe.pk])
r = self.client.get(url)
r = self.user_client_1.get(url)
self.assertEqual(r.status_code, 302)
r = self.client.get(r.url)
r = self.user_client_1.get(r.url)
self.assertTemplateUsed(r, 'generic/edit_template.html')
def test_convert_recipe(self):
url = reverse('edit_convert_recipe', args=[42])
r = self.client.get(url)
r = self.user_client_1.get(url)
self.assertEqual(r.status_code, 404)
external_recipe = Recipe.objects.create(
name='Test',
internal=False,
created_by=auth.get_user(self.client)
created_by=auth.get_user(self.user_client_1)
)
url = reverse('edit_convert_recipe', args=[external_recipe.pk])
r = self.client.get(url)
r = self.user_client_1.get(url)
self.assertEqual(r.status_code, 302)
recipe = Recipe.objects.get(pk=external_recipe.pk)
self.assertTrue(recipe.internal)
url = reverse('edit_convert_recipe', args=[recipe.pk])
r = self.client.get(url)
r = self.user_client_1.get(url)
self.assertEqual(r.status_code, 302)
def test_internal_recipe_update(self):
recipe = Recipe.objects.create(
name='Test',
created_by=auth.get_user(self.client)
created_by=auth.get_user(self.user_client_1)
)
url = reverse('edit_internal_recipe', args=[recipe.pk])
r = self.client.get(url)
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)
r = self.client.post(url, {'name': 'Changed', 'working_time': 15, 'waiting_time': 15, 'ingredients': '[]'})
r = self.user_client_1.post(url, {'name': 'Changed', 'working_time': 15, 'waiting_time': 15, 'ingredients': '[]'})
self.assertEqual(r.status_code, 200)
recipe = Recipe.objects.get(pk=recipe.pk)
@@ -79,30 +79,30 @@ class TestEditsRecipe(TestViews):
Ingredient.objects.create(name='Egg')
Unit.objects.create(name='g')
r = self.client.post(url,
r = self.user_client_1.post(url,
{'name': 'Changed', 'working_time': 15, 'waiting_time': 15,
'ingredients': '[{"ingredient__name":"Tomato","unit__name":"g","amount":100,"delete":false},{"ingredient__name":"Egg","unit__name":"Piece","amount":"2,5","delete":false}]'})
self.assertEqual(r.status_code, 200)
self.assertEqual(2, RecipeIngredient.objects.filter(recipe=recipe).count())
r = self.client.post(url,
r = self.user_client_1.post(url,
{'name': "Test", 'working_time': "Test", 'waiting_time': 15,
'ingredients': '[{"ingredient__name":"Tomato","unit__name":"g","amount":100,"delete":false},{"ingredient__name":"Egg","unit__name":"Piece","amount":"2,5","delete":false}]'})
self.assertEqual(r.status_code, 403)
with open('cookbook/tests/resources/image.jpg', 'rb') as file:
r = self.client.post(url, {'name': "Changed", 'working_time': 15, 'waiting_time': 15, 'image': file})
r = self.user_client_1.post(url, {'name': "Changed", 'working_time': 15, 'waiting_time': 15, 'image': file})
self.assertEqual(r.status_code, 200)
with open('cookbook/tests/resources/image.png', 'rb') as file:
r = self.client.post(url, {'name': "Changed", 'working_time': 15, 'waiting_time': 15, 'image': file})
r = self.user_client_1.post(url, {'name': "Changed", 'working_time': 15, 'waiting_time': 15, 'image': file})
self.assertEqual(r.status_code, 200)
def test_external_recipe_update(self):
storage = Storage.objects.create(
name='TestStorage',
method=Storage.DROPBOX,
created_by=auth.get_user(self.client),
created_by=auth.get_user(self.user_client_1),
token='test',
username='test',
password='test',
@@ -110,19 +110,19 @@ class TestEditsRecipe(TestViews):
recipe = Recipe.objects.create(
name='Test',
created_by=auth.get_user(self.client),
created_by=auth.get_user(self.user_client_1),
storage=storage,
)
url = reverse('edit_external_recipe', args=[recipe.pk])
r = self.client.get(url)
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)
r = self.client.post(url, {'name': 'Test', 'working_time': 15, 'waiting_time': 15, })
r = self.user_client_1.post(url, {'name': 'Test', 'working_time': 15, 'waiting_time': 15, })
recipe.refresh_from_db()
self.assertEqual(recipe.working_time, 15)
self.assertEqual(recipe.waiting_time, 15)

View File

@@ -7,33 +7,40 @@ from cookbook.tests.views.test_views import TestViews
class TestEditsRecipe(TestViews):
def test_edit_storage(self):
storage = Storage.objects.create(
def setUp(self):
super(TestEditsRecipe, self).setUp()
self.storage = Storage.objects.create(
name='TestStorage',
method=Storage.DROPBOX,
created_by=auth.get_user(self.client),
created_by=auth.get_user(self.admin_client_1),
token='test',
username='test',
password='test',
)
self.url = reverse('edit_storage', args=[self.storage.pk])
url = reverse('edit_storage', args=[storage.pk])
r = self.anonymous_client.get(url)
self.assertEqual(r.status_code, 302)
def test_edit_storage(self):
r = self.admin_client_1.post(self.url, {'name': 'NewStorage', 'password': '1234_pw', 'token': '1234_token', 'method': Storage.DROPBOX})
self.storage.refresh_from_db()
self.assertEqual(self.storage.password, '1234_pw')
self.assertEqual(self.storage.token, '1234_token')
r = self.another_client.get(url)
self.assertEqual(r.status_code, 302)
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
r = self.superuser_client.get(url)
self.assertEqual(r.status_code, 200)
r = self.client.post(url, {'name': 'NewStorage', 'password': '1234_pw', 'token': '1234_token', 'method': Storage.DROPBOX})
storage.refresh_from_db()
self.assertEqual(storage.password, '1234_pw')
self.assertEqual(storage.token, '1234_token')
r = self.client.post(url, {'name': 'NewStorage', 'password': '1234_pw', 'token': '1234_token', 'method': 'not_a_valid_method'})
r = self.admin_client_1.post(self.url, {'name': 'NewStorage', 'password': '1234_pw', 'token': '1234_token', 'method': 'not_a_valid_method'})
self.assertFormError(r, 'form', 'method', ['Select a valid choice. not_a_valid_method is not one of the available choices.'])
def test_edit_storage_permissions(self):
r = self.anonymous_client.get(self.url)
self.assertEqual(r.status_code, 302)
r = self.guest_client_1.get(self.url)
self.assertEqual(r.status_code, 302)
r = self.user_client_1.get(self.url)
self.assertEqual(r.status_code, 302)
r = self.admin_client_1.get(self.url)
self.assertEqual(r.status_code, 200)
r = self.superuser_client.get(self.url)
self.assertEqual(r.status_code, 200)

View File

@@ -1,24 +1,38 @@
from django.contrib import auth
from django.contrib.auth.models import User
from django.contrib.auth.models import User, Group
from django.test import TestCase, Client
class TestBase(TestCase):
user_client_1 = None
user_client_2 = None
admin_client_1 = None
admin_client_2 = None
guest_client_1 = None
guest_client_2 = None
superuser_client = None
def create_login_user(self, name, group):
client = Client()
setattr(self, name, client)
client.force_login(User.objects.get_or_create(username=name)[0])
user = auth.get_user(getattr(self, name))
user.groups.add(Group.objects.get(name=group))
self.assertTrue(user.is_authenticated)
return user
def setUp(self):
self.create_login_user('admin_client_1', 'admin')
self.create_login_user('admin_client_2', 'admin')
self.create_login_user('user_client_1', 'user')
self.create_login_user('user_client_2', 'user')
self.create_login_user('guest_client_1', 'guest')
self.create_login_user('guest_client_2', 'guest')
self.anonymous_client = Client()
self.client = Client()
self.client.force_login(User.objects.get_or_create(username='client')[0])
user = auth.get_user(self.client)
self.assertTrue(user.is_authenticated)
self.another_client = Client()
self.another_client.force_login(User.objects.get_or_create(username='another_client')[0])
user = auth.get_user(self.another_client)
self.assertTrue(user.is_authenticated)
self.superuser_client = Client()
self.superuser_client.force_login(User.objects.get_or_create(username='superuser_client', is_superuser=True)[0])
user = auth.get_user(self.superuser_client)
self.assertTrue(user.is_authenticated)
user = self.create_login_user('superuser_client', 'admin')
user.is_superuser = True
user.save()

View File

@@ -0,0 +1,37 @@
from django.contrib import auth
from django.urls import reverse
from cookbook.models import Recipe
from cookbook.tests.views.test_views import TestViews
class TestViewsApi(TestViews):
def test_external_link_permission(self):
recipe = Recipe.objects.create(
internal=False,
link='test',
instructions='Do something',
working_time=1,
waiting_time=1,
created_by=auth.get_user(self.user_client_1)
)
url = reverse('api_get_external_file_link', args=[recipe.pk])
self.assertEqual(self.anonymous_client.get(url).status_code, 302)
self.assertEqual(self.guest_client_1.get(url).status_code, 302)
self.assertEqual(self.user_client_1.get(url).status_code, 200)
self.assertEqual(self.admin_client_1.get(url).status_code, 200)
self.assertEqual(self.superuser_client.get(url).status_code, 200)
def test_file_permission(self):
url = reverse('api_get_recipe_file', args=[1])
self.assertEqual(self.anonymous_client.get(url).status_code, 302)
self.assertEqual(self.guest_client_1.get(url).status_code, 302)
def test_sync_permission(self):
url = reverse('api_sync')
self.assertEqual(self.anonymous_client.get(url).status_code, 302)
self.assertEqual(self.guest_client_1.get(url).status_code, 302)

View File

@@ -6,7 +6,7 @@ from cookbook.tests.views.test_views import TestViews
class TestViewsGeneral(TestViews):
def test_index(self):
r = self.client.get(reverse('index'))
r = self.user_client_1.get(reverse('index'))
self.assertEqual(r.status_code, 302)
r = self.anonymous_client.get(reverse('index'))
@@ -14,7 +14,7 @@ class TestViewsGeneral(TestViews):
def test_books(self):
url = reverse('view_books')
r = self.client.get(url)
r = self.user_client_1.get(url)
self.assertEqual(r.status_code, 200)
r = self.anonymous_client.get(url)
@@ -22,7 +22,7 @@ class TestViewsGeneral(TestViews):
def test_plan(self):
url = reverse('view_plan')
r = self.client.get(url)
r = self.user_client_1.get(url)
self.assertEqual(r.status_code, 200)
r = self.anonymous_client.get(url)
@@ -30,7 +30,7 @@ class TestViewsGeneral(TestViews):
def test_shopping(self):
url = reverse('view_shopping')
r = self.client.get(url)
r = self.user_client_1.get(url)
self.assertEqual(r.status_code, 200)
r = self.anonymous_client.get(url)

View File

@@ -3,7 +3,7 @@ from pydoc import locate
from django.urls import path
from .views import *
from cookbook.views import api
from cookbook.views import api, import_export
from cookbook.helper import dal
urlpatterns = [
@@ -11,8 +11,13 @@ urlpatterns = [
path('search/', views.search, name='view_search'),
path('books/', views.books, name='view_books'),
path('plan/', views.meal_plan, name='view_plan'),
path('plan/entry/<int:pk>', views.meal_plan_entry, name='view_plan_entry'),
path('shopping/', views.shopping_list, name='view_shopping'),
path('settings/', views.settings, name='view_settings'),
path('settings/', views.user_settings, name='view_settings'),
path('history/', views.history, name='view_history'),
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'),
@@ -36,12 +41,14 @@ urlpatterns = [
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('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'),
]
generic_models = (Recipe, RecipeImport, Storage, RecipeBook, MealPlan, SyncLog, Sync, Comment, RecipeBookEntry, Keyword, Ingredient)

View File

@@ -1,11 +1,14 @@
from django.contrib import messages
from django.http import HttpResponse, FileResponse
from django.urls import reverse
from django.utils.translation import gettext as _
from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect
import re
from cookbook.models import Recipe, Sync, Storage
from annoying.decorators import ajax_request
from annoying.functions import get_object_or_None
from django.contrib import messages
from django.http import HttpResponse
from django.shortcuts import redirect
from django.utils.translation import gettext as _
from cookbook.helper.permission_helper import group_required
from cookbook.models import Recipe, Sync, Storage, CookLog
from cookbook.provider.dropbox import Dropbox
from cookbook.provider.nextcloud import Nextcloud
@@ -26,7 +29,7 @@ def update_recipe_links(recipe):
recipe.save()
@login_required
@group_required('user')
def get_external_file_link(request, recipe_id):
recipe = Recipe.objects.get(id=recipe_id)
if not recipe.link:
@@ -35,7 +38,7 @@ def get_external_file_link(request, recipe_id):
return HttpResponse(recipe.link)
@login_required
@group_required('user')
def get_recipe_file(request, recipe_id):
recipe = Recipe.objects.get(id=recipe_id)
if not recipe.cors_link:
@@ -44,7 +47,7 @@ def get_recipe_file(request, recipe_id):
return HttpResponse(get_recipe_provider(recipe).get_base64_file(recipe))
@login_required
@group_required('user')
def sync_all(request):
monitors = Sync.objects.filter(active=True)
@@ -65,3 +68,22 @@ def sync_all(request):
else:
messages.add_message(request, messages.ERROR, _('Error synchronizing with Storage'))
return redirect('list_recipe_import')
@group_required('user')
@ajax_request
def log_cooking(request, recipe_id):
recipe = get_object_or_None(Recipe, id=recipe_id)
if recipe:
log = CookLog.objects.create(created_by=request.user, recipe=recipe)
servings = request.GET['s'] if 's' in request.GET else None
if servings and re.match(r'^([1-9])+$', servings):
log.servings = int(servings)
rating = request.GET['r'] if 'r' in request.GET else None
if rating and re.match(r'^([1-9])+$', rating):
log.rating = int(rating)
log.save()
return {'msg': 'updated successfully'}
return {'error': 'recipe does not exist'}

View File

@@ -7,11 +7,12 @@ 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.models import *
from cookbook.tables import SyncTable
@login_required
@group_required('user')
def sync(request):
if request.method == "POST":
form = SyncForm(request.POST)
@@ -31,12 +32,12 @@ def sync(request):
return render(request, 'batch/monitor.html', {'form': form, 'monitored_paths': monitored_paths})
@login_required
@group_required('user')
def sync_wait(request):
return render(request, 'batch/waiting.html')
@login_required
@group_required('user')
def batch_import(request):
imports = RecipeImport.objects.all()
for new_recipe in imports:
@@ -47,7 +48,7 @@ def batch_import(request):
return redirect('list_recipe_import')
@login_required
@group_required('user')
def batch_edit(request):
if request.method == "POST":
form = BatchEditForm(request.POST)
@@ -86,12 +87,18 @@ class Object(object):
pass
@login_required
@group_required('user')
def statistics(request):
counts = Object()
counts.recipes = Recipe.objects.count()
counts.keywords = Keyword.objects.count()
counts.recipe_import = RecipeImport.objects.count()
counts.units = Unit.objects.count()
counts.ingredients = Ingredient.objects.count()
counts.comments = Comment.objects.count()
counts.recipes_internal = Recipe.objects.filter(internal=True).count()
counts.recipes_external = counts.recipes - counts.recipes_internal
counts.recipes_no_keyword = Recipe.objects.filter(keywords=None).count()

View File

@@ -1,3 +1,4 @@
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
@@ -5,13 +6,15 @@ from django.urls import reverse_lazy, reverse
from django.utils.translation import gettext as _
from django.views.generic import DeleteView
from cookbook.helper.permission_helper import GroupRequiredMixin, OwnerRequiredMixin
from cookbook.models import Recipe, Sync, Keyword, RecipeImport, Storage, Comment, RecipeBook, \
RecipeBookEntry, MealPlan, Ingredient
from cookbook.provider.dropbox import Dropbox
from cookbook.provider.nextcloud import Nextcloud
class RecipeDelete(LoginRequiredMixin, DeleteView):
class RecipeDelete(GroupRequiredMixin, DeleteView):
groups_required = ['user']
template_name = "generic/delete_template.html"
model = Recipe
success_url = reverse_lazy('index')
@@ -23,6 +26,7 @@ class RecipeDelete(LoginRequiredMixin, DeleteView):
def delete_recipe_source(request, pk):
group_required = ['user']
recipe = get_object_or_404(Recipe, pk=pk)
if recipe.storage.method == Storage.DROPBOX:
@@ -38,7 +42,8 @@ def delete_recipe_source(request, pk):
return HttpResponseRedirect(reverse('edit_recipe', args=[recipe.pk]))
class RecipeImportDelete(LoginRequiredMixin, DeleteView):
class RecipeImportDelete(GroupRequiredMixin, DeleteView):
groups_required = ['user']
template_name = "generic/delete_template.html"
model = RecipeImport
success_url = reverse_lazy('list_recipe_import')
@@ -49,7 +54,8 @@ class RecipeImportDelete(LoginRequiredMixin, DeleteView):
return context
class SyncDelete(LoginRequiredMixin, DeleteView):
class SyncDelete(GroupRequiredMixin, DeleteView):
groups_required = ['admin']
template_name = "generic/delete_template.html"
model = Sync
success_url = reverse_lazy('data_sync')
@@ -60,7 +66,8 @@ class SyncDelete(LoginRequiredMixin, DeleteView):
return context
class KeywordDelete(LoginRequiredMixin, DeleteView):
class KeywordDelete(GroupRequiredMixin, DeleteView):
groups_required = ['user']
template_name = "generic/delete_template.html"
model = Keyword
success_url = reverse_lazy('list_keyword')
@@ -71,7 +78,8 @@ class KeywordDelete(LoginRequiredMixin, DeleteView):
return context
class IngredientDelete(LoginRequiredMixin, DeleteView):
class IngredientDelete(GroupRequiredMixin, DeleteView):
groups_required = ['user']
template_name = "generic/delete_template.html"
model = Ingredient
success_url = reverse_lazy('list_ingredient')
@@ -82,7 +90,8 @@ class IngredientDelete(LoginRequiredMixin, DeleteView):
return context
class StorageDelete(LoginRequiredMixin, DeleteView):
class StorageDelete(GroupRequiredMixin, DeleteView):
groups_required = ['admin']
template_name = "generic/delete_template.html"
model = Storage
success_url = reverse_lazy('list_storage')
@@ -93,7 +102,7 @@ class StorageDelete(LoginRequiredMixin, DeleteView):
return context
class CommentDelete(LoginRequiredMixin, DeleteView):
class CommentDelete(OwnerRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = Comment
success_url = reverse_lazy('index')
@@ -104,7 +113,7 @@ class CommentDelete(LoginRequiredMixin, DeleteView):
return context
class RecipeBookDelete(LoginRequiredMixin, DeleteView):
class RecipeBookDelete(OwnerRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = RecipeBook
success_url = reverse_lazy('view_books')
@@ -115,18 +124,26 @@ class RecipeBookDelete(LoginRequiredMixin, DeleteView):
return context
class RecipeBookEntryDelete(LoginRequiredMixin, DeleteView):
class RecipeBookEntryDelete(GroupRequiredMixin, DeleteView):
groups_required = ['user']
template_name = "generic/delete_template.html"
model = RecipeBookEntry
success_url = reverse_lazy('view_books')
def dispatch(self, request, *args, **kwargs):
obj = self.get_object()
if not (obj.book.created_by == request.user or request.user.is_superuser):
messages.add_message(request, messages.ERROR, _('You cannot interact with this object as its not owned by you!'))
return HttpResponseRedirect(reverse('index'))
return super(RecipeBookEntryDelete, self).dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super(RecipeBookEntryDelete, self).get_context_data(**kwargs)
context['title'] = _("Bookmarks")
return context
class MealPlanDelete(LoginRequiredMixin, DeleteView):
class MealPlanDelete(OwnerRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = MealPlan
success_url = reverse_lazy('view_plan')

View File

@@ -5,7 +5,6 @@ import simplejson
import simplejson as json
from PIL import Image
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.files import File
from django.http import HttpResponseRedirect
@@ -15,14 +14,17 @@ from django.utils.translation import gettext as _
from django.views.generic import UpdateView
from cookbook.forms import ExternalRecipeForm, KeywordForm, StorageForm, SyncForm, InternalRecipeForm, CommentForm, \
MealPlanForm, UnitMergeForm, IngredientMergeForm, IngredientForm
MealPlanForm, UnitMergeForm, IngredientMergeForm, IngredientForm, RecipeBookForm
from cookbook.helper.permission_helper import group_required, GroupRequiredMixin
from cookbook.helper.permission_helper import OwnerRequiredMixin
from cookbook.models import Recipe, Sync, Keyword, RecipeImport, Storage, Comment, RecipeIngredient, RecipeBook, \
MealPlan, Unit, Ingredient
from cookbook.provider.dropbox import Dropbox
from cookbook.provider.nextcloud import Nextcloud
@login_required
@group_required('guest')
def switch_recipe(request, pk):
recipe = get_object_or_404(Recipe, pk=pk)
if recipe.internal:
@@ -31,7 +33,7 @@ def switch_recipe(request, pk):
return HttpResponseRedirect(reverse('edit_external_recipe', args=[pk]))
@login_required
@group_required('user')
def convert_recipe(request, pk):
recipe = get_object_or_404(Recipe, pk=pk)
if not recipe.internal:
@@ -41,7 +43,7 @@ def convert_recipe(request, pk):
return HttpResponseRedirect(reverse('edit_internal_recipe', args=[pk]))
@login_required
@group_required('user')
def internal_recipe_update(request, pk):
recipe_instance = get_object_or_404(Recipe, pk=pk)
status = 200
@@ -124,14 +126,15 @@ def internal_recipe_update(request, pk):
else:
form = InternalRecipeForm(instance=recipe_instance)
ingredients = RecipeIngredient.objects.select_related('unit__name', 'ingredient__name').filter(recipe=recipe_instance).values('ingredient__name', 'unit__name', 'amount', 'note')
ingredients = RecipeIngredient.objects.select_related('unit__name', 'ingredient__name').filter(recipe=recipe_instance).values('ingredient__name', 'unit__name', 'amount', 'note').order_by('id')
return render(request, 'forms/edit_internal_recipe.html',
{'form': form, 'ingredients': json.dumps(list(ingredients)),
'view_url': reverse('view_recipe', args=[pk])}, status=status)
class SyncUpdate(LoginRequiredMixin, UpdateView):
class SyncUpdate(GroupRequiredMixin, UpdateView):
groups_required = ['admin']
template_name = "generic/edit_template.html"
model = Sync
form_class = SyncForm
@@ -147,7 +150,8 @@ class SyncUpdate(LoginRequiredMixin, UpdateView):
return context
class KeywordUpdate(LoginRequiredMixin, UpdateView):
class KeywordUpdate(GroupRequiredMixin, UpdateView):
groups_required = ['user']
template_name = "generic/edit_template.html"
model = Keyword
form_class = KeywordForm
@@ -163,7 +167,8 @@ class KeywordUpdate(LoginRequiredMixin, UpdateView):
return context
class IngredientUpdate(LoginRequiredMixin, UpdateView):
class IngredientUpdate(GroupRequiredMixin, UpdateView):
groups_required = ['user']
template_name = "generic/edit_template.html"
model = Ingredient
form_class = IngredientForm
@@ -179,7 +184,7 @@ class IngredientUpdate(LoginRequiredMixin, UpdateView):
return context
@login_required
@group_required('admin')
def edit_storage(request, pk):
instance = get_object_or_404(Storage, pk=pk)
@@ -205,30 +210,21 @@ def edit_storage(request, pk):
messages.add_message(request, messages.SUCCESS, _('Storage saved!'))
else:
messages.add_message(request, messages.ERROR, _('There was an error updating this storage backend.!'))
messages.add_message(request, messages.ERROR, _('There was an error updating this storage backend!'))
else:
pseudo_instance = instance
pseudo_instance.password = '__NO__CHANGE__'
pseudo_instance.token = '__NO__CHANGE__'
form = StorageForm(instance=pseudo_instance)
return render(request, 'generic/edit_template.html', {'form': form})
return render(request, 'generic/edit_template.html', {'form': form, 'title': _('Storage')})
class CommentUpdate(LoginRequiredMixin, UpdateView):
class CommentUpdate(OwnerRequiredMixin, UpdateView):
template_name = "generic/edit_template.html"
model = Comment
form_class = CommentForm
# TODO add msg box
def dispatch(self, request, *args, **kwargs):
obj = self.get_object()
if not (obj.created_by == request.user or request.user.is_superuser):
messages.add_message(request, messages.ERROR, _('You cannot edit this comment!'))
return HttpResponseRedirect(reverse('view_recipe', args=[obj.recipe.pk]))
return super(CommentUpdate, self).dispatch(request, *args, **kwargs)
def get_success_url(self):
return reverse('edit_comment', kwargs={'pk': self.object.pk})
@@ -239,7 +235,8 @@ class CommentUpdate(LoginRequiredMixin, UpdateView):
return context
class ImportUpdate(LoginRequiredMixin, UpdateView):
class ImportUpdate(GroupRequiredMixin, UpdateView):
groups_required = ['user']
template_name = "generic/edit_template.html"
model = RecipeImport
fields = ['name', 'path']
@@ -255,12 +252,10 @@ class ImportUpdate(LoginRequiredMixin, UpdateView):
return context
class RecipeBookUpdate(LoginRequiredMixin, UpdateView):
class RecipeBookUpdate(OwnerRequiredMixin, UpdateView):
template_name = "generic/edit_template.html"
model = RecipeBook
fields = ['name']
# TODO add msg box
form_class = RecipeBookForm
def get_success_url(self):
return reverse('view_books')
@@ -271,15 +266,13 @@ class RecipeBookUpdate(LoginRequiredMixin, UpdateView):
return context
class MealPlanUpdate(LoginRequiredMixin, UpdateView):
class MealPlanUpdate(OwnerRequiredMixin, UpdateView):
template_name = "generic/edit_template.html"
model = MealPlan
form_class = MealPlanForm
# TODO add msg box
def get_success_url(self):
return reverse('view_plan')
return reverse('view_plan_entry', kwargs={'pk': self.object.pk})
def get_context_data(self, **kwargs):
context = super(MealPlanUpdate, self).get_context_data(**kwargs)
@@ -287,7 +280,8 @@ class MealPlanUpdate(LoginRequiredMixin, UpdateView):
return context
class ExternalRecipeUpdate(LoginRequiredMixin, UpdateView):
class ExternalRecipeUpdate(GroupRequiredMixin, UpdateView):
groups_required = ['user']
model = Recipe
form_class = ExternalRecipeForm
template_name = "generic/edit_template.html"
@@ -322,7 +316,7 @@ class ExternalRecipeUpdate(LoginRequiredMixin, UpdateView):
return context
@login_required
@group_required('user')
def edit_ingredients(request):
if request.method == "POST":
success = False

View File

@@ -0,0 +1,117 @@
import base64
import json
import re
from django.contrib import messages
from django.core.files.base import ContentFile
from django.db import IntegrityError
from django.http import HttpResponseRedirect, JsonResponse, HttpResponse
from django.shortcuts import render
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from cookbook.forms import ExportForm, ImportForm
from cookbook.helper.permission_helper import group_required
from cookbook.models import RecipeIngredient, Recipe, Unit, Ingredient, Keyword
@group_required('user')
def import_recipe(request):
if request.method == "POST":
form = ImportForm(request.POST)
if form.is_valid():
data = json.loads(form.cleaned_data['recipe'])
recipe = Recipe.objects.create(name=data['recipe']['name'], instructions=data['recipe']['instructions'],
working_time=data['recipe']['working_time'], waiting_time=data['recipe']['waiting_time'],
created_by=request.user, internal=True)
for k in data['keywords']:
try:
Keyword.objects.create(name=k['name'], icon=k['icon'], description=k['description']).save()
except IntegrityError:
pass
recipe.keywords.add(Keyword.objects.get(name=k['name']))
for u in data['units']:
try:
Unit.objects.create(name=u['name'], description=u['description']).save()
except IntegrityError:
pass
for i in data['ingredients']:
try:
Ingredient.objects.create(name=i['name']).save()
except IntegrityError:
pass
for ri in data['recipe_ingredients']:
RecipeIngredient.objects.create(recipe=recipe, ingredient=Ingredient.objects.get(name=ri['ingredient']),
unit=Unit.objects.get(name=ri['unit']), amount=ri['amount'], note=ri['note'])
if data['image']:
fmt, img = data['image'].split(';base64,')
ext = fmt.split('/')[-1]
recipe.image = ContentFile(base64.b64decode(img), name=f'{recipe.pk}.{ext}')
recipe.save()
messages.add_message(request, messages.SUCCESS, _('Recipe imported successfully!'))
return HttpResponseRedirect(reverse_lazy('view_recipe', args=[recipe.pk]))
else:
form = ImportForm()
return render(request, 'import.html', {'form': form})
@group_required('user')
def export_recipe(request):
context = {}
if request.method == "POST":
form = ExportForm(request.POST)
if form.is_valid():
recipe = form.cleaned_data['recipe']
if recipe.internal:
export = {
'recipe': {'name': recipe.name, 'instructions': recipe.instructions, 'working_time': recipe.working_time, 'waiting_time': recipe.working_time},
'units': [],
'ingredients': [],
'recipe_ingredients': [],
'keywords': [],
'image': None
}
for k in recipe.keywords.all():
export['keywords'].append({'name': k.name, 'icon': k.icon, 'description': k.description})
for ri in RecipeIngredient.objects.filter(recipe=recipe).all():
if ri.unit not in export['units']:
export['units'].append({'name': ri.unit.name, 'description': ri.unit.description})
if ri.ingredient not in export['ingredients']:
export['ingredients'].append({'name': ri.ingredient.name})
export['recipe_ingredients'].append({'ingredient': ri.ingredient.name, 'unit': ri.unit.name, 'amount': float(ri.amount), 'note': ri.note})
if recipe.image and form.cleaned_data['image']:
with open(recipe.image.path, 'rb') as img_f:
export['image'] = f'data:image/png;base64,{base64.b64encode(img_f.read()).decode("utf-8")}'
if form.cleaned_data['download']:
response = HttpResponse(json.dumps(export), content_type='text/plain')
response['Content-Disposition'] = f'attachment; filename={recipe.name}.json'
return response
context['export'] = json.dumps(export, indent=4)
else:
form.add_error('recipe', _('External recipes cannot be exported, please share the file directly or select an internal recipe.'))
else:
form = ExportForm()
recipe = request.GET.get('r')
if recipe:
if re.match(r'^([0-9])+$', recipe):
if recipe := Recipe.objects.filter(pk=int(recipe)).first():
form = ExportForm(initial={'recipe': recipe})
context['form'] = form
return render(request, 'export.html', context)

View File

@@ -1,16 +1,16 @@
from django.contrib.auth.decorators import login_required
from django.db.models.functions import Lower
from django.shortcuts import render
from django.urls import reverse_lazy
from django_tables2 import RequestConfig
from django.utils.translation import gettext as _
from django_tables2 import RequestConfig
from cookbook.filters import IngredientFilter
from cookbook.helper.permission_helper import group_required
from cookbook.models import Keyword, SyncLog, RecipeImport, Storage, Ingredient
from cookbook.tables import KeywordTable, ImportLogTable, RecipeImportTable, StorageTable, IngredientTable
@login_required
@group_required('user')
def keyword(request):
table = KeywordTable(Keyword.objects.all())
RequestConfig(request, paginate={'per_page': 25}).configure(table)
@@ -18,7 +18,7 @@ def keyword(request):
return render(request, 'generic/list_template.html', {'title': _("Keyword"), 'table': table, 'create_url': 'new_keyword'})
@login_required
@group_required('admin')
def sync_log(request):
table = ImportLogTable(SyncLog.objects.all().order_by(Lower('created_at').desc()))
RequestConfig(request, paginate={'per_page': 25}).configure(table)
@@ -26,16 +26,16 @@ def sync_log(request):
return render(request, 'generic/list_template.html', {'title': _("Import Log"), 'table': table})
@login_required
@group_required('user')
def recipe_import(request):
table = RecipeImportTable(RecipeImport.objects.all())
RequestConfig(request, paginate={'per_page': 25}).configure(table)
return render(request, 'generic/list_template.html', {'title': _("Import"), 'table': table, 'import_btn': True})
return render(request, 'generic/list_template.html', {'title': _("Discovery"), 'table': table, 'import_btn': True})
@login_required
@group_required('user')
def ingredient(request):
f = IngredientFilter(request.GET, queryset=Ingredient.objects.all().order_by('pk'))
@@ -45,7 +45,7 @@ def ingredient(request):
return render(request, 'generic/list_template.html', {'title': _("Ingredients"), 'table': table, 'filter': f})
@login_required
@group_required('admin')
def storage(request):
table = StorageTable(Storage.objects.all())
RequestConfig(request, paginate={'per_page': 25}).configure(table)

View File

@@ -2,8 +2,6 @@ import re
from datetime import datetime
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponseRedirect
from django.shortcuts import render, redirect
from django.urls import reverse_lazy, reverse
@@ -12,10 +10,12 @@ 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
class RecipeCreate(LoginRequiredMixin, CreateView):
class RecipeCreate(GroupRequiredMixin, CreateView):
groups_required = ['user']
template_name = "generic/new_template.html"
model = Recipe
fields = ('name',)
@@ -36,7 +36,8 @@ class RecipeCreate(LoginRequiredMixin, CreateView):
return context
class KeywordCreate(LoginRequiredMixin, CreateView):
class KeywordCreate(GroupRequiredMixin, CreateView):
groups_required = ['user']
template_name = "generic/new_template.html"
model = Keyword
form_class = KeywordForm
@@ -48,7 +49,8 @@ class KeywordCreate(LoginRequiredMixin, CreateView):
return context
class StorageCreate(LoginRequiredMixin, CreateView):
class StorageCreate(GroupRequiredMixin, CreateView):
groups_required = ['admin']
template_name = "generic/new_template.html"
model = Storage
form_class = StorageForm
@@ -66,7 +68,7 @@ class StorageCreate(LoginRequiredMixin, CreateView):
return context
@login_required
@group_required('user')
def create_new_external_recipe(request, import_id):
if request.method == "POST":
form = ImportRecipeForm(request.POST)
@@ -97,7 +99,8 @@ def create_new_external_recipe(request, import_id):
return render(request, 'forms/edit_import_recipe.html', {'form': form})
class RecipeBookCreate(LoginRequiredMixin, CreateView):
class RecipeBookCreate(GroupRequiredMixin, CreateView):
groups_required = ['user']
template_name = "generic/new_template.html"
model = RecipeBook
form_class = RecipeBookForm
@@ -105,7 +108,7 @@ class RecipeBookCreate(LoginRequiredMixin, CreateView):
def form_valid(self, form):
obj = form.save(commit=False)
obj.user = self.request.user
obj.created_by = self.request.user
obj.save()
return HttpResponseRedirect(reverse('view_books'))
@@ -115,7 +118,8 @@ class RecipeBookCreate(LoginRequiredMixin, CreateView):
return context
class MealPlanCreate(LoginRequiredMixin, CreateView):
class MealPlanCreate(GroupRequiredMixin, CreateView):
groups_required = ['user']
template_name = "generic/new_template.html"
model = MealPlan
form_class = MealPlanForm
@@ -124,12 +128,13 @@ class MealPlanCreate(LoginRequiredMixin, CreateView):
def get_initial(self):
return dict(
meal=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
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
)
def form_valid(self, form):
obj = form.save(commit=False)
obj.user = self.request.user
obj.created_by = self.request.user
obj.save()
return HttpResponseRedirect(reverse('view_plan'))

View File

@@ -1,20 +1,22 @@
import copy
import re
from datetime import datetime, timedelta
from django.contrib import messages
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import PasswordChangeForm
from django.db.models import Q
from django.http import HttpResponseRedirect
from django.shortcuts import render, get_object_or_404
from django.urls import reverse_lazy
from django.utils import timezone
from django_tables2 import RequestConfig
from django.utils.translation import gettext as _
from django.conf import settings
from cookbook.filters import RecipeFilter
from cookbook.forms import *
from cookbook.tables import RecipeTable
from cookbook.helper.permission_helper import group_required
from cookbook.tables import RecipeTable, RecipeTableSmall, CookLogTable, ViewLogTable
def index(request):
@@ -36,15 +38,32 @@ def search(request):
if request.user.is_authenticated:
f = RecipeFilter(request.GET, queryset=Recipe.objects.all().order_by('name'))
table = RecipeTable(f.qs)
if request.user.userpreference.search_style == UserPreference.LARGE:
table = RecipeTable(f.qs)
else:
table = RecipeTableSmall(f.qs)
RequestConfig(request, paginate={'per_page': 25}).configure(table)
return render(request, 'index.html', {'recipes': table, 'filter': f})
if request.GET == {}:
qs = Recipe.objects.filter(viewlog__created_by=request.user).order_by('-viewlog__created_at').all()
recent_list = []
for r in qs:
if r not in recent_list:
recent_list.append(r)
if len(recent_list) >= 5:
break
last_viewed = RecipeTable(recent_list)
else:
last_viewed = None
return render(request, 'index.html', {'recipes': table, 'filter': f, 'last_viewed': last_viewed})
else:
return render(request, 'index.html')
@login_required
@group_required('guest')
def recipe_view(request, pk):
recipe = get_object_or_404(Recipe, pk=pk)
ingredients = RecipeIngredient.objects.filter(recipe=recipe)
@@ -75,16 +94,20 @@ def recipe_view(request, pk):
comment_form = CommentForm()
bookmark_form = RecipeBookEntryForm()
if request.user.is_authenticated:
if not ViewLog.objects.filter(recipe=recipe).filter(created_by=request.user).filter(created_at__gt=(timezone.now() - timezone.timedelta(minutes=5))).exists():
ViewLog.objects.create(recipe=recipe, created_by=request.user)
return render(request, 'recipe_view.html',
{'recipe': recipe, 'ingredients': ingredients, 'comments': comments, 'comment_form': comment_form,
'bookmark_form': bookmark_form})
@login_required()
@group_required('user')
def books(request):
book_list = []
books = RecipeBook.objects.filter(user=request.user).all()
books = RecipeBook.objects.filter(Q(created_by=request.user) | Q(shared=request.user)).distinct().all()
for b in books:
book_list.append({'book': b, 'recipes': RecipeBookEntry.objects.filter(book=b).all()})
@@ -106,7 +129,7 @@ def get_days_from_week(start, end):
return days
@login_required()
@group_required('user')
def meal_plan(request):
js_week = datetime.now().strftime("%Y-W%V")
if request.method == "POST":
@@ -127,14 +150,27 @@ def meal_plan(request):
plan[t[0]] = {'type_name': t[1], 'days': copy.deepcopy(days_dict)}
for d in days:
plan_day = MealPlan.objects.filter(date=d).all()
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})
@login_required
@group_required('user')
def meal_plan_entry(request, pk):
plan = MealPlan.objects.get(pk=pk)
if plan.created_by != request.user and plan.shared != request.user:
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()
return render(request, 'meal_plan_entry.html', {'plan': plan, 'same_day_plan': same_day_plan})
@group_required('user')
def shopping_list(request):
markdown_format = True
@@ -174,12 +210,9 @@ def shopping_list(request):
return render(request, 'shopping_list.html', {'ingredients': ingredients, 'recipes': recipes, 'form': form, 'markdown_format': markdown_format})
@login_required
def settings(request):
try:
up = request.user.userpreference
except UserPreference.DoesNotExist:
up = None
@group_required('guest')
def user_settings(request):
up = request.user.userpreference
user_name_form = UserNameForm(instance=request.user)
password_form = PasswordChangeForm(request.user)
@@ -194,6 +227,8 @@ def settings(request):
up.nav_color = form.cleaned_data['nav_color']
up.default_unit = form.cleaned_data['default_unit']
up.default_page = form.cleaned_data['default_page']
up.search_style = form.cleaned_data['search_style']
up.plan_share.set(form.cleaned_data['plan_share'])
up.save()
if 'user_name_form' in request.POST:
@@ -215,3 +250,14 @@ def settings(request):
preference_form = UserPreferenceForm()
return render(request, 'settings.html', {'preference_form': preference_form, 'user_name_form': user_name_form, 'password_form': password_form})
@group_required('guest')
def history(request):
view_log = ViewLogTable(ViewLog.objects.filter(created_by=request.user).order_by('-created_at').all())
cook_log = CookLogTable(CookLog.objects.filter(created_by=request.user).order_by('-created_at').all())
return render(request, 'history.html', {'view_log': view_log, 'cook_log': cook_log})
def markdown_info(request):
return render(request, 'markdown_info.html', {})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-03-18 12:13+0100\n"
"POT-Creation-Date: 2020-04-25 23:31+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -18,10 +18,10 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: .\recipes\settings.py:136
#: .\recipes\settings.py:137
msgid "German"
msgstr "Deutsch"
#: .\recipes\settings.py:137
#: .\recipes\settings.py:138
msgid "English"
msgstr "Englisch"

View File

@@ -31,6 +31,7 @@ SESSION_COOKIE_AGE = 365 * 60 * 24 * 60
CRISPY_TEMPLATE_PACK = 'bootstrap4'
DJANGO_TABLES2_TEMPLATE = 'cookbook/templates/generic/table_template.html'
DJANGO_TABLES2_PAGE_RANGE = 8
MESSAGE_TAGS = {
messages.ERROR: 'danger'

View File

@@ -7,6 +7,7 @@ djangorestframework
django-autocomplete-light
django-emoji-picker
django-cleanup
django-annoying
bleach
bleach-whitelist
six