Merge branch 'develop' into feature/vue

This commit is contained in:
vabene1111 2021-01-11 10:04:55 +01:00
commit 5bb20bd479
61 changed files with 2102 additions and 662 deletions

View File

@ -1,5 +1,11 @@
from django.contrib import admin from django.contrib import admin
from .models import *
from .models import (Comment, CookLog, Food, Ingredient, InviteLink, Keyword,
MealPlan, MealType, NutritionInformation, Recipe,
RecipeBook, RecipeBookEntry, RecipeImport, ShareLink,
ShoppingList, ShoppingListEntry, ShoppingListRecipe,
Space, Step, Storage, Sync, SyncLog, Unit, UserPreference,
ViewLog)
class SpaceAdmin(admin.ModelAdmin): class SpaceAdmin(admin.ModelAdmin):
@ -10,7 +16,10 @@ admin.site.register(Space, SpaceAdmin)
class UserPreferenceAdmin(admin.ModelAdmin): class UserPreferenceAdmin(admin.ModelAdmin):
list_display = ('name', 'theme', 'nav_color', 'default_page', 'search_style', 'comments') list_display = (
'name', 'theme', 'nav_color',
'default_page', 'search_style', 'comments'
)
@staticmethod @staticmethod
def name(obj): def name(obj):
@ -133,7 +142,10 @@ admin.site.register(ViewLog, ViewLogAdmin)
class InviteLinkAdmin(admin.ModelAdmin): class InviteLinkAdmin(admin.ModelAdmin):
list_display = ('username', 'group', 'valid_until', 'created_by', 'created_at', 'used_by') list_display = (
'username', 'group', 'valid_until',
'created_by', 'created_at', 'used_by'
)
admin.site.register(InviteLink, InviteLinkAdmin) admin.site.register(InviteLink, InviteLinkAdmin)

View File

@ -1,18 +1,26 @@
import django_filters import django_filters
from django.conf import settings
from django.contrib.postgres.search import TrigramSimilarity from django.contrib.postgres.search import TrigramSimilarity
from django.db.models import Q from django.db.models import Q
from cookbook.forms import MultiSelectWidget
from cookbook.models import Recipe, Keyword, Food, ShoppingList
from django.conf import settings
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from cookbook.forms import MultiSelectWidget
from cookbook.models import Food, Keyword, Recipe, ShoppingList
class RecipeFilter(django_filters.FilterSet): class RecipeFilter(django_filters.FilterSet):
name = django_filters.CharFilter(method='filter_name') name = django_filters.CharFilter(method='filter_name')
keywords = django_filters.ModelMultipleChoiceFilter(queryset=Keyword.objects.all(), widget=MultiSelectWidget, keywords = django_filters.ModelMultipleChoiceFilter(
method='filter_keywords') queryset=Keyword.objects.all(),
foods = django_filters.ModelMultipleChoiceFilter(queryset=Food.objects.all(), widget=MultiSelectWidget, widget=MultiSelectWidget,
method='filter_foods', label=_('Ingredients')) method='filter_keywords'
)
foods = django_filters.ModelMultipleChoiceFilter(
queryset=Food.objects.all(),
widget=MultiSelectWidget,
method='filter_foods',
label=_('Ingredients')
)
@staticmethod @staticmethod
def filter_keywords(queryset, name, value): def filter_keywords(queryset, name, value):
@ -27,16 +35,20 @@ class RecipeFilter(django_filters.FilterSet):
if not name == 'foods': if not name == 'foods':
return queryset return queryset
for x in value: for x in value:
queryset = queryset.filter(steps__ingredients__food__name=x).distinct() queryset = queryset.filter(
steps__ingredients__food__name=x
).distinct()
return queryset return queryset
@staticmethod @staticmethod
def filter_name(queryset, name, value): def filter_name(queryset, name, value):
if not name == 'name': if not name == 'name':
return queryset return queryset
if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql_psycopg2': if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql_psycopg2': # noqa: E501
queryset = queryset.annotate(similarity=TrigramSimilarity('name', value), ).filter( queryset = queryset \
Q(similarity__gt=0.1) | Q(name__icontains=value)).order_by('-similarity') .annotate(similarity=TrigramSimilarity('name', value), ) \
.filter(Q(similarity__gt=0.1) | Q(name__icontains=value)) \
.order_by('-similarity')
else: else:
queryset = queryset.filter(name__icontains=value) queryset = queryset.filter(name__icontains=value)
return queryset return queryset

View File

@ -3,7 +3,9 @@ from django.forms import widgets
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from emoji_picker.widgets import EmojiPickerTextInput from emoji_picker.widgets import EmojiPickerTextInput
from .models import * from .models import (Comment, Food, InviteLink, Keyword, MealPlan, Recipe,
RecipeBook, RecipeBookEntry, Storage, Sync, Unit, User,
UserPreference)
class SelectWidget(widgets.Select): class SelectWidget(widgets.Select):
@ -16,7 +18,8 @@ class MultiSelectWidget(widgets.SelectMultiple):
js = ('custom/js/form_multiselect.js',) js = ('custom/js/form_multiselect.js',)
# yes there are some stupid browsers that still dont support this but i dont support people using these browsers # Yes there are some stupid browsers that still dont support this but
# I dont support people using these browsers.
class DateWidget(forms.DateInput): class DateWidget(forms.DateInput):
input_type = 'date' input_type = 'date'
@ -30,20 +33,26 @@ class UserPreferenceForm(forms.ModelForm):
class Meta: class Meta:
model = UserPreference model = UserPreference
fields = ('default_unit', 'use_fractions', 'theme', 'nav_color', 'sticky_navbar', 'default_page', 'show_recent', 'search_style', 'plan_share', 'ingredient_decimals', 'shopping_auto_sync', 'comments') fields = (
'default_unit', 'use_fractions', 'theme', 'nav_color',
'sticky_navbar', 'default_page', 'show_recent', 'search_style',
'plan_share', 'ingredient_decimals', 'shopping_auto_sync',
'comments'
)
help_texts = { 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!'), # noqa: E501
'default_unit': _('Default Unit to be used when inserting a new ingredient into a recipe.'), 'default_unit': _('Default Unit to be used when inserting a new ingredient into a recipe.'), # noqa: E501
'use_fractions': _('Enables support for fractions in ingredient amounts (e.g. convert decimals to fractions automatically)'), 'use_fractions': _('Enables support for fractions in ingredient amounts (e.g. convert decimals to fractions automatically)'), # noqa: E501
'plan_share': _('Users with whom newly created meal plan/shopping list entries should be shared by default.'), 'plan_share': _('Users with whom newly created meal plan/shopping list entries should be shared by default.'), # noqa: E501
'show_recent': _('Show recently viewed recipes on search page.'), 'show_recent': _('Show recently viewed recipes on search page.'), # noqa: E501
'ingredient_decimals': _('Number of decimals to round ingredients.'), 'ingredient_decimals': _('Number of decimals to round ingredients.'), # noqa: E501
'comments': _('If you want to be able to create and see comments underneath recipes.'), 'comments': _('If you want to be able to create and see comments underneath recipes.'), # noqa: E501
'shopping_auto_sync': _( 'shopping_auto_sync': _(
'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit ' 'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit ' # noqa: E501
'of mobile data. If lower than instance limit it is reset when saving.'), 'of mobile data. If lower than instance limit it is reset when saving.' # noqa: E501
'sticky_navbar': _('Makes the navbar stick to the top of the page.') ),
'sticky_navbar': _('Makes the navbar stick to the top of the page.') # noqa: E501
} }
widgets = { widgets = {
@ -59,18 +68,25 @@ class UserNameForm(forms.ModelForm):
fields = ('first_name', 'last_name') fields = ('first_name', 'last_name')
help_texts = { help_texts = {
'first_name': _('Both fields are optional. If none are given the username will be displayed instead') 'first_name': _('Both fields are optional. If none are given the username will be displayed instead') # noqa: E501
} }
class ExternalRecipeForm(forms.ModelForm): class ExternalRecipeForm(forms.ModelForm):
file_path = forms.CharField(disabled=True, required=False) file_path = forms.CharField(disabled=True, required=False)
storage = forms.ModelChoiceField(queryset=Storage.objects.all(), disabled=True, required=False) storage = forms.ModelChoiceField(
queryset=Storage.objects.all(),
disabled=True,
required=False
)
file_uid = forms.CharField(disabled=True, required=False) file_uid = forms.CharField(disabled=True, required=False)
class Meta: class Meta:
model = Recipe model = Recipe
fields = ('name', 'keywords', 'working_time', 'waiting_time', 'file_path', 'storage', 'file_uid') fields = (
'name', 'keywords', 'working_time', 'waiting_time',
'file_path', 'storage', 'file_uid'
)
labels = { labels = {
'name': _('Name'), 'name': _('Name'),
@ -88,7 +104,10 @@ class InternalRecipeForm(forms.ModelForm):
class Meta: class Meta:
model = Recipe model = Recipe
fields = ('name', 'image', 'working_time', 'waiting_time', 'servings', 'keywords') fields = (
'name', 'image', 'working_time',
'waiting_time', 'servings', 'keywords'
)
labels = { labels = {
'name': _('Name'), 'name': _('Name'),
@ -106,7 +125,7 @@ class ShoppingForm(forms.Form):
widget=MultiSelectWidget widget=MultiSelectWidget
) )
markdown_format = forms.BooleanField( markdown_format = forms.BooleanField(
help_text=_('Include <code>- [ ]</code> in list for easier usage in markdown based documents.'), help_text=_('Include <code>- [ ]</code> in list for easier usage in markdown based documents.'), # noqa: E501
required=False, required=False,
initial=False initial=False
) )
@ -128,7 +147,10 @@ class ExportForm(forms.Form):
class ImportForm(forms.Form): class ImportForm(forms.Form):
recipe = forms.CharField(widget=forms.Textarea, help_text=_('Simply paste a JSON export into this textarea and click import.')) recipe = forms.CharField(
widget=forms.Textarea,
help_text=_('Simply paste a JSON export into this textarea and click import.') # noqa: E501
)
class UnitMergeForm(forms.Form): class UnitMergeForm(forms.Form):
@ -195,21 +217,31 @@ class FoodForm(forms.ModelForm):
class StorageForm(forms.ModelForm): class StorageForm(forms.ModelForm):
username = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password'}), required=False) username = forms.CharField(
password = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}), widget=forms.TextInput(attrs={'autocomplete': 'new-password'}),
required=False, required=False
help_text=_('Leave empty for dropbox and enter app password for nextcloud.')) )
token = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}), password = forms.CharField(
required=False, widget=forms.TextInput(
help_text=_('Leave empty for nextcloud and enter api token for dropbox.')) attrs={'autocomplete': 'new-password', 'type': 'password'}
),
required=False,
help_text=_('Leave empty for dropbox and enter app password for nextcloud.') # noqa: E501
)
token = forms.CharField(
widget=forms.TextInput(
attrs={'autocomplete': 'new-password', 'type': 'password'}
),
required=False,
help_text=_('Leave empty for nextcloud and enter api token for dropbox.') # noqa: E501
)
class Meta: class Meta:
model = Storage model = Storage
fields = ('name', 'method', 'username', 'password', 'token', 'url') fields = ('name', 'method', 'username', 'password', 'token', 'url')
help_texts = { help_texts = {
'url': _( 'url': _('Leave empty for dropbox and enter only base url for nextcloud (<code>/remote.php/webdav/</code> is added automatically)'), # noqa: E501
'Leave empty for dropbox and enter only base url for nextcloud (<code>/remote.php/webdav/</code> is added automatically)'),
} }
@ -229,8 +261,11 @@ class SyncForm(forms.ModelForm):
class BatchEditForm(forms.Form): class BatchEditForm(forms.Form):
search = forms.CharField(label=_('Search String')) search = forms.CharField(label=_('Search String'))
keywords = forms.ModelMultipleChoiceField(queryset=Keyword.objects.all().order_by('id'), required=False, keywords = forms.ModelMultipleChoiceField(
widget=MultiSelectWidget) queryset=Keyword.objects.all().order_by('id'),
required=False,
widget=MultiSelectWidget
)
class ImportRecipeForm(forms.ModelForm): class ImportRecipeForm(forms.ModelForm):
@ -260,20 +295,29 @@ class MealPlanForm(forms.ModelForm):
cleaned_data = super(MealPlanForm, self).clean() cleaned_data = super(MealPlanForm, self).clean()
if cleaned_data['title'] == '' and cleaned_data['recipe'] is None: if cleaned_data['title'] == '' and cleaned_data['recipe'] is None:
raise forms.ValidationError(_('You must provide at least a recipe or a title.')) raise forms.ValidationError(
_('You must provide at least a recipe or a title.')
)
return cleaned_data return cleaned_data
class Meta: class Meta:
model = MealPlan model = MealPlan
fields = ('recipe', 'title', 'meal_type', 'note', 'servings', 'date', 'shared') fields = (
'recipe', 'title', 'meal_type', 'note',
'servings', 'date', 'shared'
)
help_texts = { help_texts = {
'shared': _('You can list default users to share recipes with in the settings.'), 'shared': _('You can list default users to share recipes with in the settings.'), # noqa: E501
'note': _('You can use markdown to format this field. See the <a href="/docs/markdown/">docs here</a>') 'note': _('You can use markdown to format this field. See the <a href="/docs/markdown/">docs here</a>') # noqa: E501
} }
widgets = {'recipe': SelectWidget, 'date': DateWidget, 'shared': MultiSelectWidget} widgets = {
'recipe': SelectWidget,
'date': DateWidget,
'shared': MultiSelectWidget
}
class InviteLinkForm(forms.ModelForm): class InviteLinkForm(forms.ModelForm):
@ -281,11 +325,19 @@ class InviteLinkForm(forms.ModelForm):
model = InviteLink model = InviteLink
fields = ('username', 'group', 'valid_until') fields = ('username', 'group', 'valid_until')
help_texts = { help_texts = {
'username': _('A username is not required, if left blank the new user can choose one.') 'username': _('A username is not required, if left blank the new user can choose one.') # noqa: E501
} }
class UserCreateForm(forms.Form): class UserCreateForm(forms.Form):
name = forms.CharField(label='Username') name = forms.CharField(label='Username')
password = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'})) password = forms.CharField(
password_confirm = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'})) widget=forms.TextInput(
attrs={'autocomplete': 'new-password', 'type': 'password'}
)
)
password_confirm = forms.CharField(
widget=forms.TextInput(
attrs={'autocomplete': 'new-password', 'type': 'password'}
)
)

View File

@ -1 +1,5 @@
from cookbook.helper.dal import * import cookbook.helper.dal
__all__ = [
'dal',
]

View File

@ -1,14 +1,16 @@
from cookbook.models import Food, Keyword, Recipe, Unit
from dal import autocomplete from dal import autocomplete
from cookbook.models import Keyword, Recipe, Unit, Food
class BaseAutocomplete(autocomplete.Select2QuerySetView):
model = None
class KeywordAutocomplete(autocomplete.Select2QuerySetView):
def get_queryset(self): def get_queryset(self):
if not self.request.user.is_authenticated: if not self.request.user.is_authenticated:
return Keyword.objects.none() return self.model.objects.none()
qs = Keyword.objects.all() qs = self.model.objects.all()
if self.q: if self.q:
qs = qs.filter(name__istartswith=self.q) qs = qs.filter(name__istartswith=self.q)
@ -16,40 +18,17 @@ class KeywordAutocomplete(autocomplete.Select2QuerySetView):
return qs return qs
class IngredientsAutocomplete(autocomplete.Select2QuerySetView): class KeywordAutocomplete(BaseAutocomplete):
def get_queryset(self): model = Keyword
if not self.request.user.is_authenticated:
return Food.objects.none()
qs = Food.objects.all()
if self.q:
qs = qs.filter(name__icontains=self.q)
return qs
class RecipeAutocomplete(autocomplete.Select2QuerySetView): class IngredientsAutocomplete(BaseAutocomplete):
def get_queryset(self): model = Food
if not self.request.user.is_authenticated:
return Recipe.objects.none()
qs = Recipe.objects.all()
if self.q:
qs = qs.filter(name__icontains=self.q)
return qs
class UnitAutocomplete(autocomplete.Select2QuerySetView): class RecipeAutocomplete(BaseAutocomplete):
def get_queryset(self): model = Recipe
if not self.request.user.is_authenticated:
return Unit.objects.none()
qs = Unit.objects.all()
if self.q: class UnitAutocomplete(BaseAutocomplete):
qs = qs.filter(name__icontains=self.q) model = Unit
return qs

View File

@ -1,11 +1,12 @@
import unicodedata
import string import string
import unicodedata
def parse_fraction(x): def parse_fraction(x):
if len(x) == 1 and 'fraction' in unicodedata.decomposition(x): if len(x) == 1 and 'fraction' in unicodedata.decomposition(x):
frac_split = unicodedata.decomposition(x[-1:]).split() frac_split = unicodedata.decomposition(x[-1:]).split()
return float((frac_split[1]).replace('003', '')) / float((frac_split[3]).replace('003', '')) return (float((frac_split[1]).replace('003', ''))
/ float((frac_split[3]).replace('003', '')))
else: else:
frac_split = x.split('/') frac_split = x.split('/')
if not len(frac_split) == 2: if not len(frac_split) == 2:
@ -22,7 +23,17 @@ def parse_amount(x):
did_check_frac = False did_check_frac = False
end = 0 end = 0
while end < len(x) and (x[end] in string.digits or ((x[end] == '.' or x[end] == ',') and end + 1 < len(x) and x[end + 1] in string.digits)): while (
end < len(x)
and (
x[end] in string.digits
or (
(x[end] == '.' or x[end] == ',')
and end + 1 < len(x)
and x[end + 1] in string.digits
)
)
):
end += 1 end += 1
if end > 0: if end > 0:
amount = float(x[:end].replace(',', '.')) amount = float(x[:end].replace(',', '.'))
@ -70,13 +81,13 @@ def parse_ingredient(tokens):
while not tokens[start].startswith('(') and not start == 0: while not tokens[start].startswith('(') and not start == 0:
start -= 1 start -= 1
if start == 0: if start == 0:
# the whole list is wrapped in brackets -> assume it is an error (e.g. assumed first argument was the unit) # the whole list is wrapped in brackets -> assume it is an error (e.g. assumed first argument was the unit) # noqa: E501
raise ValueError raise ValueError
elif start < 0: elif start < 0:
# no opening bracket anywhere -> just ignore the last bracket # no opening bracket anywhere -> just ignore the last bracket
ingredient, note = parse_ingredient_with_comma(tokens) ingredient, note = parse_ingredient_with_comma(tokens)
else: else:
# opening bracket found -> split in ingredient and note, remove brackets from note # opening bracket found -> split in ingredient and note, remove brackets from note # noqa: E501
note = ' '.join(tokens[start:])[1:-1] note = ' '.join(tokens[start:])[1:-1]
ingredient = ' '.join(tokens[:start]) ingredient = ' '.join(tokens[:start])
else: else:
@ -99,19 +110,20 @@ def parse(x):
try: try:
# try to parse first argument as amount # try to parse first argument as amount
amount, unit = parse_amount(tokens[0]) amount, unit = parse_amount(tokens[0])
# only try to parse second argument as amount if there are at least three arguments # only try to parse second argument as amount if there are at least
# if it already has a unit there can't be a fraction for the amount # three arguments if it already has a unit there can't be
# a fraction for the amount
if len(tokens) > 2: if len(tokens) > 2:
try: try:
if not unit == '': if not unit == '':
# a unit is already found, no need to try the second argument for a fraction # a unit is already found, no need to try the second argument for a fraction # noqa: E501
# probably not the best method to do it, but I didn't want to make an if check and paste the exact same thing in the else as already is in the except # probably not the best method to do it, but I didn't want to make an if check and paste the exact same thing in the else as already is in the except # noqa: E501
raise ValueError raise ValueError
# try to parse second argument as amount and add that, in case of '2 1/2' or '2 ½' # try to parse second argument as amount and add that, in case of '2 1/2' or '2 ½' # noqa: E501
amount += parse_fraction(tokens[1]) amount += parse_fraction(tokens[1])
# assume that units can't end with a comma # assume that units can't end with a comma
if len(tokens) > 3 and not tokens[2].endswith(','): if len(tokens) > 3 and not tokens[2].endswith(','):
# try to use third argument as unit and everything else as ingredient, use everything as ingredient if it fails # try to use third argument as unit and everything else as ingredient, use everything as ingredient if it fails # noqa: E501
try: try:
ingredient, note = parse_ingredient(tokens[3:]) ingredient, note = parse_ingredient(tokens[3:])
unit = tokens[2] unit = tokens[2]
@ -122,7 +134,7 @@ def parse(x):
except ValueError: except ValueError:
# assume that units can't end with a comma # assume that units can't end with a comma
if not tokens[1].endswith(','): if not tokens[1].endswith(','):
# try to use second argument as unit and everything else as ingredient, use everything as ingredient if it fails # try to use second argument as unit and everything else as ingredient, use everything as ingredient if it fails # noqa: E501
try: try:
ingredient, note = parse_ingredient(tokens[2:]) ingredient, note = parse_ingredient(tokens[2:])
unit = tokens[1] unit = tokens[1]
@ -131,11 +143,13 @@ def parse(x):
else: else:
ingredient, note = parse_ingredient(tokens[1:]) ingredient, note = parse_ingredient(tokens[1:])
else: else:
# only two arguments, first one is the amount which means this is the ingredient # only two arguments, first one is the amount
# which means this is the ingredient
ingredient = tokens[1] ingredient = tokens[1]
except ValueError: except ValueError:
try: try:
# can't parse first argument as amount -> no unit -> parse everything as ingredient # can't parse first argument as amount
# -> no unit -> parse everything as ingredient
ingredient, note = parse_ingredient(tokens) ingredient, note = parse_ingredient(tokens)
except ValueError: except ValueError:
ingredient = ' '.join(tokens[1:]) ingredient = ' '.join(tokens[1:])

View File

@ -1,5 +1,4 @@
import markdown import markdown
from markdown.treeprocessors import Treeprocessor from markdown.treeprocessors import Treeprocessor
@ -21,4 +20,8 @@ class StyleTreeprocessor(Treeprocessor):
class MarkdownFormatExtension(markdown.Extension): class MarkdownFormatExtension(markdown.Extension):
def extendMarkdown(self, md, md_globals): def extendMarkdown(self, md, md_globals):
md.treeprocessors.register(StyleTreeprocessor(), 'StyleTreeprocessor', 10) md.treeprocessors.register(
StyleTreeprocessor(),
'StyleTreeprocessor',
10
)

View File

@ -1,4 +1,5 @@
"""A more liberal autolinker """
A more liberal autolinker
Inspired by Django's urlize function. Inspired by Django's urlize function.
@ -45,27 +46,30 @@ URLIZE_RE = '(%s)' % '|'.join([
r'[^(<\s]+\.(?:com|net|org)\b', r'[^(<\s]+\.(?:com|net|org)\b',
]) ])
class UrlizePattern(markdown.inlinepatterns.Pattern): class UrlizePattern(markdown.inlinepatterns.Pattern):
""" Return a link Element given an autolink (`http://example/com`). """ """ Return a link Element given an autolink (`http://example/com`). """
def handleMatch(self, m): def handleMatch(self, m):
url = m.group(2) url = m.group(2)
if url.startswith('<'): if url.startswith('<'):
url = url[1:-1] url = url[1:-1]
text = url text = url
if not url.split('://')[0] in ('http','https','ftp'): if not url.split('://')[0] in ('http', 'https', 'ftp'):
if '@' in url and not '/' in url: if '@' in url and '/' not in url:
url = 'mailto:' + url url = 'mailto:' + url
else: else:
url = 'http://' + url url = 'http://' + url
el = markdown.util.etree.Element("a") el = markdown.util.etree.Element("a")
el.set('href', url) el.set('href', url)
el.text = markdown.util.AtomicString(text) el.text = markdown.util.AtomicString(text)
return el return el
class UrlizeExtension(markdown.Extension): class UrlizeExtension(markdown.Extension):
""" Urlize Extension for Python-Markdown. """ """ Urlize Extension for Python-Markdown. """
@ -73,9 +77,12 @@ class UrlizeExtension(markdown.Extension):
""" Replace autolink with UrlizePattern """ """ Replace autolink with UrlizePattern """
md.inlinePatterns['autolink'] = UrlizePattern(URLIZE_RE, md) md.inlinePatterns['autolink'] = UrlizePattern(URLIZE_RE, md)
def makeExtension(*args, **kwargs): def makeExtension(*args, **kwargs):
return UrlizeExtension(*args, **kwargs) return UrlizeExtension(*args, **kwargs)
if __name__ == "__main__": if __name__ == "__main__":
import doctest import doctest
doctest.testmod() doctest.testmod()

View File

@ -1,5 +1,4 @@
# Permission Config from cookbook.helper.permission_helper import CustomIsUser
from cookbook.helper.permission_helper import CustomIsUser, CustomIsOwner, CustomIsAdmin, CustomIsGuest
class PermissionConfig: class PermissionConfig:

View File

@ -1,20 +1,16 @@
""" """
Source: https://djangosnippets.org/snippets/1703/ Source: https://djangosnippets.org/snippets/1703/
""" """
from cookbook.models import ShareLink
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import user_passes_test from django.contrib.auth.decorators import user_passes_test
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.urls import reverse_lazy, reverse from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext as _
from rest_framework import permissions from rest_framework import permissions
from rest_framework.permissions import SAFE_METHODS from rest_framework.permissions import SAFE_METHODS
from cookbook.models import ShareLink
# Helper Functions
def get_allowed_groups(groups_required): def get_allowed_groups(groups_required):
""" """
@ -34,8 +30,8 @@ def get_allowed_groups(groups_required):
def has_group_permission(user, groups): def has_group_permission(user, groups):
""" """
Tests if a given user is member of a certain group (or any higher group) Tests if a given user is member of a certain group (or any higher group)
Superusers always bypass permission checks. Unauthenticated users cant be member of any Superusers always bypass permission checks.
group thus always return false. Unauthenticated users cant be member of any group thus always return false.
:param user: django auth user object :param user: django auth user object
:param groups: list or tuple of groups the user should be checked for :param groups: list or tuple of groups the user should be checked for
:return: True if user is in allowed groups, false otherwise :return: True if user is in allowed groups, false otherwise
@ -44,7 +40,8 @@ def has_group_permission(user, groups):
return False return False
groups_allowed = get_allowed_groups(groups) groups_allowed = get_allowed_groups(groups)
if user.is_authenticated: if user.is_authenticated:
if user.is_superuser | bool(user.groups.filter(name__in=groups_allowed)): if (user.is_superuser
| bool(user.groups.filter(name__in=groups_allowed))):
return True return True
return False return False
@ -52,13 +49,15 @@ def has_group_permission(user, groups):
def is_object_owner(user, obj): def is_object_owner(user, obj):
""" """
Tests if a given user is the owner of a given object Tests if a given user is the owner of a given object
test performed by checking user against the objects user and create_by field (if exists) test performed by checking user against the objects user
and create_by field (if exists)
superusers bypass all checks, unauthenticated users cannot own anything superusers bypass all checks, unauthenticated users cannot own anything
:param user django auth user object :param user django auth user object
:param obj any object that should be tested :param obj any object that should be tested
:return: true if user is owner of object, false otherwise :return: true if user is owner of object, false otherwise
""" """
# TODO this could be improved/cleaned up by adding get_owner methods to all models that allow owner checks # TODO this could be improved/cleaned up by adding
# get_owner methods to all models that allow owner checks
if not user.is_authenticated: if not user.is_authenticated:
return False return False
if user.is_superuser: if user.is_superuser:
@ -81,7 +80,8 @@ def is_object_shared(user, obj):
:param obj any object that should be tested :param obj any object that should be tested
:return: true if user is shared for object, false otherwise :return: true if user is shared for object, false otherwise
""" """
# TODO this could be improved/cleaned up by adding share checks for relevant objects # TODO this could be improved/cleaned up by adding
# share checks for relevant objects
if not user.is_authenticated: if not user.is_authenticated:
return False return False
if user.is_superuser: if user.is_superuser:
@ -94,10 +94,14 @@ def share_link_valid(recipe, share):
Verifies the validity of a share uuid Verifies the validity of a share uuid
:param recipe: recipe object :param recipe: recipe object
:param share: share uuid :param share: share uuid
:return: true if a share link with the given recipe and uuid exists, false otherwise :return: true if a share link with the given recipe and uuid exists
""" """
try: try:
return True if ShareLink.objects.filter(recipe=recipe, uuid=share).exists() else False return (
True
if ShareLink.objects.filter(recipe=recipe, uuid=share).exists()
else False
)
except ValidationError: except ValidationError:
return False return False
@ -106,8 +110,8 @@ def share_link_valid(recipe, share):
def group_required(*groups_required): def group_required(*groups_required):
""" """
Decorator that tests the requesting user to be member of at least one of the provided groups Decorator that tests the requesting user to be member
or higher level groups of at least one of the provided groups or higher level groups
:param groups_required: list of required groups :param groups_required: list of required groups
:return: true if member of group, false otherwise :return: true if member of group, false otherwise
""" """
@ -127,24 +131,40 @@ class GroupRequiredMixin(object):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
if not has_group_permission(request.user, self.groups_required): if not has_group_permission(request.user, self.groups_required):
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!')) messages.add_message(
request,
messages.ERROR,
_('You do not have the required permissions to view this page!') # noqa: E501
)
return HttpResponseRedirect(reverse_lazy('index')) return HttpResponseRedirect(reverse_lazy('index'))
return super(GroupRequiredMixin, self).dispatch(request, *args, **kwargs) return super(GroupRequiredMixin, self) \
.dispatch(request, *args, **kwargs)
class OwnerRequiredMixin(object): class OwnerRequiredMixin(object):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated: if not request.user.is_authenticated:
messages.add_message(request, messages.ERROR, _('You are not logged in and therefore cannot view this page!')) messages.add_message(
return HttpResponseRedirect(reverse_lazy('login') + '?next=' + request.path) request,
messages.ERROR,
_('You are not logged in and therefore cannot view this page!')
)
return HttpResponseRedirect(
reverse_lazy('login') + '?next=' + request.path
)
else: else:
if not is_object_owner(request.user, self.get_object()): if not is_object_owner(request.user, self.get_object()):
messages.add_message(request, messages.ERROR, _('You cannot interact with this object as it is not owned by you!')) messages.add_message(
request,
messages.ERROR,
_('You cannot interact with this object as it is not owned by you!') # noqa: E501
)
return HttpResponseRedirect(reverse('index')) return HttpResponseRedirect(reverse('index'))
return super(OwnerRequiredMixin, self).dispatch(request, *args, **kwargs) return super(OwnerRequiredMixin, self) \
.dispatch(request, *args, **kwargs)
# Django Rest Framework Permission classes # Django Rest Framework Permission classes
@ -155,7 +175,7 @@ class CustomIsOwner(permissions.BasePermission):
verifies user has ownership over object verifies user has ownership over object
(either user or created_by or user is request user) (either user or created_by or user is request user)
""" """
message = _('You cannot interact with this object as it is not owned by you!') message = _('You cannot interact with this object as it is not owned by you!') # noqa: E501
def has_permission(self, request, view): def has_permission(self, request, view):
return request.user.is_authenticated return request.user.is_authenticated
@ -164,12 +184,13 @@ class CustomIsOwner(permissions.BasePermission):
return is_object_owner(request.user, obj) return is_object_owner(request.user, obj)
class CustomIsShared(permissions.BasePermission): # TODO function duplicate/too similar name # TODO function duplicate/too similar name
class CustomIsShared(permissions.BasePermission):
""" """
Custom permission class for django rest framework views Custom permission class for django rest framework views
verifies user is shared for the object he is trying to access verifies user is shared for the object he is trying to access
""" """
message = _('You cannot interact with this object as it is not owned by you!') message = _('You cannot interact with this object as it is not owned by you!') # noqa: E501
def has_permission(self, request, view): def has_permission(self, request, view):
return request.user.is_authenticated return request.user.is_authenticated

View File

@ -1,18 +1,16 @@
import json import json
import random import random
import re import re
import unicodedata
from json import JSONDecodeError from json import JSONDecodeError
import microdata import microdata
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from cookbook.helper.ingredient_parser import parse as parse_ingredient
from cookbook.models import Keyword
from django.http import JsonResponse from django.http import JsonResponse
from django.utils.dateparse import parse_duration from django.utils.dateparse import parse_duration
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from cookbook.models import Keyword
from cookbook.helper.ingredient_parser import parse as parse_ingredient
def get_from_html(html_text, url): def get_from_html(html_text, url):
soup = BeautifulSoup(html_text, "html.parser") soup = BeautifulSoup(html_text, "html.parser")
@ -31,10 +29,16 @@ def get_from_html(html_text, url):
if '@type' in x and x['@type'] == 'Recipe': if '@type' in x and x['@type'] == 'Recipe':
ld_json_item = x ld_json_item = x
if '@type' in ld_json_item and ld_json_item['@type'] == 'Recipe': if ('@type' in ld_json_item
and ld_json_item['@type'] == 'Recipe'):
return find_recipe_json(ld_json_item, url) return find_recipe_json(ld_json_item, url)
except JSONDecodeError as e: except JSONDecodeError:
return JsonResponse({'error': True, 'msg': _('The requested site provided malformed data and cannot be read.')}, status=400) return JsonResponse(
{
'error': True,
'msg': _('The requested site provided malformed data and cannot be read.') # noqa: E501
},
status=400)
# now try to find microdata # now try to find microdata
items = microdata.get_items(html_text) items = microdata.get_items(html_text)
@ -43,14 +47,19 @@ def get_from_html(html_text, url):
if 'schema.org/Recipe' in str(md_json['type']): if 'schema.org/Recipe' in str(md_json['type']):
return find_recipe_json(md_json['properties'], url) return find_recipe_json(md_json['properties'], url)
return JsonResponse({'error': True, 'msg': _('The requested site does not provide any recognized data format to import the recipe from.')}, status=400) return JsonResponse(
{
'error': True,
'msg': _('The requested site does not provide any recognized data format to import the recipe from.') # noqa: E501
},
status=400)
def find_recipe_json(ld_json, url): def find_recipe_json(ld_json, url):
if type(ld_json['name']) == list: if type(ld_json['name']) == list:
try: try:
ld_json['name'] = ld_json['name'][0] ld_json['name'] = ld_json['name'][0]
except: except Exception:
ld_json['name'] = 'ERROR' ld_json['name'] = 'ERROR'
# some sites use ingredients instead of recipeIngredients # some sites use ingredients instead of recipeIngredients
@ -59,8 +68,9 @@ def find_recipe_json(ld_json, url):
if 'recipeIngredient' in ld_json: if 'recipeIngredient' in ld_json:
# some pages have comma separated ingredients in a single array entry # some pages have comma separated ingredients in a single array entry
if len(ld_json['recipeIngredient']) == 1 and len(ld_json['recipeIngredient'][0]) > 30: if (len(ld_json['recipeIngredient']) == 1
ld_json['recipeIngredient'] = ld_json['recipeIngredient'][0].split(',') and len(ld_json['recipeIngredient'][0]) > 30):
ld_json['recipeIngredient'] = ld_json['recipeIngredient'][0].split(',') # noqa: E501
for x in ld_json['recipeIngredient']: for x in ld_json['recipeIngredient']:
if '\n' in x: if '\n' in x:
@ -71,13 +81,41 @@ def find_recipe_json(ld_json, url):
ingredients = [] ingredients = []
for x in ld_json['recipeIngredient']: for x in ld_json['recipeIngredient']:
if x.replace(' ','') != '': if x.replace(' ', '') != '':
try: try:
amount, unit, ingredient, note = parse_ingredient(x) amount, unit, ingredient, note = parse_ingredient(x)
if ingredient: if ingredient:
ingredients.append({'amount': amount, 'unit': {'text': unit, 'id': random.randrange(10000, 99999)}, 'ingredient': {'text': ingredient, 'id': random.randrange(10000, 99999)}, "note": note, 'original': x}) ingredients.append(
except: {
ingredients.append({'amount': 0, 'unit': {'text': "", 'id': random.randrange(10000, 99999)}, 'ingredient': {'text': x, 'id': random.randrange(10000, 99999)}, "note": "", 'original': x}) 'amount': amount,
'unit': {
'text': unit,
'id': random.randrange(10000, 99999)
},
'ingredient': {
'text': ingredient,
'id': random.randrange(10000, 99999)
},
'note': note,
'original': x
}
)
except Exception:
ingredients.append(
{
'amount': 0,
'unit': {
'text': '',
'id': random.randrange(10000, 99999)
},
'ingredient': {
'text': x,
'id': random.randrange(10000, 99999)
},
'note': '',
'original': x
}
)
ld_json['recipeIngredient'] = ingredients ld_json['recipeIngredient'] = ingredients
else: else:
@ -91,7 +129,9 @@ def find_recipe_json(ld_json, url):
ld_json['keywords'] = ld_json['keywords'].split(',') ld_json['keywords'] = ld_json['keywords'].split(',')
# keywords as string in list # keywords as string in list
if type(ld_json['keywords']) == list and len(ld_json['keywords']) == 1 and ',' in ld_json['keywords'][0]: if (type(ld_json['keywords']) == list
and len(ld_json['keywords']) == 1
and ',' in ld_json['keywords'][0]):
ld_json['keywords'] = ld_json['keywords'][0].split(',') ld_json['keywords'] = ld_json['keywords'][0].split(',')
# keywords as list # keywords as list
@ -126,10 +166,10 @@ def find_recipe_json(ld_json, url):
instructions += str(i) instructions += str(i)
ld_json['recipeInstructions'] = instructions ld_json['recipeInstructions'] = instructions
ld_json['recipeInstructions'] = re.sub(r'\n\s*\n', '\n\n', ld_json['recipeInstructions']) ld_json['recipeInstructions'] = re.sub(r'\n\s*\n', '\n\n', ld_json['recipeInstructions']) # noqa: E501
ld_json['recipeInstructions'] = re.sub(' +', ' ', ld_json['recipeInstructions']) ld_json['recipeInstructions'] = re.sub(' +', ' ', ld_json['recipeInstructions']) # noqa: E501
ld_json['recipeInstructions'] = ld_json['recipeInstructions'].replace('<p>', '') ld_json['recipeInstructions'] = ld_json['recipeInstructions'].replace('<p>', '') # noqa: E501
ld_json['recipeInstructions'] = ld_json['recipeInstructions'].replace('</p>', '') ld_json['recipeInstructions'] = ld_json['recipeInstructions'].replace('</p>', '') # noqa: E501
else: else:
ld_json['recipeInstructions'] = '' ld_json['recipeInstructions'] = ''
@ -149,9 +189,14 @@ def find_recipe_json(ld_json, url):
if 'cookTime' in ld_json: if 'cookTime' in ld_json:
try: try:
if type(ld_json['cookTime']) == list and len(ld_json['cookTime']) > 0: if (type(ld_json['cookTime']) == list
and len(ld_json['cookTime']) > 0):
ld_json['cookTime'] = ld_json['cookTime'][0] ld_json['cookTime'] = ld_json['cookTime'][0]
ld_json['cookTime'] = round(parse_duration(ld_json['cookTime']).seconds / 60) ld_json['cookTime'] = round(
parse_duration(
ld_json['cookTime']
).seconds / 60
)
except TypeError: except TypeError:
ld_json['cookTime'] = 0 ld_json['cookTime'] = 0
else: else:
@ -159,16 +204,24 @@ def find_recipe_json(ld_json, url):
if 'prepTime' in ld_json: if 'prepTime' in ld_json:
try: try:
if type(ld_json['prepTime']) == list and len(ld_json['prepTime']) > 0: if (type(ld_json['prepTime']) == list
and len(ld_json['prepTime']) > 0):
ld_json['prepTime'] = ld_json['prepTime'][0] ld_json['prepTime'] = ld_json['prepTime'][0]
ld_json['prepTime'] = round(parse_duration(ld_json['prepTime']).seconds / 60) ld_json['prepTime'] = round(
parse_duration(
ld_json['prepTime']
).seconds / 60
)
except TypeError: except TypeError:
ld_json['prepTime'] = 0 ld_json['prepTime'] = 0
else: else:
ld_json['prepTime'] = 0 ld_json['prepTime'] = 0
for key in list(ld_json): for key in list(ld_json):
if key not in ['prepTime', 'cookTime', 'image', 'recipeInstructions', 'keywords', 'name', 'recipeIngredient']: if key not in [
'prepTime', 'cookTime', 'image', 'recipeInstructions',
'keywords', 'name', 'recipeIngredient'
]:
ld_json.pop(key, None) ld_json.pop(key, None)
return JsonResponse(ld_json) return JsonResponse(ld_json)

View File

@ -1,10 +1,9 @@
import bleach import bleach
import markdown as md import markdown as md
from bleach_whitelist import markdown_tags, markdown_attrs from bleach_whitelist import markdown_attrs, markdown_tags
from jinja2 import Template, TemplateSyntaxError
from cookbook.helper.mdx_attributes import MarkdownFormatExtension from cookbook.helper.mdx_attributes import MarkdownFormatExtension
from cookbook.helper.mdx_urlize import UrlizeExtension from cookbook.helper.mdx_urlize import UrlizeExtension
from jinja2 import Template, TemplateSyntaxError
class IngredientObject(object): class IngredientObject(object):
@ -45,8 +44,16 @@ def render_instructions(step): # TODO deduplicate markdown cleanup code
except TemplateSyntaxError: except TemplateSyntaxError:
instructions = step.instruction instructions = step.instruction
tags = markdown_tags + ['pre', 'table', 'td', 'tr', 'th', 'tbody', 'style', 'thead'] tags = markdown_tags + [
parsed_md = md.markdown(instructions, extensions=['markdown.extensions.fenced_code', 'tables', UrlizeExtension(), MarkdownFormatExtension()]) 'pre', 'table', 'td', 'tr', 'th', 'tbody', 'style', 'thead'
]
parsed_md = md.markdown(
instructions,
extensions=[
'markdown.extensions.fenced_code', 'tables',
UrlizeExtension(), MarkdownFormatExtension()
]
)
markdown_attrs['*'] = markdown_attrs['*'] + ['class'] markdown_attrs['*'] = markdown_attrs['*'] + ['class']
return bleach.clean(parsed_md, tags, markdown_attrs) return bleach.clean(parsed_md, tags, markdown_attrs)

View File

@ -389,7 +389,7 @@ msgstr "Modifica di massa"
#: cookbook/templates/base.html:98 #: cookbook/templates/base.html:98
msgid "Storage Data" msgid "Storage Data"
msgstr "Dati di archiviazione" msgstr "Dati e Archiviazione"
#: cookbook/templates/base.html:102 #: cookbook/templates/base.html:102
msgid "Storage Backends" msgid "Storage Backends"
@ -470,8 +470,8 @@ msgstr "Modifica di massa per ricette"
#: cookbook/templates/batch/edit.html:20 #: cookbook/templates/batch/edit.html:20
msgid "Add the specified keywords to all recipes containing a word" msgid "Add the specified keywords to all recipes containing a word"
msgstr "" msgstr ""
"Aggiungi le parole chiave specificate a tutte le ricette che contengono una " "Aggiungi a tutte le ricette che contengono una determinata stringa le parole"
"parola" " chiave desiderate "
#: cookbook/templates/batch/monitor.html:6 cookbook/views/edit.py:59 #: cookbook/templates/batch/monitor.html:6 cookbook/views/edit.py:59
msgid "Sync" msgid "Sync"
@ -998,7 +998,7 @@ msgstr "Nuova Ricetta"
#: cookbook/templates/index.html:47 #: cookbook/templates/index.html:47
msgid "Website Import" msgid "Website Import"
msgstr "Importa da Sito Web" msgstr "Importa dal web"
#: cookbook/templates/index.html:53 #: cookbook/templates/index.html:53
msgid "Advanced Search" msgid "Advanced Search"
@ -1419,7 +1419,7 @@ msgstr "Commenti"
#: cookbook/templates/recipe_view.html:469 cookbook/views/delete.py:108 #: cookbook/templates/recipe_view.html:469 cookbook/views/delete.py:108
#: cookbook/views/edit.py:143 #: cookbook/views/edit.py:143
msgid "Comment" msgid "Comment"
msgstr "Commenta" msgstr "Commento"
#: cookbook/templates/recipes_table.html:46 #: cookbook/templates/recipes_table.html:46
#: cookbook/templates/url_import.html:55 #: cookbook/templates/url_import.html:55
@ -1711,7 +1711,7 @@ msgstr "Importa da URL"
#: cookbook/templates/url_import.html:23 #: cookbook/templates/url_import.html:23
msgid "Enter website URL" msgid "Enter website URL"
msgstr "Inserisci la URL del sito web" msgstr "Inserisci l'indirizzo del sito web"
#: cookbook/templates/url_import.html:44 #: cookbook/templates/url_import.html:44
msgid "Recipe Name" msgid "Recipe Name"

View File

@ -0,0 +1,31 @@
# Generated by Django 3.1.5 on 2021-01-09 19:44
from django.db import migrations
def delete_duplicate_bookmarks(apps, schema_editor):
"""
In this migration, a unique constraint is set on the fields `recipe` and `book`.
If there are already duplicate entries, the migration will fail.
Therefore all duplicate entries are deleted beforehand.
"""
RecipeBookEntry = apps.get_model('cookbook', 'RecipeBookEntry')
for row in RecipeBookEntry.objects.all():
if RecipeBookEntry.objects.filter(recipe=row.recipe, book=row.book).count() > 1:
row.delete()
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0095_auto_20210107_1804'),
]
operations = [
# run function to delete duplicated bookmarks
migrations.RunPython(delete_duplicate_bookmarks),
migrations.AlterUniqueTogether(
name='recipebookentry',
unique_together={('recipe', 'book')},
),
]

View File

@ -4,13 +4,14 @@ from datetime import date, timedelta
from annoying.fields import AutoOneToOneField from annoying.fields import AutoOneToOneField
from django.contrib import auth from django.contrib import auth
from django.contrib.auth.models import User, Group from django.contrib.auth.models import Group, User
from django.core.validators import MinLengthValidator from django.core.validators import MinLengthValidator
from django.utils.translation import gettext as _
from django.db import models from django.db import models
from django.utils.translation import gettext as _
from django_random_queryset import RandomManager from django_random_queryset import RandomManager
from recipes.settings import COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT, STICKY_NAV_PREF_DEFAULT from recipes.settings import (COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT,
STICKY_NAV_PREF_DEFAULT)
def get_user_name(self): def get_user_name(self):
@ -39,7 +40,12 @@ class UserPreference(models.Model):
FLATLY = 'FLATLY' FLATLY = 'FLATLY'
SUPERHERO = 'SUPERHERO' SUPERHERO = 'SUPERHERO'
THEMES = ((BOOTSTRAP, 'Bootstrap'), (DARKLY, 'Darkly'), (FLATLY, 'Flatly'), (SUPERHERO, 'Superhero')) THEMES = (
(BOOTSTRAP, 'Bootstrap'),
(DARKLY, 'Darkly'),
(FLATLY, 'Flatly'),
(SUPERHERO, 'Superhero')
)
# Nav colors # Nav colors
PRIMARY = 'PRIMARY' PRIMARY = 'PRIMARY'
@ -51,14 +57,26 @@ class UserPreference(models.Model):
LIGHT = 'LIGHT' LIGHT = 'LIGHT'
DARK = 'DARK' DARK = 'DARK'
COLORS = ((PRIMARY, 'Primary'), (SECONDARY, 'Secondary'), (SUCCESS, 'Success'), (INFO, 'Info'), (WARNING, 'Warning'), (DANGER, 'Danger'), (LIGHT, 'Light'), (DARK, 'Dark')) COLORS = (
(PRIMARY, 'Primary'),
(SECONDARY, 'Secondary'),
(SUCCESS, 'Success'), (INFO, 'Info'),
(WARNING, 'Warning'),
(DANGER, 'Danger'),
(LIGHT, 'Light'),
(DARK, 'Dark')
)
# Default Page # Default Page
SEARCH = 'SEARCH' SEARCH = 'SEARCH'
PLAN = 'PLAN' PLAN = 'PLAN'
BOOKS = 'BOOKS' BOOKS = 'BOOKS'
PAGES = ((SEARCH, _('Search')), (PLAN, _('Meal-Plan')), (BOOKS, _('Books')),) PAGES = (
(SEARCH, _('Search')),
(PLAN, _('Meal-Plan')),
(BOOKS, _('Books')),
)
# Search Style # Search Style
SMALL = 'SMALL' SMALL = 'SMALL'
@ -68,13 +86,21 @@ class UserPreference(models.Model):
user = AutoOneToOneField(User, on_delete=models.CASCADE, primary_key=True) user = AutoOneToOneField(User, on_delete=models.CASCADE, primary_key=True)
theme = models.CharField(choices=THEMES, max_length=128, default=FLATLY) theme = models.CharField(choices=THEMES, max_length=128, default=FLATLY)
nav_color = models.CharField(choices=COLORS, max_length=128, default=PRIMARY) nav_color = models.CharField(
choices=COLORS, max_length=128, default=PRIMARY
)
default_unit = models.CharField(max_length=32, default='g') default_unit = models.CharField(max_length=32, default='g')
use_fractions = models.BooleanField(default=FRACTION_PREF_DEFAULT) use_fractions = models.BooleanField(default=FRACTION_PREF_DEFAULT)
default_page = models.CharField(choices=PAGES, max_length=64, default=SEARCH) default_page = models.CharField(
search_style = models.CharField(choices=SEARCH_STYLE, max_length=64, default=LARGE) choices=PAGES, max_length=64, default=SEARCH
)
search_style = models.CharField(
choices=SEARCH_STYLE, max_length=64, default=LARGE
)
show_recent = models.BooleanField(default=True) show_recent = models.BooleanField(default=True)
plan_share = models.ManyToManyField(User, blank=True, related_name='plan_share_default') plan_share = models.ManyToManyField(
User, blank=True, related_name='plan_share_default'
)
ingredient_decimals = models.IntegerField(default=2) ingredient_decimals = models.IntegerField(default=2)
comments = models.BooleanField(default=COMMENT_PREF_DEFAULT) comments = models.BooleanField(default=COMMENT_PREF_DEFAULT)
shopping_auto_sync = models.IntegerField(default=5) shopping_auto_sync = models.IntegerField(default=5)
@ -90,7 +116,9 @@ class Storage(models.Model):
STORAGE_TYPES = ((DROPBOX, 'Dropbox'), (NEXTCLOUD, 'Nextcloud')) STORAGE_TYPES = ((DROPBOX, 'Dropbox'), (NEXTCLOUD, 'Nextcloud'))
name = models.CharField(max_length=128) name = models.CharField(max_length=128)
method = models.CharField(choices=STORAGE_TYPES, max_length=128, default=DROPBOX) method = models.CharField(
choices=STORAGE_TYPES, max_length=128, default=DROPBOX
)
username = models.CharField(max_length=128, blank=True, null=True) username = models.CharField(max_length=128, blank=True, null=True)
password = models.CharField(max_length=128, blank=True, null=True) password = models.CharField(max_length=128, blank=True, null=True)
token = models.CharField(max_length=512, blank=True, null=True) token = models.CharField(max_length=512, blank=True, null=True)
@ -138,7 +166,9 @@ class Keyword(models.Model):
class Unit(models.Model): class Unit(models.Model):
name = models.CharField(unique=True, max_length=128, validators=[MinLengthValidator(1)]) name = models.CharField(
unique=True, max_length=128, validators=[MinLengthValidator(1)]
)
description = models.TextField(blank=True, null=True) description = models.TextField(blank=True, null=True)
def __str__(self): def __str__(self):
@ -146,16 +176,24 @@ class Unit(models.Model):
class Food(models.Model): class Food(models.Model):
name = models.CharField(unique=True, max_length=128, validators=[MinLengthValidator(1)]) name = models.CharField(
recipe = models.ForeignKey('Recipe', null=True, blank=True, on_delete=models.SET_NULL) unique=True, max_length=128, validators=[MinLengthValidator(1)]
)
recipe = models.ForeignKey(
'Recipe', null=True, blank=True, on_delete=models.SET_NULL
)
def __str__(self): def __str__(self):
return self.name return self.name
class Ingredient(models.Model): class Ingredient(models.Model):
food = models.ForeignKey(Food, on_delete=models.PROTECT, null=True, blank=True) food = models.ForeignKey(
unit = models.ForeignKey(Unit, on_delete=models.PROTECT, null=True, blank=True) Food, on_delete=models.PROTECT, null=True, blank=True
)
unit = models.ForeignKey(
Unit, on_delete=models.PROTECT, null=True, blank=True
)
amount = models.DecimalField(default=0, decimal_places=16, max_digits=32) amount = models.DecimalField(default=0, decimal_places=16, max_digits=32)
note = models.CharField(max_length=256, null=True, blank=True) note = models.CharField(max_length=256, null=True, blank=True)
is_header = models.BooleanField(default=False) is_header = models.BooleanField(default=False)
@ -174,7 +212,11 @@ class Step(models.Model):
TIME = 'TIME' TIME = 'TIME'
name = models.CharField(max_length=128, default='', blank=True) name = models.CharField(max_length=128, default='', blank=True)
type = models.CharField(choices=((TEXT, _('Text')), (TIME, _('Time')),), default=TEXT, max_length=16) type = models.CharField(
choices=((TEXT, _('Text')), (TIME, _('Time')),),
default=TEXT,
max_length=16
)
instruction = models.TextField(blank=True) instruction = models.TextField(blank=True)
ingredients = models.ManyToManyField(Ingredient, blank=True) ingredients = models.ManyToManyField(Ingredient, blank=True)
time = models.IntegerField(default=0, blank=True) time = models.IntegerField(default=0, blank=True)
@ -191,20 +233,26 @@ class Step(models.Model):
class NutritionInformation(models.Model): class NutritionInformation(models.Model):
fats = models.DecimalField(default=0, decimal_places=16, max_digits=32) fats = models.DecimalField(default=0, decimal_places=16, max_digits=32)
carbohydrates = models.DecimalField(default=0, decimal_places=16, max_digits=32) carbohydrates = models.DecimalField(
default=0, decimal_places=16, max_digits=32
)
proteins = models.DecimalField(default=0, decimal_places=16, max_digits=32) proteins = models.DecimalField(default=0, decimal_places=16, max_digits=32)
calories = models.DecimalField(default=0, decimal_places=16, max_digits=32) calories = models.DecimalField(default=0, decimal_places=16, max_digits=32)
source = models.CharField(max_length=512, default="", null=True, blank=True) source = models.CharField(
max_length=512, default="", null=True, blank=True
)
def __str__(self): def __str__(self):
return f'Nutrition' return 'Nutrition'
class Recipe(models.Model): class Recipe(models.Model):
name = models.CharField(max_length=128) name = models.CharField(max_length=128)
servings = models.IntegerField(default=1) servings = models.IntegerField(default=1)
image = models.ImageField(upload_to='recipes/', blank=True, null=True) image = models.ImageField(upload_to='recipes/', blank=True, null=True)
storage = models.ForeignKey(Storage, on_delete=models.PROTECT, blank=True, null=True) storage = models.ForeignKey(
Storage, on_delete=models.PROTECT, blank=True, null=True
)
file_uid = models.CharField(max_length=256, default="", blank=True) file_uid = models.CharField(max_length=256, default="", blank=True)
file_path = models.CharField(max_length=512, default="", blank=True) file_path = models.CharField(max_length=512, default="", blank=True)
link = models.CharField(max_length=512, null=True, blank=True) link = models.CharField(max_length=512, null=True, blank=True)
@ -214,7 +262,9 @@ class Recipe(models.Model):
working_time = models.IntegerField(default=0) working_time = models.IntegerField(default=0)
waiting_time = models.IntegerField(default=0) waiting_time = models.IntegerField(default=0)
internal = models.BooleanField(default=False) internal = models.BooleanField(default=False)
nutrition = models.ForeignKey(NutritionInformation, blank=True, null=True, on_delete=models.CASCADE) nutrition = models.ForeignKey(
NutritionInformation, blank=True, null=True, on_delete=models.CASCADE
)
created_by = models.ForeignKey(User, on_delete=models.PROTECT) created_by = models.ForeignKey(User, on_delete=models.PROTECT)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@ -251,7 +301,9 @@ class RecipeBook(models.Model):
name = models.CharField(max_length=128) name = models.CharField(max_length=128)
description = models.TextField(blank=True) description = models.TextField(blank=True)
icon = models.CharField(max_length=16, blank=True, null=True) icon = models.CharField(max_length=16, blank=True, null=True)
shared = models.ManyToManyField(User, blank=True, related_name='shared_with') shared = models.ManyToManyField(
User, blank=True, related_name='shared_with'
)
created_by = models.ForeignKey(User, on_delete=models.CASCADE) created_by = models.ForeignKey(User, on_delete=models.CASCADE)
def __str__(self): def __str__(self):
@ -265,6 +317,9 @@ class RecipeBookEntry(models.Model):
def __str__(self): def __str__(self):
return self.recipe.name return self.recipe.name
class Meta:
unique_together = (('recipe', 'book'),)
class MealType(models.Model): class MealType(models.Model):
name = models.CharField(max_length=128) name = models.CharField(max_length=128)
@ -276,11 +331,15 @@ class MealType(models.Model):
class MealPlan(models.Model): class MealPlan(models.Model):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, blank=True, null=True) recipe = models.ForeignKey(
Recipe, on_delete=models.CASCADE, blank=True, null=True
)
servings = models.DecimalField(default=1, max_digits=8, decimal_places=4) servings = models.DecimalField(default=1, max_digits=8, decimal_places=4)
title = models.CharField(max_length=64, blank=True, default='') title = models.CharField(max_length=64, blank=True, default='')
created_by = models.ForeignKey(User, on_delete=models.CASCADE) created_by = models.ForeignKey(User, on_delete=models.CASCADE)
shared = models.ManyToManyField(User, blank=True, related_name='plan_share') shared = models.ManyToManyField(
User, blank=True, related_name='plan_share'
)
meal_type = models.ForeignKey(MealType, on_delete=models.CASCADE) meal_type = models.ForeignKey(MealType, on_delete=models.CASCADE)
note = models.TextField(blank=True) note = models.TextField(blank=True)
date = models.DateField() date = models.DateField()
@ -298,7 +357,9 @@ class MealPlan(models.Model):
class ShoppingListRecipe(models.Model): class ShoppingListRecipe(models.Model):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, null=True, blank=True) recipe = models.ForeignKey(
Recipe, on_delete=models.CASCADE, null=True, blank=True
)
servings = models.DecimalField(default=1, max_digits=8, decimal_places=4) servings = models.DecimalField(default=1, max_digits=8, decimal_places=4)
def __str__(self): def __str__(self):
@ -312,9 +373,13 @@ class ShoppingListRecipe(models.Model):
class ShoppingListEntry(models.Model): class ShoppingListEntry(models.Model):
list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True) list_recipe = models.ForeignKey(
ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True
)
food = models.ForeignKey(Food, on_delete=models.CASCADE) food = models.ForeignKey(Food, on_delete=models.CASCADE)
unit = models.ForeignKey(Unit, on_delete=models.CASCADE, null=True, blank=True) unit = models.ForeignKey(
Unit, on_delete=models.CASCADE, null=True, blank=True
)
amount = models.DecimalField(default=0, decimal_places=16, max_digits=32) amount = models.DecimalField(default=0, decimal_places=16, max_digits=32)
order = models.IntegerField(default=0) order = models.IntegerField(default=0)
checked = models.BooleanField(default=False) checked = models.BooleanField(default=False)
@ -334,7 +399,9 @@ class ShoppingList(models.Model):
note = models.TextField(blank=True, null=True) note = models.TextField(blank=True, null=True)
recipes = models.ManyToManyField(ShoppingListRecipe, blank=True) recipes = models.ManyToManyField(ShoppingListRecipe, blank=True)
entries = models.ManyToManyField(ShoppingListEntry, blank=True) entries = models.ManyToManyField(ShoppingListEntry, blank=True)
shared = models.ManyToManyField(User, blank=True, related_name='list_share') shared = models.ManyToManyField(
User, blank=True, related_name='list_share'
)
finished = models.BooleanField(default=False) finished = models.BooleanField(default=False)
created_by = models.ForeignKey(User, on_delete=models.CASCADE) created_by = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
@ -358,7 +425,9 @@ class InviteLink(models.Model):
username = models.CharField(blank=True, max_length=64) username = models.CharField(blank=True, max_length=64)
group = models.ForeignKey(Group, on_delete=models.CASCADE) group = models.ForeignKey(Group, on_delete=models.CASCADE)
valid_until = models.DateField(default=date.today() + timedelta(days=14)) valid_until = models.DateField(default=date.today() + timedelta(days=14))
used_by = models.ForeignKey(User, null=True, on_delete=models.CASCADE, related_name='used_by') used_by = models.ForeignKey(
User, null=True, on_delete=models.CASCADE, related_name='used_by'
)
created_by = models.ForeignKey(User, on_delete=models.CASCADE) created_by = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)

View File

@ -1,11 +1,9 @@
import base64
import io import io
import json
import os import os
from datetime import datetime from datetime import datetime
import requests import requests
import json
from cookbook.models import Recipe, RecipeImport, SyncLog from cookbook.models import Recipe, RecipeImport, SyncLog
from cookbook.provider.provider import Provider from cookbook.provider.provider import Provider
@ -34,16 +32,26 @@ class Dropbox(Provider):
return r return r
import_count = 0 import_count = 0
for recipe in recipes['entries']: # TODO check if has_more is set and import that as well # TODO check if has_more is set and import that as well
for recipe in recipes['entries']:
path = recipe['path_lower'] path = recipe['path_lower']
if not Recipe.objects.filter(file_path__iexact=path).exists() and not RecipeImport.objects.filter( if not Recipe.objects.filter(file_path__iexact=path).exists() \
file_path=path).exists(): and not RecipeImport.objects.filter(file_path=path).exists(): # noqa: E501
name = os.path.splitext(recipe['name'])[0] name = os.path.splitext(recipe['name'])[0]
new_recipe = RecipeImport(name=name, file_path=path, storage=monitor.storage, file_uid=recipe['id']) new_recipe = RecipeImport(
name=name,
file_path=path,
storage=monitor.storage,
file_uid=recipe['id']
)
new_recipe.save() new_recipe.save()
import_count += 1 import_count += 1
log_entry = SyncLog(status='SUCCESS', msg='Imported ' + str(import_count) + ' recipes', sync=monitor) log_entry = SyncLog(
status='SUCCESS',
msg='Imported ' + str(import_count) + ' recipes',
sync=monitor
)
log_entry.save() log_entry.save()
monitor.last_checked = datetime.now() monitor.last_checked = datetime.now()
@ -53,7 +61,7 @@ class Dropbox(Provider):
@staticmethod @staticmethod
def create_share_link(recipe): def create_share_link(recipe):
url = "https://api.dropboxapi.com/2/sharing/create_shared_link_with_settings" url = "https://api.dropboxapi.com/2/sharing/create_shared_link_with_settings" # noqa: E501
headers = { headers = {
"Authorization": "Bearer " + recipe.storage.token, "Authorization": "Bearer " + recipe.storage.token,
@ -84,8 +92,8 @@ class Dropbox(Provider):
r = requests.post(url, headers=headers, data=json.dumps(data)) r = requests.post(url, headers=headers, data=json.dumps(data))
p = r.json() p = r.json()
for l in p['links']: for link in p['links']:
return l['url'] return link['url']
response = Dropbox.create_share_link(recipe) response = Dropbox.create_share_link(recipe)
return response['url'] return response['url']
@ -96,7 +104,9 @@ class Dropbox(Provider):
recipe.link = Dropbox.get_share_link(recipe) recipe.link = Dropbox.get_share_link(recipe)
recipe.save() recipe.save()
response = requests.get(recipe.link.replace('www.dropbox.', 'dl.dropboxusercontent.')) response = requests.get(
recipe.link.replace('www.dropbox.', 'dl.dropboxusercontent.')
)
return io.BytesIO(response.content) return io.BytesIO(response.content)
@ -111,7 +121,11 @@ class Dropbox(Provider):
data = { data = {
"from_path": recipe.file_path, "from_path": recipe.file_path,
"to_path": os.path.dirname(recipe.file_path) + '/' + new_name + os.path.splitext(recipe.file_path)[1] "to_path": "%s/%s%s" % (
os.path.dirname(recipe.file_path),
new_name,
os.path.splitext(recipe.file_path)[1]
)
} }
r = requests.post(url, headers=headers, data=json.dumps(data)) r = requests.post(url, headers=headers, data=json.dumps(data))

View File

@ -1,15 +1,13 @@
import base64
import io import io
import os import os
import tempfile import tempfile
from datetime import datetime from datetime import datetime
import webdav3.client as wc
import requests
from io import BytesIO
from requests.auth import HTTPBasicAuth
import requests
import webdav3.client as wc
from cookbook.models import Recipe, RecipeImport, SyncLog from cookbook.models import Recipe, RecipeImport, SyncLog
from cookbook.provider.provider import Provider from cookbook.provider.provider import Provider
from requests.auth import HTTPBasicAuth
class Nextcloud(Provider): class Nextcloud(Provider):
@ -34,14 +32,22 @@ class Nextcloud(Provider):
import_count = 0 import_count = 0
for file in files: for file in files:
path = monitor.path + '/' + file path = monitor.path + '/' + file
if not Recipe.objects.filter(file_path__iexact=path).exists() and not RecipeImport.objects.filter( if not Recipe.objects.filter(file_path__iexact=path).exists() \
file_path=path).exists(): and not RecipeImport.objects.filter(file_path=path).exists(): # noqa: E501
name = os.path.splitext(file)[0] name = os.path.splitext(file)[0]
new_recipe = RecipeImport(name=name, file_path=path, storage=monitor.storage) new_recipe = RecipeImport(
name=name,
file_path=path,
storage=monitor.storage
)
new_recipe.save() new_recipe.save()
import_count += 1 import_count += 1
log_entry = SyncLog(status='SUCCESS', msg='Imported ' + str(import_count) + ' recipes', sync=monitor) log_entry = SyncLog(
status='SUCCESS',
msg='Imported ' + str(import_count) + ' recipes',
sync=monitor
)
log_entry.save() log_entry.save()
monitor.last_checked = datetime.now() monitor.last_checked = datetime.now()
@ -51,7 +57,7 @@ class Nextcloud(Provider):
@staticmethod @staticmethod
def create_share_link(recipe): def create_share_link(recipe):
url = recipe.storage.url + '/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json' url = recipe.storage.url + '/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json' # noqa: E501
headers = { headers = {
"OCS-APIRequest": "true", "OCS-APIRequest": "true",
@ -60,8 +66,14 @@ class Nextcloud(Provider):
data = {'path': recipe.file_path, 'shareType': 3} data = {'path': recipe.file_path, 'shareType': 3}
r = requests.post(url, headers=headers, auth=HTTPBasicAuth(recipe.storage.username, recipe.storage.password), r = requests.post(
data=data) url,
headers=headers,
auth=HTTPBasicAuth(
recipe.storage.username, recipe.storage.password
),
data=data
)
response_json = r.json() response_json = r.json()
@ -69,14 +81,20 @@ class Nextcloud(Provider):
@staticmethod @staticmethod
def get_share_link(recipe): def get_share_link(recipe):
url = recipe.storage.url + '/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json&path=' + recipe.file_path url = recipe.storage.url + '/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json&path=' + recipe.file_path # noqa: E501
headers = { headers = {
"OCS-APIRequest": "true", "OCS-APIRequest": "true",
"Content-Type": "application/json" "Content-Type": "application/json"
} }
r = requests.get(url, headers=headers, auth=HTTPBasicAuth(recipe.storage.username, recipe.storage.password)) r = requests.get(
url,
headers=headers,
auth=HTTPBasicAuth(
recipe.storage.username, recipe.storage.password
)
)
response_json = r.json() response_json = r.json()
for element in response_json['ocs']['data']: for element in response_json['ocs']['data']:
@ -91,7 +109,10 @@ class Nextcloud(Provider):
tmp_file_path = tempfile.gettempdir() + '/' + recipe.name + '.pdf' tmp_file_path = tempfile.gettempdir() + '/' + recipe.name + '.pdf'
client.download_file(remote_path=recipe.file_path, local_path=tmp_file_path) client.download_file(
remote_path=recipe.file_path,
local_path=tmp_file_path
)
file = io.BytesIO(open(tmp_file_path, 'rb').read()) file = io.BytesIO(open(tmp_file_path, 'rb').read())
os.remove(tmp_file_path) os.remove(tmp_file_path)
@ -102,8 +123,14 @@ class Nextcloud(Provider):
def rename_file(recipe, new_name): def rename_file(recipe, new_name):
client = Nextcloud.get_client(recipe.storage) client = Nextcloud.get_client(recipe.storage)
client.move(recipe.file_path, client.move(
os.path.dirname(recipe.file_path) + '/' + new_name + os.path.splitext(recipe.file_path)[1]) recipe.file_path,
"%s/%s%s" % (
os.path.dirname(recipe.file_path),
new_name,
os.path.splitext(recipe.file_path)[1]
)
)
return True return True

View File

@ -1,18 +1,24 @@
from decimal import Decimal from decimal import Decimal
from django.contrib.auth.models import User from django.contrib.auth.models import User
from drf_writable_nested import WritableNestedModelSerializer, UniqueFieldsMixin from drf_writable_nested import (UniqueFieldsMixin,
WritableNestedModelSerializer)
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from cookbook.models import MealPlan, MealType, Recipe, ViewLog, UserPreference, Storage, Sync, SyncLog, Keyword, Unit, Ingredient, Comment, RecipeImport, RecipeBook, RecipeBookEntry, ShareLink, CookLog, Food, Step, ShoppingList, \ from cookbook.models import (Comment, CookLog, Food, Ingredient, Keyword,
ShoppingListEntry, ShoppingListRecipe, NutritionInformation MealPlan, MealType, NutritionInformation, Recipe,
RecipeBook, RecipeBookEntry, RecipeImport,
ShareLink, ShoppingList, ShoppingListEntry,
ShoppingListRecipe, Step, Storage, Sync, SyncLog,
Unit, UserPreference, ViewLog)
from cookbook.templatetags.custom_tags import markdown from cookbook.templatetags.custom_tags import markdown
class CustomDecimalField(serializers.Field): class CustomDecimalField(serializers.Field):
""" """
Custom decimal field to normalize useless decimal places and allow commas as decimal separators Custom decimal field to normalize useless decimal places
and allow commas as decimal separators
""" """
def to_representation(self, value): def to_representation(self, value):
@ -47,15 +53,21 @@ class UserNameSerializer(WritableNestedModelSerializer):
class UserPreferenceSerializer(serializers.ModelSerializer): class UserPreferenceSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = UserPreference model = UserPreference
fields = ('user', 'theme', 'nav_color', 'default_unit', 'default_page', 'search_style', 'show_recent', fields = (
'plan_share', 'ingredient_decimals', 'comments') 'user', 'theme', 'nav_color', 'default_unit', 'default_page',
'search_style', 'show_recent', 'plan_share', 'ingredient_decimals',
'comments'
)
read_only_fields = ['user'] read_only_fields = ['user']
class StorageSerializer(serializers.ModelSerializer): class StorageSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Storage model = Storage
fields = ('id', 'name', 'method', 'username', 'password', 'token', 'created_by') fields = (
'id', 'name', 'method', 'username', 'password',
'token', 'created_by'
)
extra_kwargs = { extra_kwargs = {
'password': {'write_only': True}, 'password': {'write_only': True},
@ -66,7 +78,10 @@ class StorageSerializer(serializers.ModelSerializer):
class SyncSerializer(serializers.ModelSerializer): class SyncSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Sync model = Sync
fields = ('id', 'storage', 'path', 'active', 'last_checked', 'created_at', 'updated_at') fields = (
'id', 'storage', 'path', 'active', 'last_checked',
'created_at', 'updated_at'
)
class SyncLogSerializer(serializers.ModelSerializer): class SyncLogSerializer(serializers.ModelSerializer):
@ -82,13 +97,17 @@ class KeywordSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
return str(obj) return str(obj)
def create(self, validated_data): def create(self, validated_data):
# since multi select tags dont have id's duplicate names might be routed to create # since multi select tags dont have id's
# duplicate names might be routed to create
obj, created = Keyword.objects.get_or_create(**validated_data) obj, created = Keyword.objects.get_or_create(**validated_data)
return obj return obj
class Meta: class Meta:
model = Keyword model = Keyword
fields = ('id', 'name', 'icon', 'label', 'description', 'created_at', 'updated_at') fields = (
'id', 'name', 'icon', 'label', 'description',
'created_at', 'updated_at'
)
read_only_fields = ('id',) read_only_fields = ('id',)
@ -96,7 +115,8 @@ class KeywordSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
class UnitSerializer(UniqueFieldsMixin, serializers.ModelSerializer): class UnitSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
def create(self, validated_data): def create(self, validated_data):
# since multi select tags dont have id's duplicate names might be routed to create # since multi select tags dont have id's
# duplicate names might be routed to create
obj, created = Unit.objects.get_or_create(**validated_data) obj, created = Unit.objects.get_or_create(**validated_data)
return obj return obj
@ -109,7 +129,8 @@ class UnitSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
class FoodSerializer(UniqueFieldsMixin, serializers.ModelSerializer): class FoodSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
def create(self, validated_data): def create(self, validated_data):
# since multi select tags dont have id's duplicate names might be routed to create # since multi select tags dont have id's
# duplicate names might be routed to create
obj, created = Food.objects.get_or_create(**validated_data) obj, created = Food.objects.get_or_create(**validated_data)
return obj return obj
@ -129,7 +150,10 @@ class IngredientSerializer(WritableNestedModelSerializer):
class Meta: class Meta:
model = Ingredient model = Ingredient
fields = ('id', 'food', 'unit', 'amount', 'note', 'order', 'is_header', 'no_amount') fields = (
'id', 'food', 'unit', 'amount', 'note', 'order',
'is_header', 'no_amount'
)
class StepSerializer(WritableNestedModelSerializer): class StepSerializer(WritableNestedModelSerializer):
@ -137,7 +161,10 @@ class StepSerializer(WritableNestedModelSerializer):
class Meta: class Meta:
model = Step model = Step
fields = ('id', 'name', 'type', 'instruction', 'ingredients', 'time', 'order', 'show_as_header') fields = (
'id', 'name', 'type', 'instruction', 'ingredients',
'time', 'order', 'show_as_header'
)
class NutritionInformationSerializer(serializers.ModelSerializer): class NutritionInformationSerializer(serializers.ModelSerializer):
@ -153,7 +180,11 @@ class RecipeSerializer(WritableNestedModelSerializer):
class Meta: class Meta:
model = Recipe model = Recipe
fields = ['id', 'name', 'image', 'keywords', 'steps', 'working_time', 'waiting_time', 'created_by', 'created_at', 'updated_at', 'internal', 'nutrition', 'servings'] fields = (
'id', 'name', 'image', 'keywords', 'steps', 'working_time',
'waiting_time', 'created_by', 'created_at', 'updated_at',
'internal', 'nutrition', 'servings'
)
read_only_fields = ['image', 'created_by', 'created_at'] read_only_fields = ['image', 'created_by', 'created_at']
def create(self, validated_data): def create(self, validated_data):
@ -209,7 +240,11 @@ class MealPlanSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = MealPlan model = MealPlan
fields = ('id', 'title', 'recipe', 'servings', 'note', 'note_markdown', 'date', 'meal_type', 'created_by', 'shared', 'recipe_name', 'meal_type_name') fields = (
'id', 'title', 'recipe', 'servings', 'note', 'note_markdown',
'date', 'meal_type', 'created_by', 'shared', 'recipe_name',
'meal_type_name'
)
class ShoppingListRecipeSerializer(serializers.ModelSerializer): class ShoppingListRecipeSerializer(serializers.ModelSerializer):
@ -229,7 +264,9 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
class Meta: class Meta:
model = ShoppingListEntry model = ShoppingListEntry
fields = ('id', 'list_recipe', 'food', 'unit', 'amount', 'order', 'checked') fields = (
'id', 'list_recipe', 'food', 'unit', 'amount', 'order', 'checked'
)
read_only_fields = ('id',) read_only_fields = ('id',)
@ -246,7 +283,10 @@ class ShoppingListSerializer(WritableNestedModelSerializer):
class Meta: class Meta:
model = ShoppingList model = ShoppingList
fields = ('id', 'uuid', 'note', 'recipes', 'entries', 'shared', 'finished', 'created_by', 'created_at',) fields = (
'id', 'uuid', 'note', 'recipes', 'entries',
'shared', 'finished', 'created_by', 'created_at'
)
read_only_fields = ('id',) read_only_fields = ('id',)

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@ -0,0 +1,44 @@
{
"name": "Recipes",
"description": "Application to manage, tag and search recipes.",
"icons": [
{
"src": "/static/manifest/icon-192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/static/manifest/icon-512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": "/search",
"background_color": "#18BC9C",
"display": "standalone",
"scope": "/",
"theme_color": "#18BC9C",
"shortcuts": [
{
"name": "Plan",
"short_name": "Plan",
"description": "View your meal Plan",
"url": "/plan",
"icons": [{ "src": "/static/manifest/icon-192.png", "sizes": "192x192" }]
},
{
"name": "Books",
"short_name": "Cookbooks",
"description": "View your cookbooks",
"url": "/books",
"icons": [{ "src": "/static/manifest/icon-192.png", "sizes": "192x192" }]
},
{
"name": "Shopping",
"short_name": "Shopping List",
"description": "View your shopping lists",
"url": "/list/shopping-list/",
"icons": [{ "src": "/static/manifest/icon-192.png", "sizes": "192x192" }]
}
]
}

View File

@ -1,9 +1,10 @@
import django_tables2 as tables import django_tables2 as tables
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django_tables2.utils import A # alias for Accessor from django_tables2.utils import A
from .models import * from .models import (CookLog, InviteLink, Keyword, Recipe, RecipeImport,
ShoppingList, Storage, Sync, SyncLog, ViewLog)
class ImageUrlColumn(tables.Column): class ImageUrlColumn(tables.Column):
@ -17,7 +18,11 @@ class RecipeTableSmall(tables.Table):
id = tables.LinkColumn('edit_recipe', args=[A('id')]) id = tables.LinkColumn('edit_recipe', args=[A('id')])
name = tables.LinkColumn('view_recipe', args=[A('id')]) name = tables.LinkColumn('view_recipe', args=[A('id')])
all_tags = tables.Column( all_tags = tables.Column(
attrs={'td': {'class': 'd-none d-lg-table-cell'}, 'th': {'class': 'd-none d-lg-table-cell'}}) attrs={
'td': {'class': 'd-none d-lg-table-cell'},
'th': {'class': 'd-none d-lg-table-cell'}
}
)
class Meta: class Meta:
model = Recipe model = Recipe
@ -26,16 +31,25 @@ class RecipeTableSmall(tables.Table):
class RecipeTable(tables.Table): class RecipeTable(tables.Table):
edit = tables.TemplateColumn("<a style='color: inherit' href='{% url 'edit_recipe' record.id %}' >" + _('Edit') + "</a>") edit = tables.TemplateColumn(
"<a style='color: inherit' href='{% url 'edit_recipe' record.id %}' >" + _('Edit') + "</a>" # noqa: E501
)
name = tables.LinkColumn('view_recipe', args=[A('id')]) name = tables.LinkColumn('view_recipe', args=[A('id')])
all_tags = tables.Column( all_tags = tables.Column(
attrs={'td': {'class': 'd-none d-lg-table-cell'}, 'th': {'class': 'd-none d-lg-table-cell'}}) attrs={
'td': {'class': 'd-none d-lg-table-cell'},
'th': {'class': 'd-none d-lg-table-cell'}
}
)
image = ImageUrlColumn() image = ImageUrlColumn()
class Meta: class Meta:
model = Recipe model = Recipe
template_name = 'recipes_table.html' template_name = 'recipes_table.html'
fields = ('id', 'name', 'all_tags', 'image', 'instructions', 'working_time', 'waiting_time', 'internal') fields = (
'id', 'name', 'all_tags', 'image', 'instructions',
'working_time', 'waiting_time', 'internal'
)
class KeywordTable(tables.Table): class KeywordTable(tables.Table):
@ -71,9 +85,13 @@ class ImportLogTable(tables.Table):
@staticmethod @staticmethod
def render_status(value): def render_status(value):
if value == 'SUCCESS': if value == 'SUCCESS':
return format_html('<span class="badge badge-success">%s</span>' % value) return format_html(
'<span class="badge badge-success">%s</span>' % value
)
else: else:
return format_html('<span class="badge badge-danger">%s</span>' % value) return format_html(
'<span class="badge badge-danger">%s</span>' % value
)
class Meta: class Meta:
model = SyncLog model = SyncLog
@ -90,7 +108,9 @@ class SyncTable(tables.Table):
@staticmethod @staticmethod
def render_storage(value): def render_storage(value):
return format_html('<span class="badge badge-success">%s</span>' % value) return format_html(
'<span class="badge badge-success">%s</span>' % value
)
class Meta: class Meta:
model = Sync model = Sync
@ -100,7 +120,9 @@ class SyncTable(tables.Table):
class RecipeImportTable(tables.Table): class RecipeImportTable(tables.Table):
id = tables.LinkColumn('new_recipe_import', args=[A('id')]) id = tables.LinkColumn('new_recipe_import', args=[A('id')])
delete = tables.TemplateColumn("<a href='{% url 'delete_recipe_import' record.id %}' >" + _('Delete') + "</a>") delete = tables.TemplateColumn(
"<a href='{% url 'delete_recipe_import' record.id %}' >" + _('Delete') + "</a>" # noqa: E501
)
class Meta: class Meta:
model = RecipeImport model = RecipeImport
@ -118,13 +140,19 @@ class ShoppingListTable(tables.Table):
class InviteLinkTable(tables.Table): class InviteLinkTable(tables.Table):
link = tables.TemplateColumn("<a href='{% url 'view_signup' record.uuid %}' >" + _('Link') + "</a>") link = tables.TemplateColumn(
delete = tables.TemplateColumn("<a href='{% url 'delete_invite_link' record.id %}' >" + _('Delete') + "</a>") "<a href='{% url 'view_signup' record.uuid %}' >" + _('Link') + "</a>"
)
delete = tables.TemplateColumn(
"<a href='{% url 'delete_invite_link' record.id %}' >" + _('Delete') + "</a>" # noqa: E501
)
class Meta: class Meta:
model = InviteLink model = InviteLink
template_name = 'generic/table_template.html' template_name = 'generic/table_template.html'
fields = ('username', 'group', 'valid_until', 'created_by', 'created_at') fields = (
'username', 'group', 'valid_until', 'created_by', 'created_at'
)
class ViewLogTable(tables.Table): class ViewLogTable(tables.Table):

View File

@ -16,6 +16,7 @@
<link rel="icon" type="image/png" href="{% static 'favicon.png' %}" sizes="32x32"> <link rel="icon" type="image/png" href="{% static 'favicon.png' %}" sizes="32x32">
<link rel="icon" type="image/png" href="{% static 'favicon.png' %}" sizes="96x96"> <link rel="icon" type="image/png" href="{% static 'favicon.png' %}" sizes="96x96">
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'favicon.png' %}"> <link rel="apple-touch-icon" sizes="180x180" href="{% static 'favicon.png' %}">
<link rel="manifest" href="{% static 'manifest/webmanifest' %}">
<meta name="msapplication-TileColor" content="#ffffff"> <meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-TileImage" content="/mstile-144x144.png"> <meta name="msapplication-TileImage" content="/mstile-144x144.png">
@ -48,7 +49,8 @@
</head> </head>
<body> <body>
<nav class="navbar navbar-expand-lg navbar-dark bg-{% nav_color request %}" id="id_main_nav" style="{% sticky_nav request %}"> <nav class="navbar navbar-expand-lg navbar-dark bg-{% nav_color request %}" id="id_main_nav"
style="{% sticky_nav request %}">
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText"
aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation"> aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
@ -210,5 +212,17 @@
{% block script %} {% block script %}
{% endblock script %} {% endblock script %}
<script type="application/javascript">
window.addEventListener("load", () => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("{% url 'service_worker' %}", { scope: '/' }).then(function (reg) {
console.log('Successfully registered service worker', reg);
}).catch(function (err) {
console.warn('Error whilst registering service worker', err);
});
}
});
</script>
</body> </body>
</html> </html>

View File

@ -0,0 +1,31 @@
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% block title %}{% trans "Offline" %}{% endblock %}
{% block extra_head %}
{% endblock %}
{% block content %}
<div style="text-align: center">
<h1 class="">Offline</h1>
<br/>
<span>{% trans 'You are currently offline!' %}</span>
<span>{% trans 'This app does not (yet) support offline functionality. Please make sure to re-establish a network connection.' %}</span>
<br/>
<br/>
<i class="fas fa-search fa-8x"></i>
<br/>
<br/>
<br/>
<br/>
</div>
{% endblock %}

View File

@ -0,0 +1,88 @@
/*
Copyright 2015, 2019, 2020 Google LLC. All Rights Reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Incrementing OFFLINE_VERSION will kick off the install event and force
// previously cached resources to be updated from the network.
const OFFLINE_VERSION = 1;
const CACHE_NAME = "offline";
// Customize this with a different URL if needed.
const OFFLINE_URL = "/offline/";
self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE_NAME);
// Setting {cache: 'reload'} in the new request will ensure that the
// response isn't fulfilled from the HTTP cache; i.e., it will be from
// the network.
await cache.add(new Request(OFFLINE_URL, {cache: "reload"}));
})()
);
// Force the waiting service worker to become the active service worker.
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
// Enable navigation preload if it's supported.
// See https://developers.google.com/web/updates/2017/02/navigation-preload
if ("navigationPreload" in self.registration) {
await self.registration.navigationPreload.enable();
}
})()
);
// Tell the active service worker to take control of the page immediately.
self.clients.claim();
});
self.addEventListener("fetch", (event) => {
// We only want to call event.respondWith() if this is a navigation request
// for an HTML page.
console.log("fetch event called");
if (event.request.mode === "navigate") {
event.respondWith(
(async () => {
try {
// First, try to use the navigation preload response if it's supported.
const preloadResponse = await event.preloadResponse;
if (preloadResponse) {
return preloadResponse;
}
// Always try the network first.
console.log("Served from network");
const networkResponse = await fetch(event.request);
return networkResponse;
} catch (error) {
// catch is only triggered if an exception is thrown, which is likely
// due to a network error.
// If fetch() returns a valid HTTP response with a response code in
// the 4xx or 5xx range, the catch() will NOT be called.
console.log("Fetch failed; returning offline page instead.", error);
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(OFFLINE_URL);
return cachedResponse;
}
})()
);
}
// If our if() condition is false, then this fetch handler won't intercept the
// request. If there are any other fetch handlers registered, they will get a
// chance to call event.respondWith(). If no fetch handlers call
// event.respondWith(), the request will be handled by the browser as if there
// were no service worker involvement.
});

View File

View File

@ -1,13 +1,12 @@
import bleach import bleach
import markdown as md import markdown as md
from bleach_whitelist import markdown_tags, markdown_attrs from bleach_whitelist import markdown_attrs, markdown_tags
from django import template
from django.db.models import Avg
from django.urls import reverse, NoReverseMatch
from cookbook.helper.mdx_attributes import MarkdownFormatExtension from cookbook.helper.mdx_attributes import MarkdownFormatExtension
from cookbook.helper.mdx_urlize import UrlizeExtension from cookbook.helper.mdx_urlize import UrlizeExtension
from cookbook.models import get_model_name, Space from cookbook.models import Space, get_model_name
from django import template
from django.db.models import Avg
from django.urls import NoReverseMatch, reverse
from recipes import settings from recipes import settings
register = template.Library() register = template.Library()
@ -33,8 +32,16 @@ def delete_url(model, pk):
@register.filter() @register.filter()
def markdown(value): def markdown(value):
tags = markdown_tags + ['pre', 'table', 'td', 'tr', 'th', 'tbody', 'style', 'thead'] tags = markdown_tags + [
parsed_md = md.markdown(value, extensions=['markdown.extensions.fenced_code', 'tables', UrlizeExtension(), MarkdownFormatExtension()]) 'pre', 'table', 'td', 'tr', 'th', 'tbody', 'style', 'thead'
]
parsed_md = md.markdown(
value,
extensions=[
'markdown.extensions.fenced_code', 'tables',
UrlizeExtension(), MarkdownFormatExtension()
]
)
markdown_attrs['*'] = markdown_attrs['*'] + ['class'] markdown_attrs['*'] = markdown_attrs['*'] + ['class']
return bleach.clean(parsed_md, tags, markdown_attrs) return bleach.clean(parsed_md, tags, markdown_attrs)
@ -43,7 +50,9 @@ def markdown(value):
def recipe_rating(recipe, user): def recipe_rating(recipe, user):
if not user.is_authenticated: if not user.is_authenticated:
return '' return ''
rating = recipe.cooklog_set.filter(created_by=user).aggregate(Avg('rating')) rating = recipe.cooklog_set \
.filter(created_by=user) \
.aggregate(Avg('rating'))
if rating['rating__avg']: if rating['rating__avg']:
rating_stars = '<span style="display: inline-block;">' rating_stars = '<span style="display: inline-block;">'
@ -51,7 +60,7 @@ def recipe_rating(recipe, user):
rating_stars = rating_stars + '<i class="fas fa-star fa-xs"></i>' rating_stars = rating_stars + '<i class="fas fa-star fa-xs"></i>'
if rating['rating__avg'] % 1 >= 0.5: if rating['rating__avg'] % 1 >= 0.5:
rating_stars = rating_stars + '<i class="fas fa-star-half-alt fa-xs"></i>' rating_stars = rating_stars + '<i class="fas fa-star-half-alt fa-xs"></i>' # noqa: E501
rating_stars += '</span>' rating_stars += '</span>'

View File

@ -1,7 +1,6 @@
from cookbook.models import UserPreference
from django import template from django import template
from django.templatetags.static import static from django.templatetags.static import static
from cookbook.models import UserPreference
from recipes.settings import STICKY_NAV_PREF_DEFAULT from recipes.settings import STICKY_NAV_PREF_DEFAULT
register = template.Library() register = template.Library()
@ -33,7 +32,7 @@ def nav_color(request):
@register.simple_tag @register.simple_tag
def sticky_nav(request): def sticky_nav(request):
if (not request.user.is_authenticated and STICKY_NAV_PREF_DEFAULT) or \ if (not request.user.is_authenticated and STICKY_NAV_PREF_DEFAULT) or \
(request.user.is_authenticated and request.user.userpreference.sticky_navbar): (request.user.is_authenticated and request.user.userpreference.sticky_navbar): # noqa: E501
return 'position: sticky; top: 0; left: 0; z-index: 1000;' return 'position: sticky; top: 0; left: 0; z-index: 1000;'
else: else:
return '' return ''

View File

@ -1,9 +1,8 @@
import json import json
from django.urls import reverse
from cookbook.models import Food from cookbook.models import Food
from cookbook.tests.views.test_views import TestViews from cookbook.tests.views.test_views import TestViews
from django.urls import reverse
class TestApiUnit(TestViews): class TestApiUnit(TestViews):
@ -19,8 +18,16 @@ class TestApiUnit(TestViews):
def test_keyword_list(self): def test_keyword_list(self):
# verify view permissions are applied accordingly # verify view permissions are applied accordingly
self.batch_requests([(self.anonymous_client, 403), (self.guest_client_1, 403), (self.user_client_1, 200), (self.admin_client_1, 200), (self.superuser_client, 200)], self.batch_requests(
reverse('api:food-list')) [
(self.anonymous_client, 403),
(self.guest_client_1, 403),
(self.user_client_1, 200),
(self.admin_client_1, 200),
(self.superuser_client, 200)
],
reverse('api:food-list')
)
# verify storage is returned # verify storage is returned
r = self.user_client_1.get(reverse('api:food-list')) r = self.user_client_1.get(reverse('api:food-list'))
@ -42,12 +49,21 @@ class TestApiUnit(TestViews):
self.assertEqual(len(response), 1) self.assertEqual(len(response), 1)
def test_keyword_update(self): def test_keyword_update(self):
r = self.user_client_1.patch(reverse('api:food-detail', args={self.food_1.id}), {'name': 'new'}, content_type='application/json') r = self.user_client_1.patch(
reverse(
'api:food-detail',
args={self.food_1.id}
),
{'name': 'new'},
content_type='application/json'
)
response = json.loads(r.content) response = json.loads(r.content)
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
self.assertEqual(response['name'], 'new') self.assertEqual(response['name'], 'new')
def test_keyword_delete(self): def test_keyword_delete(self):
r = self.user_client_1.delete(reverse('api:food-detail', args={self.food_1.id})) r = self.user_client_1.delete(
reverse('api:food-detail', args={self.food_1.id})
)
self.assertEqual(r.status_code, 204) self.assertEqual(r.status_code, 204)
self.assertEqual(Food.objects.count(), 1) self.assertEqual(Food.objects.count(), 1)

View File

@ -1,11 +1,8 @@
import json import json
from django.contrib import auth from cookbook.models import Keyword
from django.db.models import ProtectedError
from django.urls import reverse
from cookbook.models import Storage, Sync, Keyword
from cookbook.tests.views.test_views import TestViews from cookbook.tests.views.test_views import TestViews
from django.urls import reverse
class TestApiKeyword(TestViews): class TestApiKeyword(TestViews):
@ -21,8 +18,16 @@ class TestApiKeyword(TestViews):
def test_keyword_list(self): def test_keyword_list(self):
# verify view permissions are applied accordingly # verify view permissions are applied accordingly
self.batch_requests([(self.anonymous_client, 403), (self.guest_client_1, 403), (self.user_client_1, 200), (self.admin_client_1, 200), (self.superuser_client, 200)], self.batch_requests(
reverse('api:keyword-list')) [
(self.anonymous_client, 403),
(self.guest_client_1, 403),
(self.user_client_1, 200),
(self.admin_client_1, 200),
(self.superuser_client, 200)
],
reverse('api:keyword-list')
)
# verify storage is returned # verify storage is returned
r = self.user_client_1.get(reverse('api:keyword-list')) r = self.user_client_1.get(reverse('api:keyword-list'))
@ -35,7 +40,9 @@ class TestApiKeyword(TestViews):
response = json.loads(r.content) response = json.loads(r.content)
self.assertEqual(len(response), 1) self.assertEqual(len(response), 1)
r = self.user_client_1.get(f'{reverse("api:keyword-list")}?query=chicken') r = self.user_client_1.get(
f'{reverse("api:keyword-list")}?query=chicken'
)
response = json.loads(r.content) response = json.loads(r.content)
self.assertEqual(len(response), 0) self.assertEqual(len(response), 0)
@ -44,12 +51,24 @@ class TestApiKeyword(TestViews):
self.assertEqual(len(response), 1) self.assertEqual(len(response), 1)
def test_keyword_update(self): def test_keyword_update(self):
r = self.user_client_1.patch(reverse('api:keyword-detail', args={self.keyword_1.id}), {'name': 'new'}, content_type='application/json') r = self.user_client_1.patch(
reverse(
'api:keyword-detail',
args={self.keyword_1.id}
),
{'name': 'new'},
content_type='application/json'
)
response = json.loads(r.content) response = json.loads(r.content)
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
self.assertEqual(response['name'], 'new') self.assertEqual(response['name'], 'new')
def test_keyword_delete(self): def test_keyword_delete(self):
r = self.user_client_1.delete(reverse('api:keyword-detail', args={self.keyword_1.id})) r = self.user_client_1.delete(
reverse(
'api:keyword-detail',
args={self.keyword_1.id}
)
)
self.assertEqual(r.status_code, 204) self.assertEqual(r.status_code, 204)
self.assertEqual(Keyword.objects.count(), 1) self.assertEqual(Keyword.objects.count(), 1)

View File

@ -1,11 +1,7 @@
import json from cookbook.models import Recipe
from django.contrib import auth
from django.db.models import ProtectedError
from django.urls import reverse
from cookbook.models import Storage, Sync, Keyword, ShoppingList, Recipe
from cookbook.tests.views.test_views import TestViews from cookbook.tests.views.test_views import TestViews
from django.contrib import auth
from django.urls import reverse
class TestApiShopping(TestViews): class TestApiShopping(TestViews):
@ -19,8 +15,17 @@ class TestApiShopping(TestViews):
) )
def test_shopping_view_permissions(self): def test_shopping_view_permissions(self):
self.batch_requests([(self.anonymous_client, 403), (self.guest_client_1, 200), (self.user_client_1, 200), self.batch_requests(
(self.user_client_2, 200), (self.admin_client_1, 200), (self.superuser_client, 200)], [
reverse('api:recipe-detail', args={self.internal_recipe.id})) (self.anonymous_client, 403),
(self.guest_client_1, 200),
(self.user_client_1, 200),
(self.user_client_2, 200),
(self.admin_client_1, 200),
(self.superuser_client, 200)
],
reverse(
'api:recipe-detail', args={self.internal_recipe.id})
)
# TODO add tests for editing # TODO add tests for editing

View File

@ -1,27 +1,48 @@
import json from cookbook.models import ShoppingList
from django.contrib import auth
from django.db.models import ProtectedError
from django.urls import reverse
from cookbook.models import Storage, Sync, Keyword, ShoppingList
from cookbook.tests.views.test_views import TestViews from cookbook.tests.views.test_views import TestViews
from django.contrib import auth
from django.urls import reverse
class TestApiShopping(TestViews): class TestApiShopping(TestViews):
def setUp(self): def setUp(self):
super(TestApiShopping, self).setUp() super(TestApiShopping, self).setUp()
self.list_1 = ShoppingList.objects.create(created_by=auth.get_user(self.user_client_1)) self.list_1 = ShoppingList.objects.create(
self.list_2 = ShoppingList.objects.create(created_by=auth.get_user(self.user_client_2)) created_by=auth.get_user(self.user_client_1)
)
self.list_2 = ShoppingList.objects.create(
created_by=auth.get_user(self.user_client_2)
)
def test_shopping_view_permissions(self): def test_shopping_view_permissions(self):
self.batch_requests([(self.anonymous_client, 403), (self.guest_client_1, 404), (self.user_client_1, 200), (self.user_client_2, 404), (self.admin_client_1, 404), (self.superuser_client, 200)], self.batch_requests(
reverse('api:shoppinglist-detail', args={self.list_1.id})) [
(self.anonymous_client, 403),
(self.guest_client_1, 404),
(self.user_client_1, 200),
(self.user_client_2, 404),
(self.admin_client_1, 404),
(self.superuser_client, 200)
],
reverse(
'api:shoppinglist-detail', args={self.list_1.id}
)
)
self.list_1.shared.add(auth.get_user(self.user_client_2)) self.list_1.shared.add(auth.get_user(self.user_client_2))
self.batch_requests([(self.anonymous_client, 403), (self.guest_client_1, 404), (self.user_client_1, 200), (self.user_client_2, 200), (self.admin_client_1, 404), (self.superuser_client, 200)], self.batch_requests(
reverse('api:shoppinglist-detail', args={self.list_1.id})) [
(self.anonymous_client, 403),
(self.guest_client_1, 404),
(self.user_client_1, 200),
(self.user_client_2, 200),
(self.admin_client_1, 404),
(self.superuser_client, 200)
],
reverse(
'api:shoppinglist-detail', args={self.list_1.id})
)
# TODO add tests for editing # TODO add tests for editing

View File

@ -1,11 +1,10 @@
import json import json
from django.contrib import auth
from django.db.models import ProtectedError
from django.urls import reverse
from cookbook.models import Storage, Sync from cookbook.models import Storage, Sync
from cookbook.tests.views.test_views import TestViews from cookbook.tests.views.test_views import TestViews
from django.contrib import auth
from django.db.models import ProtectedError
from django.urls import reverse
class TestApiStorage(TestViews): class TestApiStorage(TestViews):
@ -23,8 +22,16 @@ class TestApiStorage(TestViews):
def test_storage_list(self): def test_storage_list(self):
# verify view permissions are applied accordingly # verify view permissions are applied accordingly
self.batch_requests([(self.anonymous_client, 403), (self.guest_client_1, 403), (self.user_client_1, 403), (self.admin_client_1, 200), (self.superuser_client, 200)], self.batch_requests(
reverse('api:storage-list')) [
(self.anonymous_client, 403),
(self.guest_client_1, 403),
(self.user_client_1, 403),
(self.admin_client_1, 200),
(self.superuser_client, 200)
],
reverse('api:storage-list')
)
# verify storage is returned # verify storage is returned
r = self.admin_client_1.get(reverse('api:storage-list')) r = self.admin_client_1.get(reverse('api:storage-list'))
@ -38,7 +45,14 @@ class TestApiStorage(TestViews):
def test_storage_update(self): def test_storage_update(self):
# can update storage as admin # can update storage as admin
r = self.admin_client_1.patch(reverse('api:storage-detail', args={self.storage.id}), {'name': 'new', 'password': 'new_password'}, content_type='application/json') r = self.admin_client_1.patch(
reverse(
'api:storage-detail',
args={self.storage.id}
),
{'name': 'new', 'password': 'new_password'},
content_type='application/json'
)
response = json.loads(r.content) response = json.loads(r.content)
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
self.assertEqual(response['name'], 'new') self.assertEqual(response['name'], 'new')
@ -49,13 +63,20 @@ class TestApiStorage(TestViews):
def test_storage_delete(self): def test_storage_delete(self):
# can delete storage as admin # can delete storage as admin
r = self.admin_client_1.delete(reverse('api:storage-detail', args={self.storage.id})) r = self.admin_client_1.delete(
reverse('api:storage-detail', args={self.storage.id})
)
self.assertEqual(r.status_code, 204) self.assertEqual(r.status_code, 204)
self.assertEqual(Storage.objects.count(), 0) self.assertEqual(Storage.objects.count(), 0)
self.storage = Storage.objects.create(created_by=auth.get_user(self.admin_client_1), name='test protect') self.storage = Storage.objects.create(
created_by=auth.get_user(self.admin_client_1), name='test protect'
)
Sync.objects.create(storage=self.storage, ) Sync.objects.create(storage=self.storage, )
# test if deleting a storage with existing sync fails (as sync protects storage) # test if deleting a storage with existing
# sync fails (as sync protects storage)
with self.assertRaises(ProtectedError): with self.assertRaises(ProtectedError):
self.admin_client_1.delete(reverse('api:storage-detail', args={self.storage.id})) self.admin_client_1.delete(
reverse('api:storage-detail', args={self.storage.id})
)

View File

@ -1,11 +1,9 @@
import json import json
from django.contrib import auth
from django.db.models import ProtectedError
from django.urls import reverse
from cookbook.models import Storage, Sync, SyncLog from cookbook.models import Storage, Sync, SyncLog
from cookbook.tests.views.test_views import TestViews from cookbook.tests.views.test_views import TestViews
from django.contrib import auth
from django.urls import reverse
class TestApiSyncLog(TestViews): class TestApiSyncLog(TestViews):
@ -26,12 +24,22 @@ class TestApiSyncLog(TestViews):
path='path' path='path'
) )
self.sync_log = SyncLog.objects.create(sync=self.sync, status='success') self.sync_log = SyncLog.objects.create(
sync=self.sync, status='success'
)
def test_sync_log_list(self): def test_sync_log_list(self):
# verify view permissions are applied accordingly # verify view permissions are applied accordingly
self.batch_requests([(self.anonymous_client, 403), (self.guest_client_1, 403), (self.user_client_1, 403), (self.admin_client_1, 200), (self.superuser_client, 200)], self.batch_requests(
reverse('api:synclog-list')) [
(self.anonymous_client, 403),
(self.guest_client_1, 403),
(self.user_client_1, 403),
(self.admin_client_1, 200),
(self.superuser_client, 200)
],
reverse('api:synclog-list')
)
# verify log entry is returned # verify log entry is returned
r = self.admin_client_1.get(reverse('api:synclog-list')) r = self.admin_client_1.get(reverse('api:synclog-list'))
@ -42,10 +50,21 @@ class TestApiSyncLog(TestViews):
def test_sync_log_update(self): def test_sync_log_update(self):
# read only view # read only view
r = self.admin_client_1.patch(reverse('api:synclog-detail', args={self.sync.id}), {'path': 'new'}, content_type='application/json') r = self.admin_client_1.patch(
reverse(
'api:synclog-detail',
args={self.sync.id}
),
{'path': 'new'},
content_type='application/json'
)
self.assertEqual(r.status_code, 405) self.assertEqual(r.status_code, 405)
def test_sync_log_delete(self): def test_sync_log_delete(self):
# read only view # read only view
r = self.admin_client_1.delete(reverse('api:synclog-detail', args={self.sync.id})) r = self.admin_client_1.delete(
reverse(
'api:synclog-detail',
args={self.sync.id})
)
self.assertEqual(r.status_code, 405) self.assertEqual(r.status_code, 405)

View File

@ -1,11 +1,9 @@
import json import json
from django.contrib import auth
from django.db.models import ProtectedError
from django.urls import reverse
from cookbook.models import Storage, Sync from cookbook.models import Storage, Sync
from cookbook.tests.views.test_views import TestViews from cookbook.tests.views.test_views import TestViews
from django.contrib import auth
from django.urls import reverse
class TestApiSync(TestViews): class TestApiSync(TestViews):
@ -28,8 +26,16 @@ class TestApiSync(TestViews):
def test_sync_list(self): def test_sync_list(self):
# verify view permissions are applied accordingly # verify view permissions are applied accordingly
self.batch_requests([(self.anonymous_client, 403), (self.guest_client_1, 403), (self.user_client_1, 403), (self.admin_client_1, 200), (self.superuser_client, 200)], self.batch_requests(
reverse('api:sync-list')) [
(self.anonymous_client, 403),
(self.guest_client_1, 403),
(self.user_client_1, 403),
(self.admin_client_1, 200),
(self.superuser_client, 200)
],
reverse('api:sync-list')
)
# verify sync is returned # verify sync is returned
r = self.admin_client_1.get(reverse('api:sync-list')) r = self.admin_client_1.get(reverse('api:sync-list'))
@ -41,13 +47,22 @@ class TestApiSync(TestViews):
def test_sync_update(self): def test_sync_update(self):
# can update sync as admin # can update sync as admin
r = self.admin_client_1.patch(reverse('api:sync-detail', args={self.sync.id}), {'path': 'new'}, content_type='application/json') r = self.admin_client_1.patch(
reverse(
'api:sync-detail',
args={self.sync.id}
),
{'path': 'new'},
content_type='application/json'
)
response = json.loads(r.content) response = json.loads(r.content)
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
self.assertEqual(response['path'], 'new') self.assertEqual(response['path'], 'new')
def test_sync_delete(self): def test_sync_delete(self):
# can delete sync as admin # can delete sync as admin
r = self.admin_client_1.delete(reverse('api:sync-detail', args={self.sync.id})) r = self.admin_client_1.delete(
reverse('api:sync-detail', args={self.sync.id})
)
self.assertEqual(r.status_code, 204) self.assertEqual(r.status_code, 204)
self.assertEqual(Sync.objects.count(), 0) self.assertEqual(Sync.objects.count(), 0)

View File

@ -1,9 +1,8 @@
import json import json
from django.urls import reverse
from cookbook.models import Unit from cookbook.models import Unit
from cookbook.tests.views.test_views import TestViews from cookbook.tests.views.test_views import TestViews
from django.urls import reverse
class TestApiUnit(TestViews): class TestApiUnit(TestViews):
@ -19,8 +18,16 @@ class TestApiUnit(TestViews):
def test_keyword_list(self): def test_keyword_list(self):
# verify view permissions are applied accordingly # verify view permissions are applied accordingly
self.batch_requests([(self.anonymous_client, 403), (self.guest_client_1, 403), (self.user_client_1, 200), (self.admin_client_1, 200), (self.superuser_client, 200)], self.batch_requests(
reverse('api:unit-list')) [
(self.anonymous_client, 403),
(self.guest_client_1, 403),
(self.user_client_1, 200),
(self.admin_client_1, 200),
(self.superuser_client, 200)
],
reverse('api:unit-list')
)
# verify storage is returned # verify storage is returned
r = self.user_client_1.get(reverse('api:unit-list')) r = self.user_client_1.get(reverse('api:unit-list'))
@ -42,12 +49,21 @@ class TestApiUnit(TestViews):
self.assertEqual(len(response), 1) self.assertEqual(len(response), 1)
def test_keyword_update(self): def test_keyword_update(self):
r = self.user_client_1.patch(reverse('api:unit-detail', args={self.unit_1.id}), {'name': 'new'}, content_type='application/json') r = self.user_client_1.patch(
reverse(
'api:unit-detail',
args={self.unit_1.id}
),
{'name': 'new'},
content_type='application/json'
)
response = json.loads(r.content) response = json.loads(r.content)
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
self.assertEqual(response['name'], 'new') self.assertEqual(response['name'], 'new')
def test_keyword_delete(self): def test_keyword_delete(self):
r = self.user_client_1.delete(reverse('api:unit-detail', args={self.unit_1.id})) r = self.user_client_1.delete(
reverse('api:unit-detail', args={self.unit_1.id})
)
self.assertEqual(r.status_code, 204) self.assertEqual(r.status_code, 204)
self.assertEqual(Unit.objects.count(), 1) self.assertEqual(Unit.objects.count(), 1)

View File

@ -1,11 +1,7 @@
import json from cookbook.tests.views.test_views import TestViews
from django.contrib import auth from django.contrib import auth
from django.urls import reverse from django.urls import reverse
from cookbook.models import UserPreference
from cookbook.tests.views.test_views import TestViews
class TestApiUsername(TestViews): class TestApiUsername(TestViews):
@ -13,15 +9,33 @@ class TestApiUsername(TestViews):
super(TestApiUsername, self).setUp() super(TestApiUsername, self).setUp()
def test_forbidden_methods(self): def test_forbidden_methods(self):
r = self.user_client_1.post(reverse('api:username-list')) r = self.user_client_1.post(
reverse('api:username-list'))
self.assertEqual(r.status_code, 405) self.assertEqual(r.status_code, 405)
r = self.user_client_1.put(reverse('api:username-detail', args=[auth.get_user(self.user_client_1).pk])) r = self.user_client_1.put(
reverse(
'api:username-detail',
args=[auth.get_user(self.user_client_1).pk])
)
self.assertEqual(r.status_code, 405) self.assertEqual(r.status_code, 405)
r = self.user_client_1.delete(reverse('api:username-detail', args=[auth.get_user(self.user_client_1).pk])) r = self.user_client_1.delete(
reverse(
'api:username-detail',
args=[auth.get_user(self.user_client_1).pk]
)
)
self.assertEqual(r.status_code, 405) self.assertEqual(r.status_code, 405)
def test_username_list(self): def test_username_list(self):
self.batch_requests([(self.anonymous_client, 403), (self.guest_client_1, 200), (self.user_client_1, 200), (self.admin_client_1, 200), (self.superuser_client, 200)], self.batch_requests(
reverse('api:username-list')) [
(self.anonymous_client, 403),
(self.guest_client_1, 200),
(self.user_client_1, 200),
(self.admin_client_1, 200),
(self.superuser_client, 200)
],
reverse('api:username-list')
)

View File

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

View File

@ -1,8 +1,7 @@
from django.contrib import auth
from django.urls import reverse
from cookbook.models import Comment, Recipe from cookbook.models import Comment, Recipe
from cookbook.tests.views.test_views import TestViews from cookbook.tests.views.test_views import TestViews
from django.contrib import auth
from django.urls import reverse
class TestEditsComment(TestViews): class TestEditsComment(TestViews):
@ -25,7 +24,17 @@ class TestEditsComment(TestViews):
self.url = reverse('edit_comment', args=[self.comment.pk]) self.url = reverse('edit_comment', args=[self.comment.pk])
def test_new_comment(self): 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}) 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) self.assertEqual(r.status_code, 200)
def test_edit_comment_permissions(self): def test_edit_comment_permissions(self):

View File

@ -1,9 +1,8 @@
from cookbook.models import Food, Recipe, Storage, Unit
from cookbook.tests.views.test_views import TestViews
from django.contrib import auth from django.contrib import auth
from django.urls import reverse from django.urls import reverse
from cookbook.models import Recipe, Ingredient, Unit, Storage, Food
from cookbook.tests.views.test_views import TestViews
class TestEditsRecipe(TestViews): class TestEditsRecipe(TestViews):
@ -70,7 +69,17 @@ class TestEditsRecipe(TestViews):
r = self.anonymous_client.get(url) r = self.anonymous_client.get(url)
self.assertEqual(r.status_code, 403) self.assertEqual(r.status_code, 403)
r = self.user_client_1.put(url, {'name': 'Changed', 'working_time': 15, 'waiting_time': 15, 'keywords': [], 'steps': []}, content_type='application/json') r = self.user_client_1.put(
url,
{
'name': 'Changed',
'working_time': 15,
'waiting_time': 15,
'keywords': [],
'steps': []
},
content_type='application/json'
)
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
recipe = Recipe.objects.get(pk=recipe.pk) recipe = Recipe.objects.get(pk=recipe.pk)
@ -79,18 +88,39 @@ class TestEditsRecipe(TestViews):
Food.objects.create(name='Egg') Food.objects.create(name='Egg')
Unit.objects.create(name='g') Unit.objects.create(name='g')
r = self.user_client_1.put(url, {'name': 'Changed', 'working_time': 15, 'waiting_time': 15, 'keywords': [], r = self.user_client_1.put(
'steps': [{'ingredients': [ url,
{"food": {"name": "test food"}, "unit": {"name": "test unit"}, 'amount': 12, 'note': "test note"}, {
{"food": {"name": "test food 2"}, "unit": {"name": "test unit 2"}, 'amount': 42, 'note': "test note 2"} 'name': 'Changed',
]}]}, content_type='application/json') 'working_time': 15,
'waiting_time': 15,
'keywords': [],
'steps': [
{
'ingredients': [
{
'food': {'name': 'test food'},
'unit': {'name': 'test unit'},
'amount': 12, 'note': 'test note'
},
{
'food': {'name': 'test food 2'},
'unit': {'name': 'test unit 2'},
'amount': 42, 'note': 'test note 2'
}
]
}
]
},
content_type='application/json'
)
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
self.assertEqual(2, recipe.steps.first().ingredients.count()) self.assertEqual(2, recipe.steps.first().ingredients.count())
with open('cookbook/tests/resources/image.jpg', 'rb') as file: with open('cookbook/tests/resources/image.jpg', 'rb') as file: # noqa: E501,F841
pass # TODO new image tests pass # TODO new image tests
with open('cookbook/tests/resources/image.png', 'rb') as file: with open('cookbook/tests/resources/image.png', 'rb') as file: # noqa: E501,F841
pass # TODO new image tests pass # TODO new image tests
def test_external_recipe_update(self): def test_external_recipe_update(self):
@ -117,7 +147,10 @@ class TestEditsRecipe(TestViews):
r = self.anonymous_client.get(url) r = self.anonymous_client.get(url)
self.assertEqual(r.status_code, 302) self.assertEqual(r.status_code, 302)
r = self.user_client_1.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() recipe.refresh_from_db()
self.assertEqual(recipe.working_time, 15) self.assertEqual(recipe.working_time, 15)
self.assertEqual(recipe.waiting_time, 15) self.assertEqual(recipe.waiting_time, 15)

View File

@ -1,8 +1,7 @@
from django.contrib import auth
from django.urls import reverse
from cookbook.models import Storage from cookbook.models import Storage
from cookbook.tests.views.test_views import TestViews from cookbook.tests.views.test_views import TestViews
from django.contrib import auth
from django.urls import reverse
class TestEditsRecipe(TestViews): class TestEditsRecipe(TestViews):
@ -21,13 +20,36 @@ class TestEditsRecipe(TestViews):
self.url = reverse('edit_storage', args=[self.storage.pk]) self.url = reverse('edit_storage', args=[self.storage.pk])
def test_edit_storage(self): def test_edit_storage(self):
r = self.admin_client_1.post(self.url, {'name': 'NewStorage', 'password': '1234_pw', 'token': '1234_token', 'method': Storage.DROPBOX}) 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.storage.refresh_from_db()
self.assertEqual(self.storage.password, '1234_pw') self.assertEqual(self.storage.password, '1234_pw')
self.assertEqual(self.storage.token, '1234_token') self.assertEqual(self.storage.token, '1234_token')
r = self.admin_client_1.post(self.url, {'name': 'NewStorage', 'password': '1234_pw', 'token': '1234_token', 'method': 'not_a_valid_method'}) r = self.admin_client_1.post(
self.assertFormError(r, 'form', 'method', ['Select a valid choice. not_a_valid_method is not one of the available choices.']) 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.' # noqa: E501
]
)
def test_edit_storage_permissions(self): def test_edit_storage_permissions(self):
r = self.anonymous_client.get(self.url) r = self.anonymous_client.get(self.url)

View File

@ -7,6 +7,7 @@ from cookbook.tests.test_setup import TestBase
class TestEditsRecipe(TestBase): class TestEditsRecipe(TestBase):
# flake8: noqa
def test_ld_json(self): def test_ld_json(self):
test_list = [ test_list = [
{'file': 'cookbook/tests/resources/websites/ld_json_1.html', 'result_length': 3218}, {'file': 'cookbook/tests/resources/websites/ld_json_1.html', 'result_length': 3218},
@ -77,10 +78,10 @@ class TestEditsRecipe(TestBase):
"3.5 l Wasser": (3.5, "l", "Wasser", ""), "3.5 l Wasser": (3.5, "l", "Wasser", ""),
"400 g Karotte(n)": (400, "g", "Karotte(n)", "") "400 g Karotte(n)": (400, "g", "Karotte(n)", "")
} }
# for German you could say that if an ingredient does not have an amount and it starts with a lowercase letter, then that is a unit ("etwas", "evtl.") # for German you could say that if an ingredient does not have
# does not apply to English tho # an amount # and it starts with a lowercase letter, then that
# is a unit ("etwas", "evtl.") does not apply to English tho
errors = 0
count = 0 count = 0
for key, val in expectations.items(): for key, val in expectations.items():
count += 1 count += 1

View File

@ -1,6 +1,6 @@
from django.contrib import auth from django.contrib import auth
from django.contrib.auth.models import User, Group from django.contrib.auth.models import Group, User
from django.test import TestCase, Client from django.test import Client, TestCase
class TestBase(TestCase): class TestBase(TestCase):
@ -38,8 +38,14 @@ class TestBase(TestCase):
user.is_superuser = True user.is_superuser = True
user.save() user.save()
def batch_requests(self, clients, url, method='get', payload={}, content_type=''): def batch_requests(
self, clients, url, method='get', payload={}, content_type=''
):
for c in clients: for c in clients:
if method == 'get': if method == 'get':
r = c[0].get(url) r = c[0].get(url)
self.assertEqual(r.status_code, c[1], msg=f'GET request failed for user {auth.get_user(c[0])} when testing url {url}') self.assertEqual(
r.status_code,
c[1],
msg=f'GET request failed for user {auth.get_user(c[0])} when testing url {url}' # noqa: E501
)

View File

@ -1,8 +1,7 @@
from django.contrib import auth
from django.urls import reverse
from cookbook.models import Recipe from cookbook.models import Recipe
from cookbook.tests.views.test_views import TestViews from cookbook.tests.views.test_views import TestViews
from django.contrib import auth
from django.urls import reverse
class TestViewsApi(TestViews): class TestViewsApi(TestViews):

View File

@ -1,6 +1,5 @@
from django.urls import reverse
from cookbook.tests.views.test_views import TestViews from cookbook.tests.views.test_views import TestViews
from django.urls import reverse
class TestViewsGeneral(TestViews): class TestViewsGeneral(TestViews):
@ -19,11 +18,29 @@ class TestViewsGeneral(TestViews):
def test_books(self): def test_books(self):
url = reverse('view_books') url = reverse('view_books')
self.batch_requests([(self.anonymous_client, 302), (self.guest_client_1, 302), (self.user_client_1, 200), (self.admin_client_1, 200), (self.superuser_client, 200)], url) self.batch_requests(
[
(self.anonymous_client, 302),
(self.guest_client_1, 302),
(self.user_client_1, 200),
(self.admin_client_1, 200),
(self.superuser_client, 200)
],
url
)
def test_plan(self): def test_plan(self):
url = reverse('view_plan') url = reverse('view_plan')
self.batch_requests([(self.anonymous_client, 302), (self.guest_client_1, 302), (self.user_client_1, 200), (self.admin_client_1, 200), (self.superuser_client, 200)], url) self.batch_requests(
[
(self.anonymous_client, 302),
(self.guest_client_1, 302),
(self.user_client_1, 200),
(self.admin_client_1, 200),
(self.superuser_client, 200)
],
url
)
def test_plan_entry(self): def test_plan_entry(self):
# TODO add appropriate test # TODO add appropriate test
@ -31,28 +48,91 @@ class TestViewsGeneral(TestViews):
def test_shopping(self): def test_shopping(self):
url = reverse('view_shopping') url = reverse('view_shopping')
self.batch_requests([(self.anonymous_client, 302), (self.guest_client_1, 302), (self.user_client_1, 200), (self.admin_client_1, 200), (self.superuser_client, 200)], url) self.batch_requests(
[
(self.anonymous_client, 302),
(self.guest_client_1, 302),
(self.user_client_1, 200),
(self.admin_client_1, 200),
(self.superuser_client, 200)
],
url
)
def test_settings(self): def test_settings(self):
url = reverse('view_settings') url = reverse('view_settings')
self.batch_requests([(self.anonymous_client, 302), (self.guest_client_1, 200), (self.user_client_1, 200), (self.admin_client_1, 200), (self.superuser_client, 200)], url) self.batch_requests(
[
(self.anonymous_client, 302),
(self.guest_client_1, 200),
(self.user_client_1, 200),
(self.admin_client_1, 200),
(self.superuser_client, 200)
],
url
)
def test_history(self): def test_history(self):
url = reverse('view_history') url = reverse('view_history')
self.batch_requests([(self.anonymous_client, 302), (self.guest_client_1, 200), (self.user_client_1, 200), (self.admin_client_1, 200), (self.superuser_client, 200)], url) self.batch_requests(
[
(self.anonymous_client, 302),
(self.guest_client_1, 200),
(self.user_client_1, 200),
(self.admin_client_1, 200),
(self.superuser_client, 200)
],
url
)
def test_system(self): def test_system(self):
url = reverse('view_system') url = reverse('view_system')
self.batch_requests([(self.anonymous_client, 302), (self.guest_client_1, 302), (self.user_client_1, 302), (self.admin_client_1, 200), (self.superuser_client, 200)], url) self.batch_requests(
[
(self.anonymous_client, 302),
(self.guest_client_1, 302),
(self.user_client_1, 302),
(self.admin_client_1, 200),
(self.superuser_client, 200)
],
url
)
def test_setup(self): def test_setup(self):
url = reverse('view_setup') url = reverse('view_setup')
self.batch_requests([(self.anonymous_client, 302), (self.guest_client_1, 302), (self.user_client_1, 302), (self.admin_client_1, 302), (self.superuser_client, 302)], url) self.batch_requests(
[
(self.anonymous_client, 302),
(self.guest_client_1, 302),
(self.user_client_1, 302),
(self.admin_client_1, 302),
(self.superuser_client, 302)
],
url
)
def test_markdown_info(self): def test_markdown_info(self):
url = reverse('docs_markdown') url = reverse('docs_markdown')
self.batch_requests([(self.anonymous_client, 200), (self.guest_client_1, 200), (self.user_client_1, 200), (self.admin_client_1, 200), (self.superuser_client, 200)], url) self.batch_requests(
[
(self.anonymous_client, 200),
(self.guest_client_1, 200),
(self.user_client_1, 200),
(self.admin_client_1, 200),
(self.superuser_client, 200)
],
url
)
def test_api_info(self): def test_api_info(self):
url = reverse('docs_api') url = reverse('docs_api')
self.batch_requests([(self.anonymous_client, 302), (self.guest_client_1, 200), (self.user_client_1, 200), (self.admin_client_1, 200), (self.superuser_client, 200)], url) self.batch_requests(
[
(self.anonymous_client, 302),
(self.guest_client_1, 200),
(self.user_client_1, 200),
(self.admin_client_1, 200),
(self.superuser_client, 200)
],
url
)

View File

@ -1,11 +1,10 @@
import uuid import uuid
from django.contrib import auth
from django.urls import reverse
from cookbook.helper.permission_helper import share_link_valid from cookbook.helper.permission_helper import share_link_valid
from cookbook.models import Recipe, ShareLink from cookbook.models import Recipe, ShareLink
from cookbook.tests.views.test_views import TestViews from cookbook.tests.views.test_views import TestViews
from django.contrib import auth
from django.urls import reverse
class TestViewsGeneral(TestViews): class TestViewsGeneral(TestViews):
@ -31,14 +30,23 @@ class TestViewsGeneral(TestViews):
self.assertIsNotNone(share) self.assertIsNotNone(share)
self.assertTrue(share_link_valid(internal_recipe, share.uuid)) self.assertTrue(share_link_valid(internal_recipe, share.uuid))
url = reverse('view_recipe', kwargs={'pk': internal_recipe.pk, 'share': share.uuid}) url = reverse(
'view_recipe',
kwargs={'pk': internal_recipe.pk, 'share': share.uuid}
)
r = self.anonymous_client.get(url) r = self.anonymous_client.get(url)
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
url = reverse('view_recipe', kwargs={'pk': (internal_recipe.pk + 1), 'share': share.uuid}) url = reverse(
'view_recipe',
kwargs={'pk': (internal_recipe.pk + 1), 'share': share.uuid}
)
r = self.anonymous_client.get(url) r = self.anonymous_client.get(url)
self.assertEqual(r.status_code, 404) self.assertEqual(r.status_code, 404)
url = reverse('view_recipe', kwargs={'pk': internal_recipe.pk, 'share': uuid.uuid4()}) url = reverse(
'view_recipe',
kwargs={'pk': internal_recipe.pk, 'share': uuid.uuid4()}
)
r = self.anonymous_client.get(url) r = self.anonymous_client.get(url)
self.assertEqual(r.status_code, 302) self.assertEqual(r.status_code, 302)

View File

@ -1,13 +1,18 @@
from pydoc import locate from pydoc import locate
from django.urls import path, include from django.urls import include, path
from django.views.generic import TemplateView
from recipes.version import VERSION_NUMBER
from rest_framework import routers from rest_framework import routers
from rest_framework.schemas import get_schema_view from rest_framework.schemas import get_schema_view
from .views import *
from cookbook.views import api, import_export
from cookbook.helper import dal from cookbook.helper import dal
from .models import (Comment, Food, InviteLink, Keyword, MealPlan, Recipe,
RecipeBook, RecipeBookEntry, RecipeImport, ShoppingList,
Storage, Sync, SyncLog, get_model_name)
from .views import api, data, delete, edit, import_export, lists, new, views
router = routers.DefaultRouter() router = routers.DefaultRouter()
router.register(r'user-name', api.UserNameViewSet, basename='username') router.register(r'user-name', api.UserNameViewSet, basename='username')
router.register(r'user-preference', api.UserPreferenceViewSet) router.register(r'user-preference', api.UserPreferenceViewSet)
@ -41,43 +46,100 @@ urlpatterns = [
path('shopping/<int:pk>', views.shopping_list, name='view_shopping'), path('shopping/<int:pk>', views.shopping_list, name='view_shopping'),
path('settings/', views.user_settings, name='view_settings'), path('settings/', views.user_settings, name='view_settings'),
path('history/', views.history, name='view_history'), path('history/', views.history, name='view_history'),
path('offline/', views.offline, name='view_offline'),
path(
'service-worker.js', (
TemplateView.as_view(
template_name="service-worker.js",
content_type='application/javascript',
)
),
name='service_worker'
),
path('test/', views.test, name='view_test'), path('test/', views.test, name='view_test'),
path('import/', import_export.import_recipe, name='view_import'), path('import/', import_export.import_recipe, name='view_import'),
path('export/', import_export.export_recipe, name='view_export'), path('export/', import_export.export_recipe, name='view_export'),
path('view/recipe/<int:pk>', views.recipe_view, name='view_recipe'), path('view/recipe/<int:pk>', views.recipe_view, name='view_recipe'),
path('view/recipe/<int:pk>/<slug:share>', views.recipe_view, name='view_recipe'), path(
'view/recipe/<int:pk>/<slug:share>',
views.recipe_view,
name='view_recipe'
),
path('new/recipe-import/<int:import_id>/', new.create_new_external_recipe, name='new_recipe_import'), path(
'new/recipe-import/<int:import_id>/',
new.create_new_external_recipe,
name='new_recipe_import'
),
path('new/share-link/<int:pk>/', new.share_link, name='new_share_link'), path('new/share-link/<int:pk>/', new.share_link, name='new_share_link'),
path('edit/recipe/<int:pk>/', edit.switch_recipe, name='edit_recipe'), path('edit/recipe/<int:pk>/', edit.switch_recipe, name='edit_recipe'),
path('edit/recipe/internal/<int:pk>/', edit.internal_recipe_update, name='edit_internal_recipe'), # for internal use only
path('edit/recipe/external/<int:pk>/', edit.ExternalRecipeUpdate.as_view(), name='edit_external_recipe'), # for internal use only # for internal use only
path('edit/recipe/convert/<int:pk>/', edit.convert_recipe, name='edit_convert_recipe'), # for internal use only path(
'edit/recipe/internal/<int:pk>/',
edit.internal_recipe_update,
name='edit_internal_recipe'
),
path(
'edit/recipe/external/<int:pk>/',
edit.ExternalRecipeUpdate.as_view(),
name='edit_external_recipe'
),
path(
'edit/recipe/convert/<int:pk>/',
edit.convert_recipe,
name='edit_convert_recipe'
),
path('edit/storage/<int:pk>/', edit.edit_storage, name='edit_storage'), path('edit/storage/<int:pk>/', edit.edit_storage, name='edit_storage'),
path('edit/ingredient/', edit.edit_ingredients, name='edit_food'), path('edit/ingredient/', edit.edit_ingredients, name='edit_food'),
path('delete/recipe-source/<int:pk>/', delete.delete_recipe_source, name='delete_recipe_source'), path(
'delete/recipe-source/<int:pk>/',
delete.delete_recipe_source,
name='delete_recipe_source'
),
path('data/sync', data.sync, name='data_sync'), # TODO move to generic "new" view # TODO move to generic "new" view
path('data/sync', data.sync, name='data_sync'),
path('data/batch/edit', data.batch_edit, name='data_batch_edit'), path('data/batch/edit', data.batch_edit, name='data_batch_edit'),
path('data/batch/import', data.batch_import, name='data_batch_import'), path('data/batch/import', data.batch_import, name='data_batch_import'),
path('data/sync/wait', data.sync_wait, name='data_sync_wait'), path('data/sync/wait', data.sync_wait, name='data_sync_wait'),
path('data/statistics', data.statistics, name='data_stats'), path('data/statistics', data.statistics, name='data_stats'),
path('data/import/url', data.import_url, name='data_import_url'), path('data/import/url', data.import_url, name='data_import_url'),
path('api/get_external_file_link/<int:recipe_id>/', api.get_external_file_link, name='api_get_external_file_link'), path(
path('api/get_recipe_file/<int:recipe_id>/', api.get_recipe_file, name='api_get_recipe_file'), 'api/get_external_file_link/<int:recipe_id>/',
api.get_external_file_link,
name='api_get_external_file_link'
),
path(
'api/get_recipe_file/<int:recipe_id>/',
api.get_recipe_file,
name='api_get_recipe_file'
),
path('api/sync_all/', api.sync_all, name='api_sync'), path('api/sync_all/', api.sync_all, name='api_sync'),
path('api/log_cooking/<int:recipe_id>/', api.log_cooking, name='api_log_cooking'), path(
path('api/plan-ical/<slug:from_date>/<slug:to_date>/', api.get_plan_ical, name='api_get_plan_ical'), 'api/log_cooking/<int:recipe_id>/',
path('api/recipe-from-url/', api.recipe_from_url, name='api_recipe_from_url'), api.log_cooking,
name='api_log_cooking'
),
path(
'api/plan-ical/<slug:from_date>/<slug:to_date>/',
api.get_plan_ical,
name='api_get_plan_ical'
),
path(
'api/recipe-from-url/', api.recipe_from_url, name='api_recipe_from_url'
),
path('api/backup/', api.get_backup, name='api_backup'), path('api/backup/', api.get_backup, name='api_backup'),
path('dal/keyword/', dal.KeywordAutocomplete.as_view(), name='dal_keyword'), path(
'dal/keyword/', dal.KeywordAutocomplete.as_view(), name='dal_keyword'
),
path('dal/food/', dal.IngredientsAutocomplete.as_view(), name='dal_food'), path('dal/food/', dal.IngredientsAutocomplete.as_view(), name='dal_food'),
path('dal/unit/', dal.UnitAutocomplete.as_view(), name='dal_unit'), path('dal/unit/', dal.UnitAutocomplete.as_view(), name='dal_unit'),
@ -90,24 +152,50 @@ urlpatterns = [
), name='openapi-schema'), ), name='openapi-schema'),
path('api/', include((router.urls, 'api'))), path('api/', include((router.urls, 'api'))),
path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), path(
'api-auth/',
include('rest_framework.urls', namespace='rest_framework')
),
] ]
generic_models = (Recipe, RecipeImport, Storage, RecipeBook, MealPlan, SyncLog, Sync, Comment, RecipeBookEntry, Keyword, Food, ShoppingList, InviteLink) generic_models = (
Recipe, RecipeImport, Storage, RecipeBook, MealPlan, SyncLog, Sync,
Comment, RecipeBookEntry, Keyword, Food, ShoppingList, InviteLink
)
for m in generic_models: for m in generic_models:
py_name = get_model_name(m) py_name = get_model_name(m)
url_name = py_name.replace('_', '-') url_name = py_name.replace('_', '-')
if c := locate(f'cookbook.views.new.{m.__name__}Create'): if c := locate(f'cookbook.views.new.{m.__name__}Create'):
urlpatterns.append(path(f'new/{url_name}/', c.as_view(), name=f'new_{py_name}')) urlpatterns.append(
path(
f'new/{url_name}/', c.as_view(), name=f'new_{py_name}'
)
)
if c := locate(f'cookbook.views.edit.{m.__name__}Update'): if c := locate(f'cookbook.views.edit.{m.__name__}Update'):
urlpatterns.append(path(f'edit/{url_name}/<int:pk>/', c.as_view(), name=f'edit_{py_name}')) urlpatterns.append(
path(
f'edit/{url_name}/<int:pk>/',
c.as_view(),
name=f'edit_{py_name}'
)
)
if c := getattr(lists, py_name, None): if c := getattr(lists, py_name, None):
urlpatterns.append(path(f'list/{url_name}/', c, name=f'list_{py_name}')) urlpatterns.append(
path(
f'list/{url_name}/', c, name=f'list_{py_name}'
)
)
if c := locate(f'cookbook.views.delete.{m.__name__}Delete'): if c := locate(f'cookbook.views.delete.{m.__name__}Delete'):
urlpatterns.append(path(f'delete/{url_name}/<int:pk>/', c.as_view(), name=f'delete_{py_name}')) urlpatterns.append(
path(
f'delete/{url_name}/<int:pk>/',
c.as_view(),
name=f'delete_{py_name}'
)
)

View File

@ -1,7 +1,19 @@
from cookbook.views.views import * import cookbook.views.api
from cookbook.views.api import * import cookbook.views.data
from cookbook.views.data import * import cookbook.views.delete
from cookbook.views.edit import * import cookbook.views.edit
from cookbook.views.new import * import cookbook.views.import_export
from cookbook.views.lists import * import cookbook.views.lists
from cookbook.views.delete import * import cookbook.views.new
import cookbook.views.views
__all__ = [
'api',
'data',
'delete',
'edit',
'import_export',
'lists',
'new',
'views',
]

View File

@ -4,7 +4,6 @@ import re
import uuid import uuid
import requests import requests
from PIL import Image
from annoying.decorators import ajax_request from annoying.decorators import ajax_request
from annoying.functions import get_object_or_None from annoying.functions import get_object_or_None
from django.contrib import messages from django.contrib import messages
@ -12,28 +11,45 @@ from django.contrib.auth.models import User
from django.core import management from django.core import management
from django.core.files import File from django.core.files import File
from django.db.models import Q from django.db.models import Q
from django.http import HttpResponse, FileResponse, JsonResponse from django.http import FileResponse, HttpResponse, JsonResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from django.utils import timezone, dateformat from django.utils import timezone
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic.base import View
from icalendar import Calendar, Event from icalendar import Calendar, Event
from rest_framework import viewsets, permissions, decorators from PIL import Image
from rest_framework import decorators, permissions, viewsets
from rest_framework.exceptions import APIException from rest_framework.exceptions import APIException
from rest_framework.mixins import RetrieveModelMixin, UpdateModelMixin, ListModelMixin from rest_framework.mixins import (ListModelMixin, RetrieveModelMixin,
from rest_framework.parsers import JSONParser, FileUploadParser, MultiPartParser UpdateModelMixin)
from rest_framework.parsers import MultiPartParser
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import ViewSetMixin from rest_framework.viewsets import ViewSetMixin
from cookbook.helper.permission_helper import group_required, CustomIsOwner, CustomIsAdmin, CustomIsUser, CustomIsGuest, CustomIsShare, CustomIsShared from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest,
CustomIsOwner, CustomIsShare,
CustomIsShared, CustomIsUser,
group_required)
from cookbook.helper.recipe_url_import import get_from_html from cookbook.helper.recipe_url_import import get_from_html
from cookbook.models import Recipe, Sync, Storage, CookLog, MealPlan, MealType, ViewLog, UserPreference, RecipeBook, Ingredient, Food, Step, Keyword, Unit, SyncLog, ShoppingListRecipe, ShoppingList, ShoppingListEntry from cookbook.models import (CookLog, Food, Ingredient, Keyword, MealPlan,
MealType, Recipe, RecipeBook, ShoppingList,
ShoppingListEntry, ShoppingListRecipe, Step,
Storage, Sync, SyncLog, Unit, UserPreference,
ViewLog)
from cookbook.provider.dropbox import Dropbox from cookbook.provider.dropbox import Dropbox
from cookbook.provider.nextcloud import Nextcloud from cookbook.provider.nextcloud import Nextcloud
from cookbook.serializer import MealPlanSerializer, MealTypeSerializer, RecipeSerializer, ViewLogSerializer, UserNameSerializer, UserPreferenceSerializer, RecipeBookSerializer, IngredientSerializer, FoodSerializer, StepSerializer, \ from cookbook.serializer import (FoodSerializer, IngredientSerializer,
KeywordSerializer, RecipeImageSerializer, StorageSerializer, SyncSerializer, SyncLogSerializer, UnitSerializer, ShoppingListSerializer, ShoppingListRecipeSerializer, ShoppingListEntrySerializer, ShoppingListEntryCheckedSerializer, \ KeywordSerializer, MealPlanSerializer,
ShoppingListAutoSyncSerializer MealTypeSerializer, RecipeBookSerializer,
RecipeImageSerializer, RecipeSerializer,
ShoppingListAutoSyncSerializer,
ShoppingListEntrySerializer,
ShoppingListRecipeSerializer,
ShoppingListSerializer, StepSerializer,
StorageSerializer, SyncLogSerializer,
SyncSerializer, UnitSerializer,
UserNameSerializer, UserPreferenceSerializer,
ViewLogSerializer)
class UserNameViewSet(viewsets.ReadOnlyModelViewSet): class UserNameViewSet(viewsets.ReadOnlyModelViewSet):
@ -54,8 +70,10 @@ class UserNameViewSet(viewsets.ReadOnlyModelViewSet):
filter_list = self.request.query_params.get('filter_list', None) filter_list = self.request.query_params.get('filter_list', None)
if filter_list is not None: if filter_list is not None:
queryset = queryset.filter(pk__in=json.loads(filter_list)) queryset = queryset.filter(pk__in=json.loads(filter_list))
except ValueError as e: except ValueError:
raise APIException(_('Parameter filter_list incorrectly formatted')) raise APIException(
_('Parameter filter_list incorrectly formatted')
)
return queryset return queryset
@ -118,7 +136,8 @@ class KeywordViewSet(viewsets.ModelViewSet, StandardFilterMixin):
list: list:
optional parameters optional parameters
- **query**: search keywords for a string contained in the keyword name (case in-sensitive) - **query**: search keywords for a string contained
in the keyword name (case in-sensitive)
- **limit**: limits the amount of returned results - **limit**: limits the amount of returned results
""" """
queryset = Keyword.objects.all() queryset = Keyword.objects.all()
@ -138,7 +157,12 @@ class FoodViewSet(viewsets.ModelViewSet, StandardFilterMixin):
permission_classes = [CustomIsUser] permission_classes = [CustomIsUser]
class RecipeBookViewSet(RetrieveModelMixin, UpdateModelMixin, ListModelMixin, viewsets.GenericViewSet): class RecipeBookViewSet(
RetrieveModelMixin,
UpdateModelMixin,
ListModelMixin,
viewsets.GenericViewSet
):
queryset = RecipeBook.objects.all() queryset = RecipeBook.objects.all()
serializer_class = RecipeBookSerializer serializer_class = RecipeBookSerializer
permission_classes = [CustomIsOwner, CustomIsAdmin] permission_classes = [CustomIsOwner, CustomIsAdmin]
@ -163,7 +187,10 @@ class MealPlanViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticated] # TODO fix permissions permission_classes = [permissions.IsAuthenticated] # TODO fix permissions
def get_queryset(self): def get_queryset(self):
queryset = MealPlan.objects.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).distinct().all() queryset = MealPlan.objects.filter(
Q(created_by=self.request.user) |
Q(shared=self.request.user)
).distinct().all()
from_date = self.request.query_params.get('from_date', None) from_date = self.request.query_params.get('from_date', None)
if from_date is not None: if from_date is not None:
@ -177,15 +204,16 @@ class MealPlanViewSet(viewsets.ModelViewSet):
class MealTypeViewSet(viewsets.ModelViewSet): class MealTypeViewSet(viewsets.ModelViewSet):
""" """
list: returns list of meal types created by the
returns list of meal types created by the requesting user ordered by the order field requesting user ordered by the order field.
""" """
queryset = MealType.objects.order_by('order').all() queryset = MealType.objects.order_by('order').all()
serializer_class = MealTypeSerializer serializer_class = MealTypeSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
def get_queryset(self): def get_queryset(self):
queryset = MealType.objects.order_by('order', 'id').filter(created_by=self.request.user).all() queryset = MealType.objects.order_by('order', 'id') \
.filter(created_by=self.request.user).all()
return queryset return queryset
@ -206,12 +234,14 @@ class RecipeViewSet(viewsets.ModelViewSet, StandardFilterMixin):
list: list:
optional parameters optional parameters
- **query**: search recipes for a string contained in the recipe name (case in-sensitive) - **query**: search recipes for a string contained
in the recipe name (case in-sensitive)
- **limit**: limits the amount of returned results - **limit**: limits the amount of returned results
""" """
queryset = Recipe.objects.all() queryset = Recipe.objects.all()
serializer_class = RecipeSerializer serializer_class = RecipeSerializer
permission_classes = [CustomIsShare | CustomIsGuest] # TODO split read and write permission for meal plan guest # TODO split read and write permission for meal plan guest
permission_classes = [CustomIsShare | CustomIsGuest]
def get_queryset(self): def get_queryset(self):
@ -231,7 +261,9 @@ class RecipeViewSet(viewsets.ModelViewSet, StandardFilterMixin):
) )
def image(self, request, pk): def image(self, request, pk):
obj = self.get_object() obj = self.get_object()
serializer = self.serializer_class(obj, data=request.data, partial=True) serializer = self.serializer_class(
obj, data=request.data, partial=True
)
if serializer.is_valid(): if serializer.is_valid():
serializer.save() serializer.save()
@ -275,7 +307,9 @@ class ShoppingListViewSet(viewsets.ModelViewSet):
def get_queryset(self): def get_queryset(self):
if self.request.user.is_superuser: if self.request.user.is_superuser:
return self.queryset return self.queryset
return self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).all() return self.queryset.filter(
Q(created_by=self.request.user) | Q(shared=self.request.user)
).all()
def get_serializer_class(self): def get_serializer_class(self):
autosync = self.request.query_params.get('autosync', None) autosync = self.request.query_params.get('autosync', None)
@ -290,7 +324,8 @@ class ViewLogViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
def get_queryset(self): def get_queryset(self):
queryset = ViewLog.objects.filter(created_by=self.request.user).all()[:5] queryset = ViewLog.objects \
.filter(created_by=self.request.user).all()[:5]
return queryset return queryset
@ -307,7 +342,8 @@ def get_recipe_provider(recipe):
def update_recipe_links(recipe): def update_recipe_links(recipe):
if not recipe.link: if not recipe.link:
recipe.link = get_recipe_provider(recipe).get_share_link(recipe) # TODO response validation in apis # TODO response validation in apis
recipe.link = get_recipe_provider(recipe).get_share_link(recipe)
recipe.save() recipe.save()
@ -346,10 +382,14 @@ def sync_all(request):
error = True error = True
if not error: if not error:
messages.add_message(request, messages.SUCCESS, _('Sync successful!')) messages.add_message(
request, messages.SUCCESS, _('Sync successful!')
)
return redirect('list_recipe_import') return redirect('list_recipe_import')
else: else:
messages.add_message(request, messages.ERROR, _('Error synchronizing with Storage')) messages.add_message(
request, messages.ERROR, _('Error synchronizing with Storage')
)
return redirect('list_recipe_import') return redirect('list_recipe_import')
@ -374,7 +414,9 @@ def log_cooking(request, recipe_id):
@group_required('user') @group_required('user')
def get_plan_ical(request, from_date, to_date): def get_plan_ical(request, from_date, to_date):
queryset = MealPlan.objects.filter(Q(created_by=request.user) | Q(shared=request.user)).distinct().all() queryset = MealPlan.objects.filter(
Q(created_by=request.user) | Q(shared=request.user)
).distinct().all()
if from_date is not None: if from_date is not None:
queryset = queryset.filter(date__gte=from_date) queryset = queryset.filter(date__gte=from_date)
@ -394,7 +436,7 @@ def get_plan_ical(request, from_date, to_date):
cal.add_component(event) cal.add_component(event)
response = FileResponse(io.BytesIO(cal.to_ical())) response = FileResponse(io.BytesIO(cal.to_ical()))
response["Content-Disposition"] = f'attachment; filename=meal_plan_{from_date}-{to_date}.ics' response["Content-Disposition"] = f'attachment; filename=meal_plan_{from_date}-{to_date}.ics' # noqa: E501
return response return response
@ -403,14 +445,28 @@ def get_plan_ical(request, from_date, to_date):
def recipe_from_url(request): def recipe_from_url(request):
url = request.POST['url'] url = request.POST['url']
headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Safari/537.36'} headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.106 Safari/537.36' # noqa: E501
}
try: try:
response = requests.get(url, headers=headers) response = requests.get(url, headers=headers)
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
return JsonResponse({'error': True, 'msg': _('The requested page could not be found.')}, status=400) return JsonResponse(
{
'error': True,
'msg': _('The requested page could not be found.')
},
status=400
)
if response.status_code == 403: if response.status_code == 403:
return JsonResponse({'error': True, 'msg': _('The requested page refused to provide any information (Status Code 403).')}, status=400) return JsonResponse(
{
'error': True,
'msg': _('The requested page refused to provide any information (Status Code 403).') # noqa: E501
},
status=400
)
return get_from_html(response.text, url) return get_from_html(response.text, url)
@ -419,9 +475,11 @@ def get_backup(request):
return HttpResponse('', status=403) return HttpResponse('', status=403)
buf = io.StringIO() buf = io.StringIO()
management.call_command('dumpdata', exclude=['contenttypes', 'auth'], stdout=buf) management.call_command(
'dumpdata', exclude=['contenttypes', 'auth'], stdout=buf
)
response = FileResponse(buf.getvalue()) response = FileResponse(buf.getvalue())
response["Content-Disposition"] = f'attachment; filename=backup{date_format(timezone.now(), format="SHORT_DATETIME_FORMAT", use_l10n=True)}.json' response["Content-Disposition"] = f'attachment; filename=backup{date_format(timezone.now(), format="SHORT_DATETIME_FORMAT", use_l10n=True)}.json' # noqa: E501
return response return response

View File

@ -1,22 +1,25 @@
import json import json
import uuid
from datetime import datetime from datetime import datetime
from io import BytesIO from io import BytesIO
import requests import requests
from PIL import Image, UnidentifiedImageError
from django.contrib import messages from django.contrib import messages
from django.core.files import File from django.core.files import File
from django.db.transaction import atomic from django.db.transaction import atomic
from django.utils.translation import gettext as _ from django.http import HttpResponse, HttpResponseRedirect
from django.http import HttpResponseRedirect, HttpResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext as _
from django.utils.translation import ngettext from django.utils.translation import ngettext
from django_tables2 import RequestConfig from django_tables2 import RequestConfig
from PIL import Image, UnidentifiedImageError
from cookbook.forms import SyncForm, BatchEditForm from cookbook.forms import BatchEditForm, SyncForm
from cookbook.helper.permission_helper import group_required, has_group_permission from cookbook.helper.permission_helper import (group_required,
from cookbook.models import * has_group_permission)
from cookbook.models import (Comment, Food, Ingredient, Keyword, Recipe,
RecipeImport, Step, Sync, Unit)
from cookbook.tables import SyncTable from cookbook.tables import SyncTable
@ -24,7 +27,10 @@ from cookbook.tables import SyncTable
def sync(request): def sync(request):
if request.method == "POST": if request.method == "POST":
if not has_group_permission(request.user, ['admin']): if not has_group_permission(request.user, ['admin']):
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!')) messages.add_message(
request, messages.ERROR,
_('You do not have the required permissions to view this page!') # noqa: E501
)
return HttpResponseRedirect(reverse('data_sync')) return HttpResponseRedirect(reverse('data_sync'))
form = SyncForm(request.POST) form = SyncForm(request.POST)
if form.is_valid(): if form.is_valid():
@ -38,9 +44,15 @@ def sync(request):
form = SyncForm() form = SyncForm()
monitored_paths = SyncTable(Sync.objects.all()) monitored_paths = SyncTable(Sync.objects.all())
RequestConfig(request, paginate={'per_page': 25}).configure(monitored_paths) RequestConfig(
request, paginate={'per_page': 25}
).configure(monitored_paths)
return render(request, 'batch/monitor.html', {'form': form, 'monitored_paths': monitored_paths}) return render(
request,
'batch/monitor.html',
{'form': form, 'monitored_paths': monitored_paths}
)
@group_required('user') @group_required('user')
@ -52,7 +64,13 @@ def sync_wait(request):
def batch_import(request): def batch_import(request):
imports = RecipeImport.objects.all() imports = RecipeImport.objects.all()
for new_recipe in imports: for new_recipe in imports:
recipe = Recipe(name=new_recipe.name, file_path=new_recipe.file_path, storage=new_recipe.storage, file_uid=new_recipe.file_uid, created_by=request.user) recipe = Recipe(
name=new_recipe.name,
file_path=new_recipe.file_path,
storage=new_recipe.storage,
file_uid=new_recipe.file_uid,
created_by=request.user
)
recipe.save() recipe.save()
new_recipe.delete() new_recipe.delete()
@ -115,7 +133,8 @@ def import_url(request):
recipe.steps.add(step) recipe.steps.add(step)
for kw in data['keywords']: for kw in data['keywords']:
if kw['id'] != "null" and (k := Keyword.objects.filter(id=kw['id']).first()): if kw['id'] != "null" \
and (k := Keyword.objects.filter(id=kw['id']).first()):
recipe.keywords.add(k) recipe.keywords.add(k)
elif data['all_keywords']: elif data['all_keywords']:
k = Keyword.objects.create(name=kw['text']) k = Keyword.objects.create(name=kw['text'])
@ -125,10 +144,14 @@ def import_url(request):
ingredient = Ingredient() ingredient = Ingredient()
if ing['ingredient']['text'] != '': if ing['ingredient']['text'] != '':
ingredient.food, f_created = Food.objects.get_or_create(name=ing['ingredient']['text']) ingredient.food, f_created = Food.objects.get_or_create(
name=ing['ingredient']['text']
)
if ing['unit'] and ing['unit']['text'] != '': if ing['unit'] and ing['unit']['text'] != '':
ingredient.unit, u_created = Unit.objects.get_or_create(name=ing['unit']['text']) ingredient.unit, u_created = Unit.objects.get_or_create(
name=ing['unit']['text']
)
# TODO properly handle no_amount recipes # TODO properly handle no_amount recipes
if isinstance(ing['amount'], str): if isinstance(ing['amount'], str):
@ -137,7 +160,8 @@ def import_url(request):
except ValueError: except ValueError:
ingredient.no_amount = True ingredient.no_amount = True
pass pass
elif isinstance(ing['amount'], float) or isinstance(ing['amount'], int): elif isinstance(ing['amount'], float) \
or isinstance(ing['amount'], int):
ingredient.amount = ing['amount'] ingredient.amount = ing['amount']
ingredient.note = ing['note'] if 'note' in ing else '' ingredient.note = ing['note'] if 'note' in ing else ''
@ -158,7 +182,9 @@ def import_url(request):
im_io = BytesIO() im_io = BytesIO()
img.save(im_io, 'PNG', quality=70) img.save(im_io, 'PNG', quality=70)
recipe.image = File(im_io, name=f'{uuid.uuid4()}_{recipe.pk}.png') recipe.image = File(
im_io, name=f'{uuid.uuid4()}_{recipe.pk}.png'
)
recipe.save() recipe.save()
except UnidentifiedImageError: except UnidentifiedImageError:
pass pass

View File

@ -1,15 +1,17 @@
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import ProtectedError from django.db.models import ProtectedError
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy, reverse from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic import DeleteView from django.views.generic import DeleteView
from cookbook.helper.permission_helper import group_required, GroupRequiredMixin, OwnerRequiredMixin from cookbook.helper.permission_helper import (GroupRequiredMixin,
from cookbook.models import Recipe, Sync, Keyword, RecipeImport, Storage, Comment, RecipeBook, \ OwnerRequiredMixin,
RecipeBookEntry, MealPlan, Food, InviteLink group_required)
from cookbook.models import (Comment, InviteLink, Keyword, MealPlan, Recipe,
RecipeBook, RecipeBookEntry, RecipeImport,
Storage, Sync)
from cookbook.provider.dropbox import Dropbox from cookbook.provider.dropbox import Dropbox
from cookbook.provider.nextcloud import Nextcloud from cookbook.provider.nextcloud import Nextcloud
@ -31,7 +33,8 @@ def delete_recipe_source(request, pk):
recipe = get_object_or_404(Recipe, pk=pk) recipe = get_object_or_404(Recipe, pk=pk)
if recipe.storage.method == Storage.DROPBOX: if recipe.storage.method == Storage.DROPBOX:
Dropbox.delete_file(recipe) # TODO central location to handle storage type switches # TODO central location to handle storage type switches
Dropbox.delete_file(recipe)
if recipe.storage.method == Storage.NEXTCLOUD: if recipe.storage.method == Storage.NEXTCLOUD:
Nextcloud.delete_file(recipe) Nextcloud.delete_file(recipe)
@ -94,7 +97,11 @@ class StorageDelete(GroupRequiredMixin, DeleteView):
try: try:
return self.delete(request, *args, **kwargs) return self.delete(request, *args, **kwargs)
except ProtectedError: except ProtectedError:
messages.add_message(request, messages.WARNING, _('Could not delete this storage backend as it is used in at least one monitor.')) messages.add_message(
request,
messages.WARNING,
_('Could not delete this storage backend as it is used in at least one monitor.') # noqa: E501
)
return HttpResponseRedirect(reverse('list_storage')) return HttpResponseRedirect(reverse('list_storage'))
@ -128,10 +135,16 @@ class RecipeBookEntryDelete(GroupRequiredMixin, DeleteView):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
obj = self.get_object() obj = self.get_object()
if not (obj.book.created_by == request.user or request.user.is_superuser): if not (obj.book.created_by == request.user
messages.add_message(request, messages.ERROR, _('You cannot interact with this object as it is not owned by you!')) or request.user.is_superuser):
messages.add_message(
request,
messages.ERROR,
_('You cannot interact with this object as it is not owned by you!') # noqa: E501
)
return HttpResponseRedirect(reverse('index')) return HttpResponseRedirect(reverse('index'))
return super(RecipeBookEntryDelete, self).dispatch(request, *args, **kwargs) return super(RecipeBookEntryDelete, self) \
.dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(RecipeBookEntryDelete, self).get_context_data(**kwargs) context = super(RecipeBookEntryDelete, self).get_context_data(**kwargs)

View File

@ -7,12 +7,16 @@ from django.urls import reverse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic import UpdateView from django.views.generic import UpdateView
from cookbook.forms import ExternalRecipeForm, KeywordForm, StorageForm, SyncForm, CommentForm, \ from cookbook.forms import (CommentForm, ExternalRecipeForm, FoodForm,
MealPlanForm, UnitMergeForm, RecipeBookForm, FoodForm, FoodMergeForm FoodMergeForm, KeywordForm, MealPlanForm,
from cookbook.helper.permission_helper import OwnerRequiredMixin RecipeBookForm, StorageForm, SyncForm,
from cookbook.helper.permission_helper import group_required, GroupRequiredMixin UnitMergeForm)
from cookbook.models import Recipe, Sync, Keyword, RecipeImport, Storage, Comment, Ingredient, RecipeBook, \ from cookbook.helper.permission_helper import (GroupRequiredMixin,
MealPlan, Food, MealType OwnerRequiredMixin,
group_required)
from cookbook.models import (Comment, Food, Ingredient, Keyword, MealPlan,
MealType, Recipe, RecipeBook, RecipeImport,
Storage, Sync)
from cookbook.provider.dropbox import Dropbox from cookbook.provider.dropbox import Dropbox
from cookbook.provider.nextcloud import Nextcloud from cookbook.provider.nextcloud import Nextcloud
@ -40,7 +44,9 @@ def convert_recipe(request, pk):
def internal_recipe_update(request, pk): def internal_recipe_update(request, pk):
recipe_instance = get_object_or_404(Recipe, pk=pk) recipe_instance = get_object_or_404(Recipe, pk=pk)
return render(request, 'forms/edit_internal_recipe.html', {'recipe': recipe_instance}) return render(
request, 'forms/edit_internal_recipe.html', {'recipe': recipe_instance}
)
class SyncUpdate(GroupRequiredMixin, UpdateView): class SyncUpdate(GroupRequiredMixin, UpdateView):
@ -99,7 +105,9 @@ def edit_storage(request, pk):
instance = get_object_or_404(Storage, pk=pk) instance = get_object_or_404(Storage, pk=pk)
if not (instance.created_by == request.user or request.user.is_superuser): if not (instance.created_by == request.user or request.user.is_superuser):
messages.add_message(request, messages.ERROR, _('You cannot edit this storage!')) messages.add_message(
request, messages.ERROR, _('You cannot edit this storage!')
)
return HttpResponseRedirect(reverse('list_storage')) return HttpResponseRedirect(reverse('list_storage'))
if request.method == "POST": if request.method == "POST":
@ -118,16 +126,26 @@ def edit_storage(request, pk):
instance.save() instance.save()
messages.add_message(request, messages.SUCCESS, _('Storage saved!')) messages.add_message(
request, messages.SUCCESS, _('Storage saved!')
)
else: 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: else:
pseudo_instance = instance pseudo_instance = instance
pseudo_instance.password = '__NO__CHANGE__' pseudo_instance.password = '__NO__CHANGE__'
pseudo_instance.token = '__NO__CHANGE__' pseudo_instance.token = '__NO__CHANGE__'
form = StorageForm(instance=pseudo_instance) form = StorageForm(instance=pseudo_instance)
return render(request, 'generic/edit_template.html', {'form': form, 'title': _('Storage')}) return render(
request,
'generic/edit_template.html',
{'form': form, 'title': _('Storage')}
)
class CommentUpdate(OwnerRequiredMixin, UpdateView): class CommentUpdate(OwnerRequiredMixin, UpdateView):
@ -141,7 +159,9 @@ class CommentUpdate(OwnerRequiredMixin, UpdateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(CommentUpdate, self).get_context_data(**kwargs) context = super(CommentUpdate, self).get_context_data(**kwargs)
context['title'] = _("Comment") context['title'] = _("Comment")
context['view_url'] = reverse('view_recipe', args=[self.object.recipe.pk]) context['view_url'] = reverse(
'view_recipe', args=[self.object.recipe.pk]
)
return context return context
@ -186,7 +206,8 @@ class MealPlanUpdate(OwnerRequiredMixin, UpdateView):
def get_form(self, form_class=None): def get_form(self, form_class=None):
form = self.form_class(**self.get_form_kwargs()) form = self.form_class(**self.get_form_kwargs())
form.fields['meal_type'].queryset = MealType.objects.filter(created_by=self.request.user).all() form.fields['meal_type'].queryset = MealType.objects \
.filter(created_by=self.request.user).all()
return form return form
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -206,17 +227,28 @@ class ExternalRecipeUpdate(GroupRequiredMixin, UpdateView):
old_recipe = Recipe.objects.get(pk=self.object.pk) old_recipe = Recipe.objects.get(pk=self.object.pk)
if not old_recipe.name == self.object.name: if not old_recipe.name == self.object.name:
if self.object.storage.method == Storage.DROPBOX: if self.object.storage.method == Storage.DROPBOX:
Dropbox.rename_file(old_recipe, self.object.name) # TODO central location to handle storage type switches # TODO central location to handle storage type switches
Dropbox.rename_file(old_recipe, self.object.name)
if self.object.storage.method == Storage.NEXTCLOUD: if self.object.storage.method == Storage.NEXTCLOUD:
Nextcloud.rename_file(old_recipe, self.object.name) Nextcloud.rename_file(old_recipe, self.object.name)
self.object.file_path = os.path.dirname(self.object.file_path) + '/' + self.object.name + os.path.splitext(self.object.file_path)[1] self.object.file_path = "%s/%s%s" % (
os.path.dirname(self.object.file_path),
self.object.name,
os.path.splitext(self.object.file_path)[1]
)
messages.add_message(self.request, messages.SUCCESS, _('Changes saved!')) messages.add_message(
self.request, messages.SUCCESS, _('Changes saved!')
)
return super(ExternalRecipeUpdate, self).form_valid(form) return super(ExternalRecipeUpdate, self).form_valid(form)
def form_invalid(self, form): def form_invalid(self, form):
messages.add_message(self.request, messages.ERROR, _('Error saving changes!')) messages.add_message(
self.request,
messages.ERROR,
_('Error saving changes!')
)
return super(ExternalRecipeUpdate, self).form_valid(form) return super(ExternalRecipeUpdate, self).form_valid(form)
def get_success_url(self): def get_success_url(self):
@ -227,7 +259,9 @@ class ExternalRecipeUpdate(GroupRequiredMixin, UpdateView):
context['title'] = _("Recipe") context['title'] = _("Recipe")
context['view_url'] = reverse('view_recipe', args=[self.object.pk]) context['view_url'] = reverse('view_recipe', args=[self.object.pk])
if self.object.storage: if self.object.storage:
context['delete_external_url'] = reverse('delete_recipe_source', args=[self.object.pk]) context['delete_external_url'] = reverse(
'delete_recipe_source', args=[self.object.pk]
)
return context return context
@ -240,16 +274,23 @@ def edit_ingredients(request):
new_unit = units_form.cleaned_data['new_unit'] new_unit = units_form.cleaned_data['new_unit']
old_unit = units_form.cleaned_data['old_unit'] old_unit = units_form.cleaned_data['old_unit']
if new_unit != old_unit: if new_unit != old_unit:
recipe_ingredients = Ingredient.objects.filter(unit=old_unit).all() recipe_ingredients = Ingredient.objects \
.filter(unit=old_unit).all()
for i in recipe_ingredients: for i in recipe_ingredients:
i.unit = new_unit i.unit = new_unit
i.save() i.save()
old_unit.delete() old_unit.delete()
success = True success = True
messages.add_message(request, messages.SUCCESS, _('Units merged!')) messages.add_message(
request, messages.SUCCESS, _('Units merged!')
)
else: else:
messages.add_message(request, messages.ERROR, _('Cannot merge with the same object!')) messages.add_message(
request,
messages.ERROR,
_('Cannot merge with the same object!')
)
food_form = FoodMergeForm(request.POST, prefix=FoodMergeForm.prefix) food_form = FoodMergeForm(request.POST, prefix=FoodMergeForm.prefix)
if food_form.is_valid(): if food_form.is_valid():
@ -263,9 +304,15 @@ def edit_ingredients(request):
old_food.delete() old_food.delete()
success = True success = True
messages.add_message(request, messages.SUCCESS, _('Foods merged!')) messages.add_message(
request, messages.SUCCESS, _('Foods merged!')
)
else: else:
messages.add_message(request, messages.ERROR, _('Cannot merge with the same object!')) messages.add_message(
request,
messages.ERROR,
_('Cannot merge with the same object!')
)
if success: if success:
units_form = UnitMergeForm() units_form = UnitMergeForm()
@ -274,4 +321,8 @@ def edit_ingredients(request):
units_form = UnitMergeForm() units_form = UnitMergeForm()
food_form = FoodMergeForm() food_form = FoodMergeForm()
return render(request, 'forms/ingredients.html', {'units_form': units_form, 'food_form': food_form}) return render(
request,
'forms/ingredients.html',
{'units_form': units_form, 'food_form': food_form}
)

View File

@ -5,7 +5,7 @@ from json import JSONDecodeError
from django.contrib import messages from django.contrib import messages
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.http import HttpResponseRedirect, HttpResponse from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import render from django.shortcuts import render
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -23,7 +23,9 @@ def import_recipe(request):
form = ImportForm(request.POST) form = ImportForm(request.POST)
if form.is_valid(): if form.is_valid():
try: try:
data = json.loads(re.sub(r'"id":([0-9])+,', '', form.cleaned_data['recipe'])) data = json.loads(
re.sub(r'"id":([0-9])+,', '', form.cleaned_data['recipe'])
)
sr = RecipeSerializer(data=data) sr = RecipeSerializer(data=data)
if sr.is_valid(): if sr.is_valid():
@ -34,18 +36,39 @@ def import_recipe(request):
try: try:
fmt, img = data['image'].split(';base64,') fmt, img = data['image'].split(';base64,')
ext = fmt.split('/')[-1] ext = fmt.split('/')[-1]
recipe.image = ContentFile(base64.b64decode(img), name=f'{recipe.pk}.{ext}') # TODO possible security risk, maybe some checks needed # TODO possible security risk,
# maybe some checks needed
recipe.image = (ContentFile(
base64.b64decode(img),
name=f'{recipe.pk}.{ext}')
)
recipe.save() recipe.save()
except ValueError: except ValueError:
pass pass
messages.add_message(request, messages.SUCCESS, _('Recipe imported successfully!')) messages.add_message(
return HttpResponseRedirect(reverse_lazy('view_recipe', args=[recipe.pk])) request,
messages.SUCCESS,
_('Recipe imported successfully!')
)
return HttpResponseRedirect(
reverse_lazy('view_recipe', args=[recipe.pk])
)
else: else:
messages.add_message(request, messages.ERROR, _('Something went wrong during the import!')) messages.add_message(
messages.add_message(request, messages.WARNING, sr.errors) request,
messages.ERROR,
_('Something went wrong during the import!')
)
messages.add_message(
request, messages.WARNING, sr.errors
)
except JSONDecodeError: except JSONDecodeError:
messages.add_message(request, messages.ERROR, _('Could not parse the supplied JSON!')) messages.add_message(
request,
messages.ERROR,
_('Could not parse the supplied JSON!')
)
else: else:
form = ImportForm() form = ImportForm()
@ -65,18 +88,23 @@ def export_recipe(request):
if recipe.image and form.cleaned_data['image']: if recipe.image and form.cleaned_data['image']:
with open(recipe.image.path, 'rb') as img_f: with open(recipe.image.path, 'rb') as img_f:
export['image'] = f'data:image/png;base64,{base64.b64encode(img_f.read()).decode("utf-8")}' export['image'] = f'data:image/png;base64,{base64.b64encode(img_f.read()).decode("utf-8")}' # noqa: E501
json_string = JSONRenderer().render(export).decode("utf-8") json_string = JSONRenderer().render(export).decode("utf-8")
if form.cleaned_data['download']: if form.cleaned_data['download']:
response = HttpResponse(json_string, content_type='text/plain') response = HttpResponse(
response['Content-Disposition'] = f'attachment; filename={recipe.name}.json' json_string, content_type='text/plain'
)
response['Content-Disposition'] = f'attachment; filename={recipe.name}.json' # noqa: E501
return response return response
context['export'] = re.sub(r'"id":([0-9])+,', '', json_string) context['export'] = re.sub(r'"id":([0-9])+,', '', json_string)
else: else:
form.add_error('recipe', _('External recipes cannot be exported, please share the file directly or select an internal recipe.')) form.add_error(
'recipe',
_('External recipes cannot be exported, please share the file directly or select an internal recipe.') # noqa: E501
)
else: else:
form = ExportForm() form = ExportForm()
recipe = request.GET.get('r') recipe = request.GET.get('r')

View File

@ -1,6 +1,5 @@
from datetime import datetime from datetime import datetime
from django.contrib.auth.decorators import login_required
from django.db.models import Q from django.db.models import Q
from django.db.models.functions import Lower from django.db.models.functions import Lower
from django.shortcuts import render from django.shortcuts import render
@ -9,8 +8,11 @@ from django_tables2 import RequestConfig
from cookbook.filters import IngredientFilter, ShoppingListFilter from cookbook.filters import IngredientFilter, ShoppingListFilter
from cookbook.helper.permission_helper import group_required from cookbook.helper.permission_helper import group_required
from cookbook.models import Keyword, SyncLog, RecipeImport, Storage, Food, ShoppingList, InviteLink from cookbook.models import (Food, InviteLink, Keyword, RecipeImport,
from cookbook.tables import KeywordTable, ImportLogTable, RecipeImportTable, StorageTable, IngredientTable, ShoppingListTable, InviteLinkTable ShoppingList, Storage, SyncLog)
from cookbook.tables import (ImportLogTable, IngredientTable, InviteLinkTable,
KeywordTable, RecipeImportTable,
ShoppingListTable, StorageTable)
@group_required('user') @group_required('user')
@ -18,15 +20,27 @@ def keyword(request):
table = KeywordTable(Keyword.objects.all()) table = KeywordTable(Keyword.objects.all())
RequestConfig(request, paginate={'per_page': 25}).configure(table) RequestConfig(request, paginate={'per_page': 25}).configure(table)
return render(request, 'generic/list_template.html', {'title': _("Keyword"), 'table': table, 'create_url': 'new_keyword'}) return render(
request,
'generic/list_template.html',
{'title': _("Keyword"), 'table': table, 'create_url': 'new_keyword'}
)
@group_required('admin') @group_required('admin')
def sync_log(request): def sync_log(request):
table = ImportLogTable(SyncLog.objects.all().order_by(Lower('created_at').desc())) table = ImportLogTable(
SyncLog.objects.all().order_by(
Lower('created_at').desc()
)
)
RequestConfig(request, paginate={'per_page': 25}).configure(table) RequestConfig(request, paginate={'per_page': 25}).configure(table)
return render(request, 'generic/list_template.html', {'title': _("Import Log"), 'table': table}) return render(
request,
'generic/list_template.html',
{'title': _("Import Log"), 'table': table}
)
@group_required('user') @group_required('user')
@ -35,27 +49,52 @@ def recipe_import(request):
RequestConfig(request, paginate={'per_page': 25}).configure(table) RequestConfig(request, paginate={'per_page': 25}).configure(table)
return render(request, 'generic/list_template.html', {'title': _("Discovery"), 'table': table, 'import_btn': True}) return render(
request,
'generic/list_template.html',
{'title': _("Discovery"), 'table': table, 'import_btn': True}
)
@group_required('user') @group_required('user')
def food(request): def food(request):
f = IngredientFilter(request.GET, queryset=Food.objects.all().order_by('pk')) f = IngredientFilter(
request.GET,
queryset=Food.objects.all().order_by('pk')
)
table = IngredientTable(f.qs) table = IngredientTable(f.qs)
RequestConfig(request, paginate={'per_page': 25}).configure(table) RequestConfig(request, paginate={'per_page': 25}).configure(table)
return render(request, 'generic/list_template.html', {'title': _("Ingredients"), 'table': table, 'filter': f}) return render(
request,
'generic/list_template.html',
{'title': _("Ingredients"), 'table': table, 'filter': f}
)
@group_required('user') @group_required('user')
def shopping_list(request): def shopping_list(request):
f = ShoppingListFilter(request.GET, queryset=ShoppingList.objects.filter(Q(created_by=request.user) | Q(shared=request.user)).all().order_by('finished', 'created_at')) f = ShoppingListFilter(
request.GET,
queryset=ShoppingList.objects.filter(
Q(created_by=request.user) |
Q(shared=request.user)
).all().order_by('finished', 'created_at'))
table = ShoppingListTable(f.qs) table = ShoppingListTable(f.qs)
RequestConfig(request, paginate={'per_page': 25}).configure(table) RequestConfig(request, paginate={'per_page': 25}).configure(table)
return render(request, 'generic/list_template.html', {'title': _("Shopping Lists"), 'table': table, 'filter': f, 'create_url': 'view_shopping'}) return render(
request,
'generic/list_template.html',
{
'title': _("Shopping Lists"),
'table': table,
'filter': f,
'create_url': 'view_shopping'
}
)
@group_required('admin') @group_required('admin')
@ -63,12 +102,31 @@ def storage(request):
table = StorageTable(Storage.objects.all()) table = StorageTable(Storage.objects.all())
RequestConfig(request, paginate={'per_page': 25}).configure(table) RequestConfig(request, paginate={'per_page': 25}).configure(table)
return render(request, 'generic/list_template.html', {'title': _("Storage Backend"), 'table': table, 'create_url': 'new_storage'}) return render(
request,
'generic/list_template.html',
{
'title': _("Storage Backend"),
'table': table,
'create_url': 'new_storage'
}
)
@group_required('admin') @group_required('admin')
def invite_link(request): def invite_link(request):
table = InviteLinkTable(InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None).all()) table = InviteLinkTable(
InviteLink.objects.filter(
valid_until__gte=datetime.today(), used_by=None
).all())
RequestConfig(request, paginate={'per_page': 25}).configure(table) RequestConfig(request, paginate={'per_page': 25}).configure(table)
return render(request, 'generic/list_template.html', {'title': _("Invite Links"), 'table': table, 'create_url': 'new_invite_link'}) return render(
request,
'generic/list_template.html',
{
'title': _("Invite Links"),
'table': table,
'create_url': 'new_invite_link'
}
)

View File

@ -3,15 +3,17 @@ from datetime import datetime
from django.contrib import messages from django.contrib import messages
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import render, redirect, get_object_or_404 from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse_lazy, reverse from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic import CreateView from django.views.generic import CreateView
from cookbook.forms import ImportRecipeForm, RecipeImport, KeywordForm, Storage, StorageForm, InternalRecipeForm, \ from cookbook.forms import (ImportRecipeForm, InviteLinkForm, KeywordForm,
RecipeBookForm, MealPlanForm, InviteLinkForm MealPlanForm, RecipeBookForm, Storage, StorageForm)
from cookbook.helper.permission_helper import GroupRequiredMixin, group_required from cookbook.helper.permission_helper import (GroupRequiredMixin,
from cookbook.models import Keyword, Recipe, RecipeBook, MealPlan, ShareLink, MealType, Step, InviteLink group_required)
from cookbook.models import (InviteLink, Keyword, MealPlan, MealType, Recipe,
RecipeBook, RecipeImport, ShareLink, Step)
class RecipeCreate(GroupRequiredMixin, CreateView): class RecipeCreate(GroupRequiredMixin, CreateView):
@ -26,7 +28,9 @@ class RecipeCreate(GroupRequiredMixin, CreateView):
obj.internal = True obj.internal = True
obj.save() obj.save()
obj.steps.add(Step.objects.create()) obj.steps.add(Step.objects.create())
return HttpResponseRedirect(reverse('edit_recipe', kwargs={'pk': obj.pk})) return HttpResponseRedirect(
reverse('edit_recipe', kwargs={'pk': obj.pk})
)
def get_success_url(self): def get_success_url(self):
return reverse('edit_recipe', kwargs={'pk': self.object.pk}) return reverse('edit_recipe', kwargs={'pk': self.object.pk})
@ -41,7 +45,9 @@ class RecipeCreate(GroupRequiredMixin, CreateView):
def share_link(request, pk): def share_link(request, pk):
recipe = get_object_or_404(Recipe, pk=pk) recipe = get_object_or_404(Recipe, pk=pk)
link = ShareLink.objects.create(recipe=recipe, created_by=request.user) link = ShareLink.objects.create(recipe=recipe, created_by=request.user)
return HttpResponseRedirect(reverse('view_recipe', kwargs={'pk': pk, 'share': link.uuid})) return HttpResponseRedirect(
reverse('view_recipe', kwargs={'pk': pk, 'share': link.uuid})
)
class KeywordCreate(GroupRequiredMixin, CreateView): class KeywordCreate(GroupRequiredMixin, CreateView):
@ -68,7 +74,9 @@ class StorageCreate(GroupRequiredMixin, CreateView):
obj = form.save(commit=False) obj = form.save(commit=False)
obj.created_by = self.request.user obj.created_by = self.request.user
obj.save() obj.save()
return HttpResponseRedirect(reverse('edit_storage', kwargs={'pk': obj.pk})) return HttpResponseRedirect(
reverse('edit_storage', kwargs={'pk': obj.pk})
)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(StorageCreate, self).get_context_data(**kwargs) context = super(StorageCreate, self).get_context_data(**kwargs)
@ -95,14 +103,25 @@ def create_new_external_recipe(request, import_id):
RecipeImport.objects.get(id=import_id).delete() RecipeImport.objects.get(id=import_id).delete()
messages.add_message(request, messages.SUCCESS, _('Imported new recipe!')) messages.add_message(
request, messages.SUCCESS, _('Imported new recipe!')
)
return redirect('list_recipe_import') return redirect('list_recipe_import')
else: else:
messages.add_message(request, messages.ERROR, _('There was an error importing this recipe!')) messages.add_message(
request,
messages.ERROR,
_('There was an error importing this recipe!')
)
else: else:
new_recipe = RecipeImport.objects.get(id=import_id) new_recipe = RecipeImport.objects.get(id=import_id)
form = ImportRecipeForm( form = ImportRecipeForm(
initial={'file_path': new_recipe.file_path, 'name': new_recipe.name, 'file_uid': new_recipe.file_uid}) initial={
'file_path': new_recipe.file_path,
'name': new_recipe.name,
'file_uid': new_recipe.file_uid
}
)
return render(request, 'forms/edit_import_recipe.html', {'form': form}) return render(request, 'forms/edit_import_recipe.html', {'form': form})
@ -135,14 +154,28 @@ class MealPlanCreate(GroupRequiredMixin, CreateView):
def get_form(self, form_class=None): def get_form(self, form_class=None):
form = self.form_class(**self.get_form_kwargs()) form = self.form_class(**self.get_form_kwargs())
form.fields['meal_type'].queryset = MealType.objects.filter(created_by=self.request.user).all() form.fields['meal_type'].queryset = MealType.objects.filter(
created_by=self.request.user
).all()
return form return form
def get_initial(self): def get_initial(self):
return dict( return dict(
meal_type=self.request.GET['meal'] if 'meal' in self.request.GET else None, meal_type=(
date=datetime.strptime(self.request.GET['date'], '%Y-%m-%d') if 'date' in self.request.GET else None, self.request.GET['meal']
shared=self.request.user.userpreference.plan_share.all() if self.request.user.userpreference.plan_share else None if 'meal' in self.request.GET
else None
),
date=(
datetime.strptime(self.request.GET['date'], '%Y-%m-%d')
if 'date' in self.request.GET
else None
),
shared=(
self.request.user.userpreference.plan_share.all()
if self.request.user.userpreference.plan_share
else None
)
) )
def form_valid(self, form): def form_valid(self, form):
@ -159,7 +192,7 @@ class MealPlanCreate(GroupRequiredMixin, CreateView):
if recipe: if recipe:
if re.match(r'^([0-9])+$', recipe): if re.match(r'^([0-9])+$', recipe):
if Recipe.objects.filter(pk=int(recipe)).exists(): if Recipe.objects.filter(pk=int(recipe)).exists():
context['default_recipe'] = Recipe.objects.get(pk=int(recipe)) context['default_recipe'] = Recipe.objects.get(pk=int(recipe)) # noqa: E501
return context return context

View File

@ -1,35 +1,40 @@
import copy
import os import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import re
from uuid import UUID from uuid import UUID
from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import update_session_auth_hash, authenticate from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.forms import PasswordChangeForm from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth.password_validation import validate_password from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Q, Avg from django.db import IntegrityError
from django.db.models import Avg, Q
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import render, get_object_or_404 from django.shortcuts import get_object_or_404, render
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.views.decorators.clickjacking import xframe_options_exempt
from django_tables2 import RequestConfig
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django_tables2 import RequestConfig
from django.conf import settings
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from cookbook.filters import RecipeFilter from cookbook.filters import RecipeFilter
from cookbook.forms import * from cookbook.forms import (CommentForm, Recipe, RecipeBookEntryForm, User,
UserCreateForm, UserNameForm, UserPreference,
UserPreferenceForm)
from cookbook.helper.permission_helper import group_required, share_link_valid from cookbook.helper.permission_helper import group_required, share_link_valid
from cookbook.tables import RecipeTable, RecipeTableSmall, CookLogTable, ViewLogTable from cookbook.models import (Comment, CookLog, InviteLink, MealPlan,
RecipeBook, RecipeBookEntry, ViewLog)
from recipes.version import * from cookbook.tables import (CookLogTable, RecipeTable, RecipeTableSmall,
ViewLogTable)
from recipes.version import BUILD_REF, VERSION_NUMBER
def index(request): def index(request):
if not request.user.is_authenticated: if not request.user.is_authenticated:
if User.objects.count() < 1 and 'django.contrib.auth.backends.RemoteUserBackend' not in settings.AUTHENTICATION_BACKENDS: if (User.objects.count() < 1
and 'django.contrib.auth.backends.RemoteUserBackend' not in settings.AUTHENTICATION_BACKENDS): # noqa: E501
return HttpResponseRedirect(reverse_lazy('view_setup')) return HttpResponseRedirect(reverse_lazy('view_setup'))
return HttpResponseRedirect(reverse_lazy('view_search')) return HttpResponseRedirect(reverse_lazy('view_search'))
try: try:
@ -39,14 +44,19 @@ def index(request):
UserPreference.BOOKS: reverse_lazy('view_books'), UserPreference.BOOKS: reverse_lazy('view_books'),
} }
return HttpResponseRedirect(page_map.get(request.user.userpreference.default_page)) return HttpResponseRedirect(
page_map.get(request.user.userpreference.default_page)
)
except UserPreference.DoesNotExist: except UserPreference.DoesNotExist:
return HttpResponseRedirect(reverse('login') + '?next=' + request.path) return HttpResponseRedirect(reverse('login') + '?next=' + request.path)
def search(request): def search(request):
if request.user.is_authenticated: if request.user.is_authenticated:
f = RecipeFilter(request.GET, queryset=Recipe.objects.all().order_by('name')) f = RecipeFilter(
request.GET,
queryset=Recipe.objects.all().order_by('name')
)
if request.user.userpreference.search_style == UserPreference.LARGE: if request.user.userpreference.search_style == UserPreference.LARGE:
table = RecipeTable(f.qs) table = RecipeTable(f.qs)
@ -55,7 +65,10 @@ def search(request):
RequestConfig(request, paginate={'per_page': 25}).configure(table) RequestConfig(request, paginate={'per_page': 25}).configure(table)
if request.GET == {} and request.user.userpreference.show_recent: if request.GET == {} and request.user.userpreference.show_recent:
qs = Recipe.objects.filter(viewlog__created_by=request.user).order_by('-viewlog__created_at').all() qs = Recipe.objects \
.filter(viewlog__created_by=request.user) \
.order_by('-viewlog__created_at') \
.all()
recent_list = [] recent_list = []
for r in qs: for r in qs:
@ -68,7 +81,11 @@ def search(request):
else: else:
last_viewed = None last_viewed = None
return render(request, 'index.html', {'recipes': table, 'filter': f, 'last_viewed': last_viewed}) return render(
request,
'index.html',
{'recipes': table, 'filter': f, 'last_viewed': last_viewed}
)
else: else:
return HttpResponseRedirect(reverse('login') + '?next=' + request.path) return HttpResponseRedirect(reverse('login') + '?next=' + request.path)
@ -77,16 +94,28 @@ def recipe_view(request, pk, share=None):
recipe = get_object_or_404(Recipe, pk=pk) recipe = get_object_or_404(Recipe, pk=pk)
if not request.user.is_authenticated and not share_link_valid(recipe, share): if not request.user.is_authenticated and not share_link_valid(recipe, share):
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!')) messages.add_message(
request,
messages.ERROR,
_('You do not have the required permissions to view this page!')
)
return HttpResponseRedirect(reverse('login') + '?next=' + request.path) return HttpResponseRedirect(reverse('login') + '?next=' + request.path)
comments = Comment.objects.filter(recipe=recipe) comments = Comment.objects.filter(recipe=recipe)
if request.method == "POST": if request.method == "POST":
if not request.user.is_authenticated: if not request.user.is_authenticated:
messages.add_message(request, messages.ERROR, messages.add_message(
_('You do not have the required permissions to perform this action!')) request,
return HttpResponseRedirect(reverse('view_recipe', kwargs={'pk': recipe.pk, 'share': share})) messages.ERROR,
_('You do not have the required permissions to perform this action!') # noqa: E501
)
return HttpResponseRedirect(
reverse(
'view_recipe',
kwargs={'pk': recipe.pk, 'share': share}
)
)
comment_form = CommentForm(request.POST, prefix='comment') comment_form = CommentForm(request.POST, prefix='comment')
if comment_form.is_valid(): if comment_form.is_valid():
@ -97,7 +126,9 @@ def recipe_view(request, pk, share=None):
comment.save() comment.save()
messages.add_message(request, messages.SUCCESS, _('Comment saved!')) messages.add_message(
request, messages.SUCCESS, _('Comment saved!')
)
bookmark_form = RecipeBookEntryForm(request.POST, prefix='bookmark') bookmark_form = RecipeBookEntryForm(request.POST, prefix='bookmark')
if bookmark_form.is_valid(): if bookmark_form.is_valid():
@ -105,42 +136,79 @@ def recipe_view(request, pk, share=None):
bookmark.recipe = recipe bookmark.recipe = recipe
bookmark.book = bookmark_form.cleaned_data['book'] bookmark.book = bookmark_form.cleaned_data['book']
bookmark.save() try:
bookmark.save()
messages.add_message(request, messages.SUCCESS, _('Bookmark saved!')) except IntegrityError as e:
if 'UNIQUE constraint' in str(e.args):
messages.add_message(
request,
messages.ERROR,
_('This recipe is already linked to the book!')
)
else:
messages.add_message(
request,
messages.SUCCESS,
_('Bookmark saved!')
)
comment_form = CommentForm() comment_form = CommentForm()
bookmark_form = RecipeBookEntryForm() bookmark_form = RecipeBookEntryForm()
user_servings = None user_servings = None
if request.user.is_authenticated: if request.user.is_authenticated:
user_servings = CookLog.objects.filter(recipe=recipe, created_by=request.user, user_servings = CookLog.objects.filter(
servings__gt=0).all().aggregate(Avg('servings'))['servings__avg'] recipe=recipe,
created_by=request.user,
servings__gt=0
).all().aggregate(Avg('servings'))['servings__avg']
if request.user.is_authenticated: if request.user.is_authenticated:
if not ViewLog.objects.filter(recipe=recipe).filter(created_by=request.user).filter( if not ViewLog.objects \
created_at__gt=(timezone.now() - timezone.timedelta(minutes=5))).exists(): .filter(recipe=recipe) \
.filter(created_by=request.user) \
.filter(created_at__gt=(
timezone.now() - timezone.timedelta(minutes=5))) \
.exists():
ViewLog.objects.create(recipe=recipe, created_by=request.user) ViewLog.objects.create(recipe=recipe, created_by=request.user)
return render(request, 'recipe_view.html', return render(
{'recipe': recipe, 'comments': comments, 'comment_form': comment_form, request,
'bookmark_form': bookmark_form, 'share': share, 'user_servings': user_servings}) 'recipe_view.html',
{
'recipe': recipe,
'comments': comments,
'comment_form': comment_form,
'bookmark_form': bookmark_form,
'share': share,
'user_servings': user_servings
}
)
@group_required('user') @group_required('user')
def books(request): def books(request):
book_list = [] book_list = []
books = RecipeBook.objects.filter(Q(created_by=request.user) | Q(shared=request.user)).distinct().all() books = RecipeBook.objects.filter(
Q(created_by=request.user) | Q(shared=request.user)
).distinct().all()
for b in books: for b in books:
book_list.append({'book': b, 'recipes': RecipeBookEntry.objects.filter(book=b).all()}) book_list.append(
{
'book': b,
'recipes': RecipeBookEntry.objects.filter(book=b).all()
}
)
return render(request, 'books.html', {'book_list': book_list}) return render(request, 'books.html', {'book_list': book_list})
def get_start_end_from_week(p_year, p_week): def get_start_end_from_week(p_year, p_week):
first_day_of_week = datetime.strptime(f'{p_year}-W{int(p_week) - 1}-1', "%Y-W%W-%w").date() first_day_of_week = datetime.strptime(
f'{p_year}-W{int(p_week) - 1}-1', "%Y-W%W-%w"
).date()
last_day_of_week = first_day_of_week + timedelta(days=6.9) last_day_of_week = first_day_of_week + timedelta(days=6.9)
return first_day_of_week, last_day_of_week return first_day_of_week, last_day_of_week
@ -163,13 +231,24 @@ def meal_plan_entry(request, pk):
plan = MealPlan.objects.get(pk=pk) plan = MealPlan.objects.get(pk=pk)
if plan.created_by != request.user and plan.shared != request.user: if plan.created_by != request.user and plan.shared != request.user:
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!')) messages.add_message(
request,
messages.ERROR,
_('You do not have the required permissions to view this page!')
)
return HttpResponseRedirect(reverse_lazy('index')) return HttpResponseRedirect(reverse_lazy('index'))
same_day_plan = MealPlan.objects.filter(date=plan.date).exclude(pk=plan.pk).filter( same_day_plan = MealPlan.objects \
Q(created_by=request.user) | Q(shared=request.user)).order_by('meal_type').all() .filter(date=plan.date) \
.exclude(pk=plan.pk) \
.filter(Q(created_by=request.user) | Q(shared=request.user)) \
.order_by('meal_type').all()
return render(request, 'meal_plan_entry.html', {'plan': plan, 'same_day_plan': same_day_plan}) return render(
request,
'meal_plan_entry.html',
{'plan': plan, 'same_day_plan': same_day_plan}
)
@group_required('user') @group_required('user')
@ -184,7 +263,11 @@ def shopping_list(request, pk=None):
if recipe := Recipe.objects.filter(pk=int(rid)).first(): if recipe := Recipe.objects.filter(pk=int(rid)).first():
recipes.append({'recipe': recipe.id, 'multiplier': multiplier}) recipes.append({'recipe': recipe.id, 'multiplier': multiplier})
return render(request, 'shopping_list.html', {'shopping_list_id': pk, 'recipes': recipes}) return render(
request,
'shopping_list.html',
{'shopping_list_id': pk, 'recipes': recipes}
)
@group_required('guest') @group_required('guest')
@ -209,22 +292,22 @@ def user_settings(request):
up.show_recent = form.cleaned_data['show_recent'] up.show_recent = form.cleaned_data['show_recent']
up.search_style = form.cleaned_data['search_style'] up.search_style = form.cleaned_data['search_style']
up.plan_share.set(form.cleaned_data['plan_share']) up.plan_share.set(form.cleaned_data['plan_share'])
up.ingredient_decimals = form.cleaned_data['ingredient_decimals'] up.ingredient_decimals = form.cleaned_data['ingredient_decimals'] # noqa: E501
up.comments = form.cleaned_data['comments'] up.comments = form.cleaned_data['comments']
up.use_fractions = form.cleaned_data['use_fractions'] up.use_fractions = form.cleaned_data['use_fractions']
up.sticky_navbar = form.cleaned_data['sticky_navbar'] up.sticky_navbar = form.cleaned_data['sticky_navbar']
up.shopping_auto_sync = form.cleaned_data['shopping_auto_sync'] up.shopping_auto_sync = form.cleaned_data['shopping_auto_sync']
if up.shopping_auto_sync < settings.SHOPPING_MIN_AUTOSYNC_INTERVAL: if up.shopping_auto_sync < settings.SHOPPING_MIN_AUTOSYNC_INTERVAL: # noqa: E501
up.shopping_auto_sync = settings.SHOPPING_MIN_AUTOSYNC_INTERVAL up.shopping_auto_sync = settings.SHOPPING_MIN_AUTOSYNC_INTERVAL # noqa: E501
up.save() up.save()
if 'user_name_form' in request.POST: if 'user_name_form' in request.POST:
user_name_form = UserNameForm(request.POST, prefix='name') user_name_form = UserNameForm(request.POST, prefix='name')
if user_name_form.is_valid(): if user_name_form.is_valid():
request.user.first_name = user_name_form.cleaned_data['first_name'] request.user.first_name = user_name_form.cleaned_data['first_name'] # noqa: E501
request.user.last_name = user_name_form.cleaned_data['last_name'] request.user.last_name = user_name_form.cleaned_data['last_name'] # noqa: E501
request.user.save() request.user.save()
if 'password_form' in request.POST: if 'password_form' in request.POST:
@ -241,40 +324,74 @@ def user_settings(request):
if (api_token := Token.objects.filter(user=request.user).first()) is None: if (api_token := Token.objects.filter(user=request.user).first()) is None:
api_token = Token.objects.create(user=request.user) api_token = Token.objects.create(user=request.user)
return render(request, 'settings.html', return render(
{'preference_form': preference_form, 'user_name_form': user_name_form, 'password_form': password_form, request,
'api_token': api_token}) 'settings.html',
{
'preference_form': preference_form,
'user_name_form': user_name_form,
'password_form': password_form,
'api_token': api_token
}
)
@group_required('guest') @group_required('guest')
def history(request): def history(request):
view_log = ViewLogTable(ViewLog.objects.filter(created_by=request.user).order_by('-created_at').all()) view_log = ViewLogTable(
cook_log = CookLogTable(CookLog.objects.filter(created_by=request.user).order_by('-created_at').all()) ViewLog.objects.filter(
return render(request, 'history.html', {'view_log': view_log, 'cook_log': cook_log}) created_by=request.user
).order_by('-created_at').all()
)
cook_log = CookLogTable(
CookLog.objects.filter(
created_by=request.user
).order_by('-created_at').all()
)
return render(
request,
'history.html',
{'view_log': view_log, 'cook_log': cook_log}
)
@group_required('admin') @group_required('admin')
def system(request): def system(request):
postgres = False if (settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql_psycopg2' or postgres = False if (
settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql') else True settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql_psycopg2' # noqa: E501
or settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql' # noqa: E501
) else True
secret_key = False if os.getenv('SECRET_KEY') else True secret_key = False if os.getenv('SECRET_KEY') else True
return render(request, 'system.html', return render(
{'gunicorn_media': settings.GUNICORN_MEDIA, 'debug': settings.DEBUG, 'postgres': postgres, request,
'version': VERSION_NUMBER, 'ref': BUILD_REF, 'secret_key': secret_key}) 'system.html',
{
'gunicorn_media': settings.GUNICORN_MEDIA,
'debug': settings.DEBUG,
'postgres': postgres,
'version': VERSION_NUMBER,
'ref': BUILD_REF,
'secret_key': secret_key
}
)
def setup(request): def setup(request):
if User.objects.count() > 0 or 'django.contrib.auth.backends.RemoteUserBackend' in settings.AUTHENTICATION_BACKENDS: if (User.objects.count() > 0
messages.add_message(request, messages.ERROR, _( or 'django.contrib.auth.backends.RemoteUserBackend' in settings.AUTHENTICATION_BACKENDS): # noqa: E501
'The setup page can only be used to create the first user! If you have forgotten your superuser credentials please consult the django documentation on how to reset passwords.')) messages.add_message(
request,
messages.ERROR,
_('The setup page can only be used to create the first user! If you have forgotten your superuser credentials please consult the django documentation on how to reset passwords.') # noqa: E501
)
return HttpResponseRedirect(reverse('login')) return HttpResponseRedirect(reverse('login'))
if request.method == 'POST': if request.method == 'POST':
form = UserCreateForm(request.POST) form = UserCreateForm(request.POST)
if form.is_valid(): if form.is_valid():
if form.cleaned_data['password'] != form.cleaned_data['password_confirm']: if form.cleaned_data['password'] != form.cleaned_data['password_confirm']: # noqa: E501
form.add_error('password', _('Passwords dont match!')) form.add_error('password', _('Passwords dont match!'))
else: else:
user = User( user = User(
@ -286,7 +403,11 @@ def setup(request):
validate_password(form.cleaned_data['password'], user=user) validate_password(form.cleaned_data['password'], user=user)
user.set_password(form.cleaned_data['password']) user.set_password(form.cleaned_data['password'])
user.save() user.save()
messages.add_message(request, messages.SUCCESS, _('User has been created, please login!')) messages.add_message(
request,
messages.SUCCESS,
_('User has been created, please login!')
)
return HttpResponseRedirect(reverse('login')) return HttpResponseRedirect(reverse('login'))
except ValidationError as e: except ValidationError as e:
for m in e: for m in e:
@ -301,10 +422,14 @@ def signup(request, token):
try: try:
token = UUID(token, version=4) token = UUID(token, version=4)
except ValueError: except ValueError:
messages.add_message(request, messages.ERROR, _('Malformed Invite Link supplied!')) messages.add_message(
request, messages.ERROR, _('Malformed Invite Link supplied!')
)
return HttpResponseRedirect(reverse('index')) return HttpResponseRedirect(reverse('index'))
if link := InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, uuid=token).first(): if link := InviteLink.objects.filter(
valid_until__gte=datetime.today(), used_by=None, uuid=token) \
.first():
if request.method == 'POST': if request.method == 'POST':
form = UserCreateForm(request.POST) form = UserCreateForm(request.POST)
@ -314,17 +439,23 @@ def signup(request, token):
form.data = data form.data = data
if form.is_valid(): if form.is_valid():
if form.cleaned_data['password'] != form.cleaned_data['password_confirm']: if form.cleaned_data['password'] != form.cleaned_data['password_confirm']: # noqa: E501
form.add_error('password', _('Passwords dont match!')) form.add_error('password', _('Passwords dont match!'))
else: else:
user = User( user = User(
username=form.cleaned_data['name'], username=form.cleaned_data['name'],
) )
try: try:
validate_password(form.cleaned_data['password'], user=user) validate_password(
form.cleaned_data['password'], user=user
)
user.set_password(form.cleaned_data['password']) user.set_password(form.cleaned_data['password'])
user.save() user.save()
messages.add_message(request, messages.SUCCESS, _('User has been created, please login!')) messages.add_message(
request,
messages.SUCCESS,
_('User has been created, please login!')
)
link.used_by = user link.used_by = user
link.save() link.save()
@ -339,9 +470,13 @@ def signup(request, token):
if link.username != '': if link.username != '':
form.fields['name'].initial = link.username form.fields['name'].initial = link.username
form.fields['name'].disabled = True form.fields['name'].disabled = True
return render(request, 'registration/signup.html', {'form': form, 'link': link}) return render(
request, 'registration/signup.html', {'form': form, 'link': link}
)
messages.add_message(request, messages.ERROR, _('Invite Link not valid or already used!')) messages.add_message(
request, messages.ERROR, _('Invite Link not valid or already used!')
)
return HttpResponseRedirect(reverse('index')) return HttpResponseRedirect(reverse('index'))
@ -354,6 +489,10 @@ def api_info(request):
return render(request, 'api_info.html', {}) return render(request, 'api_info.html', {})
def offline(request):
return render(request, 'offline.html', {})
def test(request): def test(request):
if not settings.DEBUG: if not settings.DEBUG:
return HttpResponseRedirect(reverse('index')) return HttpResponseRedirect(reverse('index'))

View File

@ -162,7 +162,7 @@ networks:
external: true external: true
volumes: volumes:
nginx: nginx_config:
staticfiles: staticfiles:
``` ```
@ -233,7 +233,7 @@ networks:
name: nginx-proxy name: nginx-proxy
volumes: volumes:
nginx: nginx_config:
staticfiles: staticfiles:
``` ```

View File

@ -1,5 +1,7 @@
from django.contrib.auth.middleware import RemoteUserMiddleware
from os import getenv from os import getenv
from django.contrib.auth.middleware import RemoteUserMiddleware
class CustomRemoteUser(RemoteUserMiddleware): class CustomRemoteUser(RemoteUserMiddleware):
header = getenv('PROXY_HEADER', 'HTTP_REMOTE_USER') header = getenv('PROXY_HEADER', 'HTTP_REMOTE_USER')

View File

@ -14,8 +14,8 @@ import random
import string import string
from django.contrib import messages from django.contrib import messages
from dotenv import load_dotenv
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from dotenv import load_dotenv
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

View File

@ -1,4 +1,5 @@
"""recipes URL Configuration """
recipes URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see: The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/2.0/topics/http/urls/ https://docs.djangoproject.com/en/2.0/topics/http/urls/
@ -15,9 +16,8 @@ Including another URLconf
""" """
from django.conf import settings from django.conf import settings
from django.conf.urls import url from django.conf.urls import url
from django.conf.urls.static import static
from django.urls import include, path
from django.contrib import admin from django.contrib import admin
from django.urls import include, path
from django.views.i18n import JavaScriptCatalog from django.views.i18n import JavaScriptCatalog
from django.views.static import serve from django.views.static import serve
@ -26,8 +26,16 @@ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('accounts/', include('django.contrib.auth.urls')), path('accounts/', include('django.contrib.auth.urls')),
path('i18n/', include('django.conf.urls.i18n')), path('i18n/', include('django.conf.urls.i18n')),
path('jsi18n/', JavaScriptCatalog.as_view(domain='django'), name='javascript-catalog'), path(
'jsi18n/',
JavaScriptCatalog.as_view(domain='django'),
name='javascript-catalog'
),
] ]
if settings.GUNICORN_MEDIA or settings.DEBUG: if settings.GUNICORN_MEDIA or settings.DEBUG:
urlpatterns += url(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}), urlpatterns += url(
r'^media/(?P<path>.*)$',
serve,
{'document_root': settings.MEDIA_ROOT}
),

View File

@ -7,7 +7,7 @@ django-cleanup==5.1.0
django-crispy-forms==1.10.0 django-crispy-forms==1.10.0
django-emoji-picker==0.0.6 django-emoji-picker==0.0.6
django-filter==2.4.0 django-filter==2.4.0
django-tables2==2.3.3 django-tables2==2.3.4
djangorestframework==3.12.2 djangorestframework==3.12.2
drf-writable-nested==0.6.2 drf-writable-nested==0.6.2
gunicorn==20.0.4 gunicorn==20.0.4