Merge branch 'feature/plan-title' into mealplan-recipes-optional

This commit is contained in:
vabene1111
2020-05-02 14:44:15 +02:00
committed by GitHub
64 changed files with 2049 additions and 520 deletions

View File

@@ -0,0 +1,18 @@
name: publish dev image docker
on:
push:
branches:
- '*'
- '*/*'
- '!master'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Publish to Registry
uses: elgohr/Publish-Docker-Github-Action@2.13
with:
name: vabene1111/recipes
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}

View File

@@ -0,0 +1,19 @@
name: publish latest image docker
on:
push:
branches:
- master
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Build and publish image
uses: ilteoood/docker_buildx@master
with:
publish: true
imageName: vabene1111/recipes
tag: latest
dockerHubUser: ${{ secrets.DOCKER_USERNAME }}
dockerHubPassword: ${{ secrets.DOCKER_PASSWORD }}

View File

@@ -1,4 +1,4 @@
name: Deploy Docker Image
name: publish tagged release docker
on:
push:
@@ -11,12 +11,15 @@ jobs:
name: Build image job
steps:
- name: Checkout master
uses: actions/checkout@master
uses: actions/checkout@master#
- name: Get the version
id: get_version
run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//}
- name: Build and publish image
uses: ilteoood/docker_buildx@master
with:
publish: true
imageName: recipes
tag: test
imageName: vabene1111/recipes
tag: ${{ steps.get_version.outputs.VERSION }}
dockerHubUser: ${{ secrets.DOCKER_USERNAME }}
dockerHubPassword: ${{ secrets.DOCKER_PASSWORD }}

View File

@@ -1,13 +0,0 @@
name: Publish Docker
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Publish to Registry
uses: elgohr/Publish-Docker-Github-Action@2.13
with:
name: vabene1111/recipes
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}

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,20 +14,22 @@ 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.
### Docker-Compose
2. Choose one of the included configurations [here](https://github.com/vabene1111/recipes/tree/develop/docs/docker).
2. Choose one of the included configurations [here](docs/docker).
2. Download the environment (config) file template and fill it out `wget https://raw.githubusercontent.com/vabene1111/recipes/develop/.env.template -O .env `
3. Start the container `docker-compose up -d`
4. Create a default user by running `docker-compose exec web_recipes createsuperuser`.
@@ -38,7 +42,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!**
@@ -46,30 +49,9 @@ While intermediate updates can be skipped when updating please make sure to **re
2. Pull the latest image using `docker-compose pull`
3. Start the container again using `docker-compose up -d`
# Documentation
## Kubernetes
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.
You can find a basic kubernetes setup [here](docs/k8s/). Please see the README in the folder for more detail.
## Contributing
Pull Requests and ideas are welcome, feel free to contribute in any way.

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,4 +1,3 @@
from dal import autocomplete
from django import forms
from django.forms import widgets
from django.utils.translation import gettext as _
@@ -31,10 +30,11 @@ class UserPreferenceForm(forms.ModelForm):
class Meta:
model = UserPreference
fields = ('theme', 'nav_color')
fields = ('default_unit', 'theme', 'nav_color', 'default_page', 'search_style')
help_texts = {
'nav_color': _('Color of the top navigation bar. Not all colors work with all themes, just try them out!')
'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.')
}
@@ -99,6 +99,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'
@@ -218,7 +237,8 @@ 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):

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,18 @@
# Generated by Django 3.0.4 on 2020-04-13 20:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0031_auto_20200407_1841'),
]
operations = [
migrations.AddField(
model_name='userpreference',
name='default_unit',
field=models.CharField(default='g', max_length=32),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.0.4 on 2020-04-13 20:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0032_userpreference_default_unit'),
]
operations = [
migrations.AddField(
model_name='userpreference',
name='default_page',
field=models.CharField(choices=[('SEARCH', 'Search'), ('PLAN', 'Meal-Plan')], default='SEARCH', max_length=64),
),
]

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

@@ -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 _
@@ -41,12 +42,28 @@ class UserPreference(models.Model):
COLORS = ((PRIMARY, 'Primary'), (SECONDARY, 'Secondary'), (SUCCESS, 'Success'), (INFO, 'Info'), (WARNING, 'Warning'), (DANGER, 'Danger'), (LIGHT, 'Light'), (DARK, 'Dark'))
user = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True)
# Default Page
SEARCH = 'SEARCH'
PLAN = 'PLAN'
BOOKS = 'BOOKS'
PAGES = ((SEARCH, _('Search')), (PLAN, _('Meal-Plan')), (BOOKS, _('Books')), )
# 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)
def __str__(self):
return self.user
return str(self.user)
class Storage(models.Model):
@@ -145,8 +162,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)
@@ -179,7 +196,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
@@ -200,8 +220,8 @@ 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, blank=True, null=True)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
meal = models.CharField(choices=MEAL_TYPES, max_length=128, default=BREAKFAST)
note = models.TextField(blank=True)
date = models.DateField()

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 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')])

View File

@@ -54,28 +54,19 @@
{% 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>
</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 'index,edit_recipe,edit_internal_recipe,edit_external_recipe,view_recipe' %}active{% endif %}">
<a class="nav-link" href="{% url 'index' %}"><i class="fas fa-book"></i> {% trans 'Cookbook' %}<span
<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>
@@ -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>
@@ -137,7 +130,8 @@
{% 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 }}
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">
@@ -164,13 +158,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

@@ -16,46 +16,48 @@
</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>
<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>
<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 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>
{% 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>
<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>
</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">
<a href="{% url 'view_recipe' r.recipe.pk %}">{{ r.recipe.name }}</a>
</div>
<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>
</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

@@ -189,7 +189,7 @@
data.push({
ingredient__name: "{% trans 'Ingredient' %}",
amount: "100",
unit__name: "g",
unit__name: "{{ request.user.userpreference.default_unit }}",
note: "",
id: Math.floor(Math.random() * 10000000),
delete: false,

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

@@ -18,56 +18,62 @@
{% 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/>

View File

@@ -52,16 +52,24 @@
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
<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-warning" 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
<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>
@@ -369,6 +377,10 @@
trigger: 'focus'
});
function roundToTwo(num) {
return +(Math.round(num + "e+2") + "e-2");
}
function reloadIngredients() {
factor = Number($('#in_factor').val());
ingredients = {
@@ -378,9 +390,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,104 @@
{% 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: 10vh">
{% 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: 10vh">
{% endif %}
</div>
<div class="col-md-8">
<div class="card-body">
<h5 class="card-title">{{ row.cells.name }}</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 %}
<span class="badge badge-light">{{ row.cells.edit }}</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

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

@@ -5,6 +5,7 @@ from bleach_whitelist import markdown_tags, markdown_attrs, all_styles, print_at
from django.urls import reverse
from cookbook.helper.mdx_attributes import MarkdownFormatExtension
from cookbook.helper.mdx_urlize import UrlizeExtension
from cookbook.models import get_model_name
register = template.Library()
@@ -28,5 +29,5 @@ 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()])
return bleach.clean(parsed_md, tags, markdown_attrs)

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,15 +6,15 @@ from cookbook.tests.views.test_views import TestViews
class TestViewsGeneral(TestViews):
def test_index(self):
r = self.client.get(reverse('index'))
self.assertEqual(r.status_code, 200)
r = self.user_client_1.get(reverse('index'))
self.assertEqual(r.status_code, 302)
r = self.anonymous_client.get(reverse('index'))
self.assertEqual(r.status_code, 200)
self.assertEqual(r.status_code, 302)
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,16 +3,20 @@ 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 = [
path('', views.index, name='index'),
path('search/', views.search, name='view_search'),
path('books/', views.books, name='view_books'),
path('plan/', views.meal_plan, name='view_plan'),
path('shopping/', views.shopping_list, name='view_shopping'),
path('settings/', views.settings, name='view_settings'),
path('import/', import_export.import_recipe, name='view_import'),
path('export/', import_export.export_recipe, name='view_export'),
path('view/recipe/<int:pk>', views.recipe_view, name='view_recipe'),
path('new/recipe_import/<int:import_id>/', new.create_new_external_recipe, name='new_recipe_import'),

View File

@@ -1,10 +1,9 @@
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.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
from cookbook.provider.dropbox import Dropbox
from cookbook.provider.nextcloud import Nextcloud
@@ -26,7 +25,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 +34,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 +43,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)

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
@@ -131,7 +133,8 @@ def internal_recipe_update(request, pk):
'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,13 +266,11 @@ 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')
@@ -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
@@ -129,7 +133,7 @@ class MealPlanCreate(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_plan'))

View File

@@ -1,25 +1,45 @@
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_tables2 import RequestConfig
from django.utils.translation import gettext as _
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
def index(request):
if not request.user.is_authenticated:
return HttpResponseRedirect(reverse_lazy('view_search'))
try:
page_map = {
UserPreference.SEARCH: reverse_lazy('view_search'),
UserPreference.PLAN: reverse_lazy('view_plan'),
UserPreference.BOOKS: reverse_lazy('view_books'),
}
return HttpResponseRedirect(page_map.get(request.user.userpreference.default_page))
except UserPreference.DoesNotExist:
return HttpResponseRedirect(reverse_lazy('view_search'))
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})
@@ -27,7 +47,7 @@ def index(request):
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)
@@ -63,11 +83,11 @@ def recipe_view(request, pk):
'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)).all()
for b in books:
book_list.append({'book': b, 'recipes': RecipeBookEntry.objects.filter(book=b).all()})
@@ -89,7 +109,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":
@@ -117,7 +137,7 @@ def meal_plan(request):
return render(request, 'meal_plan.html', {'js_week': js_week, 'plan': plan, 'days': days, 'surrounding_weeks': surrounding_weeks})
@login_required
@group_required('user')
def shopping_list(request):
markdown_format = True
@@ -157,12 +177,9 @@ def shopping_list(request):
return render(request, 'shopping_list.html', {'ingredients': ingredients, 'recipes': recipes, 'form': form, 'markdown_format': markdown_format})
@login_required
@group_required('guest')
def settings(request):
try:
up = request.user.userpreference
except UserPreference.DoesNotExist:
up = None
up = request.user.userpreference
user_name_form = UserNameForm(instance=request.user)
password_form = PasswordChangeForm(request.user)
@@ -175,6 +192,9 @@ def settings(request):
up = UserPreference(user=request.user)
up.theme = form.cleaned_data['theme']
up.nav_color = form.cleaned_data['nav_color']
up.default_unit = form.cleaned_data['default_unit']
up.default_page = form.cleaned_data['default_page']
up.search_style = form.cleaned_data['search_style']
up.save()
if 'user_name_form' in request.POST:

View File

@@ -0,0 +1,33 @@
kind: ConfigMap
apiVersion: v1
metadata:
labels:
app: recipes
name: recipes-nginx-config
data:
nginx-config: |-
events {
worker_connections 1024;
}
http {
server {
listen 80;
server_name _;
client_max_body_size 16M;
# serve static files
location /static/ {
alias /static/;
}
# serve media files
location /media/ {
alias /media/;
}
# pass requests for dynamic content to gunicorn
location / {
proxy_set_header Host $host;
proxy_pass http://localhost:8080;
}
}
}

50
docs/k8s/30-pv.yaml Normal file
View File

@@ -0,0 +1,50 @@
apiVersion: v1
kind: PersistentVolume
metadata:
name: recipes-db
labels:
app: recipes
type: local
tier: db
spec:
storageClassName: manual
capacity:
storage: 1Gi
accessModes:
- ReadWriteMany
hostPath:
path: "/data/recipes/db"
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: recipes-media
labels:
app: recipes
type: local
tier: media
spec:
storageClassName: manual
capacity:
storage: 1Gi
accessModes:
- ReadWriteMany
hostPath:
path: "/data/recipes/media"
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: recipes-static
labels:
app: recipes
type: local
tier: static
spec:
storageClassName: manual
capacity:
storage: 1Gi
accessModes:
- ReadWriteMany
hostPath:
path: "/data/recipes/static"

52
docs/k8s/30-pvc.yaml Normal file
View File

@@ -0,0 +1,52 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: recipes-db
labels:
app: recipes
spec:
selector:
matchLabels:
tier: db
storageClassName: manual
accessModes:
- ReadWriteMany
resources:
requests:
storage: 1Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: recipes-media
labels:
app: recipes
spec:
selector:
matchLabels:
tier: media
app: recipes
storageClassName: manual
accessModes:
- ReadWriteMany
resources:
requests:
storage: 1Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: recipes-static
labels:
app: recipes
spec:
selector:
matchLabels:
tier: static
app: recipes
storageClassName: manual
accessModes:
- ReadWriteMany
resources:
requests:
storage: 1Gi

102
docs/k8s/50-deployment.yaml Normal file
View File

@@ -0,0 +1,102 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: recipes
labels:
app: recipes
environment: production
tier: frontend
spec:
replicas: 1
strategy:
type: RollingUpdate
selector:
matchLabels:
app: recipes
environment: production
template:
metadata:
labels:
app: recipes
environment: production
spec:
containers:
- name: recipes-nginx
image: nginx:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80
protocol: TCP
name: http
volumeMounts:
- mountPath: '/media'
name: media
- mountPath: '/static'
name: static
- name: nginx-config
mountPath: /etc/nginx/nginx.conf
subPath: nginx-config
readOnly: true
- name: recipes
image: 'vabene1111/recipes:latest'
imagePullPolicy: IfNotPresent
livenessProbe:
httpGet:
path: /
port: 8080
readinessProbe:
httpGet:
path: /
port: 8080
volumeMounts:
- mountPath: '/opt/recipes/mediafiles'
name: media
- mountPath: '/opt/recipes/staticfiles'
name: static
env:
- name: DEBUG
value: "0"
- name: ALLOWED_HOSTS
value: '*'
- name: SECRET_KEY
value: # CHANGEME
- name: DB_ENGINE
value: django.db.backends.postgresql_psycopg2
- name: POSTGRES_HOST
value: localhost
- name: POSTGRES_PORT
value: "5432"
- name: POSTGRES_USER
value: recipes
- name: POSTGRES_DB
value: recipes
- name: POSTGRES_PASSWORD
value: # CHANGEME
- name: recipes-db
image: 'postgres:latest'
imagePullPolicy: IfNotPresent
ports:
- containerPort: 5432
volumeMounts:
- mountPath: '/var/lib/postgresql/data'
name: database
env:
- name: POSTGRES_USER
value: recipes
- name: POSTGRES_DB
value: recipes
- name: POSTGRES_PASSWORD
value: # CHANGEME
volumes:
- name: database
persistentVolumeClaim:
claimName: recipes-db
- name: media
persistentVolumeClaim:
claimName: recipes-media
- name: static
persistentVolumeClaim:
claimName: recipes-static
- name: nginx-config
configMap:
name: recipes-nginx-config

15
docs/k8s/60-service.yaml Normal file
View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: recipes
labels:
app: recipes
spec:
selector:
app: recipes
environment: production
ports:
- port: 80
targetPort: http
name: http
protocol: TCP

25
docs/k8s/README.md Normal file
View File

@@ -0,0 +1,25 @@
# Kubernetes
This is a basic kubernetes setup. Please note that this does not necessarily follow Kubernetes best practices and should only used as a basis to build your own setup from!
## Important notes
State (database, static files and media files) is handled via `PersistentVolumes`.
Note that you will most likely have to change the `PersistentVolumes` in `30-pv.yaml`. The current setup is only usable for a single-node cluster because it uses local storage on the kubernetes worker nodes under `/data/recipes/`. It should just serve as an example.
Currently, the deployment in `50-deployment.yaml` just pulls the `latest` tag of all containers. In a production setup, you should set this to a fixed version!
See env variables tagged with `CHANGEME` in `50-deployment.yaml` and make sure to change those! A better setup would use kubernetes secrets but this is not implemented yet.
## Updates
These manifests are not tested against new versions.
## Apply the manifets
To apply the manifest with `kubectl`, use the following command:
```
kubectl apply -f ./docs/k8s/
```

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