Merge branch 'feature/keywords-rework' into feature/fulltext-search

# Conflicts:
#	cookbook/admin.py
#	cookbook/helper/recipe_search.py
#	cookbook/models.py
#	cookbook/static/vue/js/import_response_view.js
#	cookbook/static/vue/js/offline_view.js
#	cookbook/static/vue/js/recipe_search_view.js
#	cookbook/static/vue/js/recipe_view.js
#	cookbook/static/vue/js/supermarket_view.js
#	cookbook/templates/sw.js
#	cookbook/views/api.py
#	cookbook/views/views.py
#	vue/src/locales/en.json
#	vue/webpack-stats.json
#	vue/yarn.lock
This commit is contained in:
vabene1111 2021-06-30 15:06:03 +02:00
commit 5c0bdce37d
183 changed files with 32502 additions and 13884 deletions

View File

@ -78,10 +78,13 @@ GUNICORN_MEDIA=0
# when unset: 0 (false)
REVERSE_PROXY_AUTH=0
# If base URL is something other than just / (you are serving a subfolder in your proxy for instance http://recipe_app/recipes/)
# SCRIPT_NAME=/recipes
# Default settings for spaces, apply per space and can be changed in the admin view
# SPACE_DEFAULT_MAX_RECIPES=0 # 0=unlimited recipes
# SPACE_DEFAULT_MAX_USERS=0 # 0=unlimited users per space
# SPACE_DEFAULT_FILES=1 # 1=can upload files (images, etc.) NOT IMPLEMENTED YET
# SPACE_DEFAULT_MAX_FILES=0 # Maximum file storage for space in MB. 0 for unlimited, -1 to disable file upload.
# SPACE_DEFAULT_ALLOW_SHARING=1 # Allow users to share recipes with public links
# allow people to create accounts on your application instance (without an invite link)
# when unset: 0 (false)
@ -111,4 +114,9 @@ REVERSE_PROXY_AUTH=0
# SOCIAL_DEFAULT_ACCESS = 1
# if SOCIAL_DEFAULT_ACCESS is used, which group should be added
# SOCIAL_DEFAULT_GROUP=guest
# SOCIAL_DEFAULT_GROUP=guest
# Django session cookie settings. Can be changed to allow a single django application to authenticate several applications
# when running under the same database
# SESSION_COOKIE_DOMAIN=.example.com
# SESSION_COOKIE_NAME=sessionid # use this only to not interfere with non unified django applications under the same top level domain

2
.gitignore vendored
View File

@ -79,3 +79,5 @@ postgresql/
/docker-compose.override.yml
vue/node_modules
.vscode/
vue/yarn.lock
vetur.config.js

View File

@ -9,18 +9,17 @@
<h4 align="center">The recipe manager that allows you to manage your ever growing collection of digital recipes.</h4>
<p align="center">
<img src="https://github.com/vabene1111/recipes/workflows/Continous%20Integration/badge.svg?branch=develop" >
<img src="https://img.shields.io/github/stars/vabene1111/recipes" >
<img src="https://img.shields.io/github/forks/vabene1111/recipes" >
<img src="https://img.shields.io/docker/pulls/vabene1111/recipes" >
<a href="https://github.com/vabene1111/recipes/actions" target="_blank" rel="noopener noreferrer"><img src="https://github.com/vabene1111/recipes/workflows/Continous%20Integration/badge.svg?branch=master" ></a>
<a href="https://github.com/vabene1111/recipes/stargazers" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/github/stars/vabene1111/recipes" ></a>
<a href="https://github.com/vabene1111/recipes/network/members" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/github/forks/vabene1111/recipes" ></a>
<a href="https://hub.docker.com/r/vabene1111/recipes" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/docker/pulls/vabene1111/recipes" ></a>
<a href="https://github.com/vabene1111/recipes/releases/latest" rel="noopener noreferrer"><img src="https://img.shields.io/github/v/release/vabene1111/recipes" ></a>
</p>
<p align="center">
<a href="https://docs.tandoor.dev/install/docker/" target="_blank" rel="noopener noreferrer">Installation</a>
<a href="https://docs.tandoor.dev/install/docker.html" target="_blank" rel="noopener noreferrer">Installation</a>
<a href="https://docs.tandoor.dev/" target="_blank" rel="noopener noreferrer">Documentation</a>
<a href="https://app.tandoor.dev/" target="_blank" rel="noopener noreferrer">Demo</a>
<a href="https://app.tandoor.dev/accounts/login/?demo" target="_blank" rel="noopener noreferrer">Demo</a>
</p>
![Preview](docs/preview.png)

View File

@ -1,6 +1,8 @@
from django.conf import settings
from django.contrib import admin
from django.contrib.postgres.search import SearchVector
from treebeard.admin import TreeAdmin
from treebeard.forms import movenodeform_factory
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import User, Group
from django_scopes import scopes_disabled
@ -12,7 +14,9 @@ from .models import (Comment, CookLog, Food, Ingredient, InviteLink, Keyword,
ShoppingList, ShoppingListEntry, ShoppingListRecipe,
Space, Step, Storage, Sync, SyncLog, Unit, UserPreference,
ViewLog, Supermarket, SupermarketCategory, SupermarketCategoryRelation,
ImportLog, TelegramBot, BookmarkletImport)
ImportLog, TelegramBot, BookmarkletImport, UserFile)
from cookbook.managers import DICTIONARY
from cookbook.managers import DICTIONARY
@ -29,14 +33,20 @@ admin.site.unregister(Group)
class SpaceAdmin(admin.ModelAdmin):
list_display = ('name', 'created_by', 'message')
list_display = ('name', 'created_by', 'max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing')
search_fields = ('name', 'created_by__username')
list_filter = ('max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing')
date_hierarchy = 'created_at'
admin.site.register(Space, SpaceAdmin)
class UserPreferenceAdmin(admin.ModelAdmin):
list_display = ('name', 'space', 'theme', 'nav_color', 'default_page', 'search_style',)
list_display = ('name', 'space', 'theme', 'nav_color', 'default_page', 'search_style',) # TODO add new fields
search_fields = ('user__username', 'space__name')
list_filter = ('theme', 'nav_color', 'default_page', 'search_style')
date_hierarchy = 'created_at'
@staticmethod
def name(obj):
@ -48,6 +58,7 @@ admin.site.register(UserPreference, UserPreferenceAdmin)
class StorageAdmin(admin.ModelAdmin):
list_display = ('name', 'method')
search_fields = ('name',)
admin.site.register(Storage, StorageAdmin)
@ -55,6 +66,7 @@ admin.site.register(Storage, StorageAdmin)
class SyncAdmin(admin.ModelAdmin):
list_display = ('storage', 'path', 'active', 'last_checked')
search_fields = ('storage__name', 'path')
admin.site.register(Sync, SyncAdmin)
@ -78,11 +90,29 @@ class SyncLogAdmin(admin.ModelAdmin):
admin.site.register(SyncLog, SyncLogAdmin)
admin.site.register(Keyword)
class KeywordAdmin(TreeAdmin):
form = movenodeform_factory(Keyword)
ordering = ('space', 'path',)
# removing ability to delete keywords from admin
# to avoid creating orphaned keywords
# def get_actions(self, request):
# actions = super().get_actions(request)
# if 'delete_selected' in actions:
# del actions['delete_selected']
# return actions
# def has_delete_permission(self, request, obj=None):
# return False
admin.site.register(Keyword, KeywordAdmin)
class StepAdmin(admin.ModelAdmin):
list_display = ('name', 'type', 'order')
search_fields = ('name', 'type')
admin.site.register(Step, StepAdmin)
@ -101,6 +131,9 @@ def rebuild_index(modeladmin, request, queryset):
class RecipeAdmin(admin.ModelAdmin):
list_display = ('name', 'internal', 'created_by', 'storage')
search_fields = ('name', 'created_by__username')
list_filter = ('internal',)
date_hierarchy = 'created_at'
@staticmethod
def created_by(obj):
@ -118,6 +151,7 @@ admin.site.register(Food)
class IngredientAdmin(admin.ModelAdmin):
list_display = ('food', 'amount', 'unit')
search_fields = ('food__name', 'unit__name')
admin.site.register(Ingredient, IngredientAdmin)
@ -125,6 +159,8 @@ admin.site.register(Ingredient, IngredientAdmin)
class CommentAdmin(admin.ModelAdmin):
list_display = ('recipe', 'name', 'created_at')
search_fields = ('text', 'user__username')
date_hierarchy = 'created_at'
@staticmethod
def name(obj):
@ -143,6 +179,7 @@ admin.site.register(RecipeImport, RecipeImportAdmin)
class RecipeBookAdmin(admin.ModelAdmin):
list_display = ('name', 'user_name')
search_fields = ('name', 'created_by__username')
@staticmethod
def user_name(obj):
@ -172,6 +209,7 @@ admin.site.register(MealPlan, MealPlanAdmin)
class MealTypeAdmin(admin.ModelAdmin):
list_display = ('name', 'created_by', 'order')
search_fields = ('name', 'created_by__username')
admin.site.register(MealType, MealTypeAdmin)
@ -186,7 +224,7 @@ admin.site.register(ViewLog, ViewLogAdmin)
class InviteLinkAdmin(admin.ModelAdmin):
list_display = (
'username', 'group', 'valid_until',
'group', 'valid_until',
'created_by', 'created_at', 'used_by'
)
@ -255,3 +293,10 @@ class BookmarkletImportAdmin(admin.ModelAdmin):
admin.site.register(BookmarkletImport, BookmarkletImportAdmin)
class UserFileAdmin(admin.ModelAdmin):
list_display = ('id', 'name', 'file_size_kb', 'created_at',)
admin.site.register(UserFile, UserFileAdmin)

View File

@ -6,6 +6,7 @@ from django.utils.translation import gettext_lazy as _
from django_scopes import scopes_disabled
from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField
from emoji_picker.widgets import EmojiPickerTextInput
from treebeard.forms import MoveNodeForm
from hcaptcha.fields import hCaptchaField
from .models import (Comment, Food, InviteLink, Keyword, MealPlan, Recipe,
@ -71,21 +72,6 @@ class UserPreferenceForm(forms.ModelForm):
}
class AllAuthSignupForm(forms.Form):
captcha = hCaptchaField()
terms = forms.BooleanField(label=_('Accept Terms and Privacy'))
def __init__(self, **kwargs):
super(AllAuthSignupForm, self).__init__(**kwargs)
if settings.PRIVACY_URL == '' and settings.TERMS_URL == '':
self.fields.pop('terms')
if settings.HCAPTCHA_SECRET == '':
self.fields.pop('captcha')
def signup(self, request, user):
pass
class UserNameForm(forms.ModelForm):
prefix = 'name'
@ -137,17 +123,20 @@ class ImportExportBase(forms.Form):
SAFRON = 'SAFRON'
CHEFTAP = 'CHEFTAP'
PEPPERPLATE = 'PEPPERPLATE'
RECIPEKEEPER = 'RECIPEKEEPER'
RECETTETEK = 'RECETTETEK'
RECIPESAGE = 'RECIPESAGE'
DOMESTICA = 'DOMESTICA'
MEALMASTER = 'MEALMASTER'
REZKONV = 'REZKONV'
OPENEATS = 'OPENEATS'
type = forms.ChoiceField(choices=(
(DEFAULT, _('Default')), (PAPRIKA, 'Paprika'), (NEXTCLOUD, 'Nextcloud Cookbook'),
(MEALIE, 'Mealie'), (CHOWDOWN, 'Chowdown'), (SAFRON, 'Safron'), (CHEFTAP, 'ChefTap'),
(PEPPERPLATE, 'Pepperplate'), (RECETTETEK, 'RecetteTek'), (RECIPESAGE, 'Recipe Sage'), (DOMESTICA, 'Domestica'),
(MEALMASTER, 'MealMaster'), (REZKONV, 'RezKonv'),
(MEALMASTER, 'MealMaster'), (REZKONV, 'RezKonv'), (OPENEATS, 'Openeats'), (RECIPEKEEPER, 'Recipe Keeper'),
))
@ -229,10 +218,11 @@ class CommentForm(forms.ModelForm):
}
class KeywordForm(forms.ModelForm):
class KeywordForm(MoveNodeForm):
class Meta:
model = Keyword
fields = ('name', 'icon', 'description')
exclude = ('sib_order', 'parent', 'path', 'depth', 'numchild')
widgets = {'icon': EmojiPickerTextInput}
@ -429,19 +419,11 @@ class InviteLinkForm(forms.ModelForm):
return email
def clean_username(self):
username = self.cleaned_data['username']
with scopes_disabled():
if username != '' and (User.objects.filter(username=username).exists() or InviteLink.objects.filter(username=username).exists()):
raise ValidationError(_('Username already taken!'))
return username
class Meta:
model = InviteLink
fields = ('username', 'email', 'group', 'valid_until', 'space')
fields = ('email', 'group', 'valid_until', 'space')
help_texts = {
'username': _('A username is not required, if left blank the new user can choose one.'),
'email': _('An email address is not required but if present the invite link will be send to the user.')
'email': _('An email address is not required but if present the invite link will be send to the user.'),
}
field_classes = {
'space': SafeModelChoiceField,
@ -465,6 +447,21 @@ class SpaceJoinForm(forms.Form):
token = forms.CharField()
class AllAuthSignupForm(forms.Form):
captcha = hCaptchaField()
terms = forms.BooleanField(label=_('Accept Terms and Privacy'))
def __init__(self, **kwargs):
super(AllAuthSignupForm, self).__init__(**kwargs)
if settings.PRIVACY_URL == '' and settings.TERMS_URL == '':
self.fields.pop('terms')
if settings.HCAPTCHA_SECRET == '':
self.fields.pop('captcha')
def signup(self, request, user):
pass
class UserCreateForm(forms.Form):
name = forms.CharField(label='Username')
password = forms.CharField(

View File

@ -7,6 +7,8 @@ from django.contrib import messages
from django.core.cache import caches
from gettext import gettext as _
from cookbook.models import InviteLink
class AllAuthCustomAdapter(DefaultAccountAdapter):
@ -14,7 +16,11 @@ class AllAuthCustomAdapter(DefaultAccountAdapter):
"""
Whether to allow sign ups.
"""
if request.resolver_match.view_name == 'account_signup' and not settings.ENABLE_SIGNUP:
signup_token = False
if 'signup_token' in request.session and InviteLink.objects.filter(valid_until__gte=datetime.datetime.today(), used_by=None, uuid=request.session['signup_token']).exists():
signup_token = True
if (request.resolver_match.view_name == 'account_signup' or request.resolver_match.view_name == 'socialaccount_signup') and not settings.ENABLE_SIGNUP and not signup_token:
return False
else:
return super(AllAuthCustomAdapter, self).is_open_for_signup(request)

View File

@ -6,6 +6,7 @@ def context_settings(request):
'EMAIL_ENABLED': settings.EMAIL_HOST != '',
'SIGNUP_ENABLED': settings.ENABLE_SIGNUP,
'CAPTCHA_ENABLED': settings.HCAPTCHA_SITEKEY != '',
'HOSTED': settings.HOSTED,
'TERMS_URL': settings.TERMS_URL,
'PRIVACY_URL': settings.PRIVACY_URL,
'IMPRINT_URL': settings.IMPRINT_URL,

View File

@ -0,0 +1,45 @@
import os
import sys
from PIL import Image
from io import BytesIO
def rescale_image_jpeg(image_object, base_width=720):
img = Image.open(image_object)
icc_profile = img.info.get('icc_profile') # remember color profile to not mess up colors
width_percent = (base_width / float(img.size[0]))
height = int((float(img.size[1]) * float(width_percent)))
img = img.resize((base_width, height), Image.ANTIALIAS)
img_bytes = BytesIO()
img.save(img_bytes, 'JPEG', quality=75, optimize=True, icc_profile=icc_profile)
return img_bytes
def rescale_image_png(image_object, base_width=720):
basewidth = 720
wpercent = (basewidth / float(image_object.size[0]))
hsize = int((float(image_object.size[1]) * float(wpercent)))
img = image_object.resize((basewidth, hsize), Image.ANTIALIAS)
im_io = BytesIO()
img.save(im_io, 'PNG', quality=70)
return img
def get_filetype(name):
try:
return os.path.splitext(name)[1]
except:
return '.jpeg'
def handle_image(request, image_object, filetype='.jpeg'):
if sys.getsizeof(image_object) / 8 > 500:
if filetype == '.jpeg':
return rescale_image_jpeg(image_object), filetype
if filetype == '.png':
return rescale_image_png(image_object), filetype
return image_object, filetype

View File

@ -1,3 +1,4 @@
import re
import string
import unicodedata
@ -22,20 +23,16 @@ def parse_fraction(x):
def parse_amount(x):
amount = 0
unit = ''
note = ''
did_check_frac = False
end = 0
while (
end < len(x)
and (
x[end] in string.digits
or (
(x[end] == '.' 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] == ',' or x[end] == '/')
and end + 1 < len(x)
and x[end + 1] in string.digits
))):
end += 1
if end > 0:
if "/" in x[:end]:
@ -55,7 +52,11 @@ def parse_amount(x):
unit = x[end + 1:]
except ValueError:
unit = x[end:]
return amount, unit
if unit.startswith('(') or unit.startswith('-'): # i dont know any unit that starts with ( or - so its likely an alternative like 1L (500ml) Water or 2-3
unit = ''
note = x
return amount, unit, note
def parse_ingredient_with_comma(tokens):
@ -106,6 +107,13 @@ def parse(x):
unit = ''
ingredient = ''
note = ''
unit_note = ''
# if the string contains parenthesis early on remove it and place it at the end
# because its likely some kind of note
if re.match('(.){1,6}\s\((.[^\(\)])+\)\s', x):
match = re.search('\((.[^\(])+\)', x)
x = x[:match.start()] + x[match.end():] + ' ' + x[match.start():match.end()]
tokens = x.split()
if len(tokens) == 1:
@ -114,17 +122,17 @@ def parse(x):
else:
try:
# try to parse first argument as amount
amount, unit = parse_amount(tokens[0])
amount, unit, unit_note = parse_amount(tokens[0])
# only try to parse second argument as amount if there are at least
# three arguments if it already has a unit there can't be
# a fraction for the amount
if len(tokens) > 2:
try:
if not unit == '':
# a unit is already found, no need to try the second argument for a fraction # noqa: E501
# a unit is already found, no need to try the second argument for a fraction
# 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
# try to parse second argument as amount and add that, in case of '2 1/2' or '2 ½' # noqa: E501
# try to parse second argument as amount and add that, in case of '2 1/2' or '2 ½'
amount += parse_fraction(tokens[1])
# assume that units can't end with a comma
if len(tokens) > 3 and not tokens[2].endswith(','):
@ -142,7 +150,10 @@ def parse(x):
# try to use second argument as unit and everything else as ingredient, use everything as ingredient if it fails # noqa: E501
try:
ingredient, note = parse_ingredient(tokens[2:])
unit = tokens[1]
if unit == '':
unit = tokens[1]
else:
note = tokens[1]
except ValueError:
ingredient, note = parse_ingredient(tokens[1:])
else:
@ -158,11 +169,16 @@ def parse(x):
ingredient, note = parse_ingredient(tokens)
except ValueError:
ingredient = ' '.join(tokens[1:])
if unit_note not in note:
note += ' ' + unit_note
return amount, unit.strip(), ingredient.strip(), note.strip()
# small utility functions to prevent emtpy unit/food creation
def get_unit(unit, space):
if not unit:
return None
if len(unit) > 0:
u, created = Unit.objects.get_or_create(name=unit, space=space)
return u
@ -170,6 +186,8 @@ def get_unit(unit, space):
def get_food(food, space):
if not food:
return None
if len(food) > 0:
f, created = Food.objects.get_or_create(name=food, space=space)
return f

View File

@ -1,6 +1,8 @@
"""
Source: https://djangosnippets.org/snippets/1703/
"""
from django.conf import settings
from django.core.cache import caches
from django.views.generic.detail import SingleObjectTemplateResponseMixin
from django.views.generic.edit import ModelFormMixin
@ -90,7 +92,18 @@ def share_link_valid(recipe, share):
:return: true if a share link with the given recipe and uuid exists
"""
try:
return True if ShareLink.objects.filter(recipe=recipe, uuid=share).exists() else False
CACHE_KEY = f'recipe_share_{recipe.pk}_{share}'
if c := caches['default'].get(CACHE_KEY, False):
return c
if link := ShareLink.objects.filter(recipe=recipe, uuid=share, abuse_blocked=False).first():
if 0 < settings.SHARING_LIMIT < link.request_count:
return False
link.request_count += 1
link.save()
caches['default'].set(CACHE_KEY, True, timeout=3)
return True
return False
except ValidationError:
return False
@ -121,15 +134,18 @@ class GroupRequiredMixin(object):
def dispatch(self, request, *args, **kwargs):
if not has_group_permission(request.user, self.groups_required):
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(request, messages.ERROR,
_('You are not logged in and therefore cannot view this page!'))
return HttpResponseRedirect(reverse_lazy('account_login') + '?next=' + request.path)
else:
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'))
try:
obj = self.get_object()
if obj.get_space() != request.space:
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'))
except AttributeError:
pass
@ -141,17 +157,20 @@ class OwnerRequiredMixin(object):
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
messages.add_message(request, messages.ERROR, _('You are not logged in and therefore cannot view this page!'))
messages.add_message(request, messages.ERROR,
_('You are not logged in and therefore cannot view this page!'))
return HttpResponseRedirect(reverse_lazy('account_login') + '?next=' + request.path)
else:
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!'))
return HttpResponseRedirect(reverse('index'))
try:
obj = self.get_object()
if obj.get_space() != request.space:
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'))
except AttributeError:
pass

View File

@ -148,3 +148,7 @@ def search_recipes(request, queryset, params):
queryset = queryset.order_by('-rank')
return queryset
# this returns a list of keywords in the queryset and how many times it appears
# Keyword.objects.filter(recipe__in=queryset).annotate(kw_count=Count('recipe'))

View File

@ -100,22 +100,21 @@ def get_from_scraper(scrape, space):
for x in scrape.ingredients():
try:
amount, unit, ingredient, note = parse_single_ingredient(x)
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(
{
'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(
{
@ -358,3 +357,11 @@ def normalize_string(string):
unescaped_string = re.sub(r'\n\s*\n', '\n\n', unescaped_string)
unescaped_string = unescaped_string.replace("\xa0", " ").replace("\t", " ").strip()
return unescaped_string
def iso_duration_to_minutes(string):
match = re.match(
r'P((?P<years>\d+)Y)?((?P<months>\d+)M)?((?P<weeks>\d+)W)?((?P<days>\d+)D)?T((?P<hours>\d+)H)?((?P<minutes>\d+)M)?((?P<seconds>\d+)S)?',
string
).groupdict()
return int(match['days'] or 0) * 24 * 60 + int(match['hours'] or 0) * 60 + int(match['minutes'] or 0)

View File

@ -16,7 +16,10 @@ class ScopeMiddleware:
with scopes_disabled():
return self.get_response(request)
if request.path.startswith('/signup/'):
if request.path.startswith('/signup/') or request.path.startswith('/invite/'):
return self.get_response(request)
if request.path.startswith('/accounts/'):
return self.get_response(request)
with scopes_disabled():

View File

@ -40,7 +40,7 @@ class Pepperplate(Integration):
recipe = Recipe.objects.create(name=title, description=description, created_by=self.request.user, internal=True, space=self.request.space)
step = Step.objects.create(
instruction='\n'.join(directions) + '\n\n'
instruction='\n'.join(directions) + '\n\n', space=self.request.space,
)
for ingredient in ingredients:
@ -49,7 +49,7 @@ class Pepperplate(Integration):
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))
recipe.steps.add(step)

View File

@ -38,7 +38,7 @@ class ChefTap(Integration):
recipe = Recipe.objects.create(name=title, created_by=self.request.user, internal=True, space=self.request.space, )
step = Step.objects.create(instruction='\n'.join(directions))
step = Step.objects.create(instruction='\n'.join(directions), space=self.request.space,)
if source_url != '':
step.instruction += '\n' + source_url
@ -50,7 +50,7 @@ class ChefTap(Integration):
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))
recipe.steps.add(step)

View File

@ -3,6 +3,7 @@ import re
from io import BytesIO
from zipfile import ZipFile
from cookbook.helper.image_processing import get_filetype
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
from cookbook.integration.integration import Integration
from cookbook.models import Recipe, Step, Food, Unit, Ingredient, Keyword
@ -54,7 +55,7 @@ class Chowdown(Integration):
recipe.keywords.add(keyword)
step = Step.objects.create(
instruction='\n'.join(directions) + '\n\n' + '\n'.join(descriptions)
instruction='\n'.join(directions) + '\n\n' + '\n'.join(descriptions), space=self.request.space,
)
for ingredient in ingredients:
@ -62,7 +63,7 @@ class Chowdown(Integration):
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))
recipe.steps.add(step)
@ -71,7 +72,7 @@ class Chowdown(Integration):
import_zip = ZipFile(f['file'])
for z in import_zip.filelist:
if re.match(f'^images/{image}$', z.filename):
self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)))
self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)), filetype=get_filetype(z.filename))
return recipe

View File

@ -1,9 +1,11 @@
import json
from io import BytesIO
from re import match
from zipfile import ZipFile
from rest_framework.renderers import JSONRenderer
from cookbook.helper.image_processing import get_filetype
from cookbook.integration.integration import Integration
from cookbook.serializer import RecipeExportSerializer
@ -15,8 +17,9 @@ class Default(Integration):
recipe_string = recipe_zip.read('recipe.json').decode("utf-8")
recipe = self.decode_recipe(recipe_string)
if 'image.png' in recipe_zip.namelist():
self.import_recipe_image(recipe, BytesIO(recipe_zip.read('image.png')))
images = list(filter(lambda v: match('image.*', v), recipe_zip.namelist()))
if images:
self.import_recipe_image(recipe, BytesIO(recipe_zip.read(images[0])), filetype=get_filetype(images[0]))
return recipe
def decode_recipe(self, string):

View File

@ -28,7 +28,7 @@ class Domestica(Integration):
recipe.save()
step = Step.objects.create(
instruction=file['directions']
instruction=file['directions'], space=self.request.space,
)
if file['source'] != '':
@ -40,12 +40,12 @@ class Domestica(Integration):
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))
recipe.steps.add(step)
if file['image'] != '':
self.import_recipe_image(recipe, BytesIO(base64.b64decode(file['image'].replace('data:image/jpeg;base64,', ''))))
self.import_recipe_image(recipe, BytesIO(base64.b64decode(file['image'].replace('data:image/jpeg;base64,', ''))), filetype='.jpeg')
return recipe

View File

@ -1,5 +1,7 @@
import datetime
import json
import os
import re
import uuid
from io import BytesIO, StringIO
from zipfile import ZipFile, BadZipFile
@ -11,6 +13,7 @@ from django.utils.translation import gettext as _
from django_scopes import scope
from cookbook.forms import ImportExportBase
from cookbook.helper.image_processing import get_filetype
from cookbook.models import Keyword, Recipe
@ -58,7 +61,7 @@ class Integration:
recipe_zip_obj.writestr(filename, recipe_stream.getvalue())
recipe_stream.close()
try:
recipe_zip_obj.writestr('image.png', r.image.file.read())
recipe_zip_obj.writestr(f'image{get_filetype(r.image.file.name)}', r.image.file.read())
except ValueError:
pass
@ -104,26 +107,54 @@ class Integration:
try:
self.files = files
for f in files:
if '.zip' in f['name'] or '.paprikarecipes' in f['name']:
if 'RecipeKeeper' in f['name']:
import_zip = ZipFile(f['file'])
file_list = []
for z in import_zip.filelist:
if self.import_file_name_filter(z):
try:
recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename)))
recipe.keywords.add(self.keyword)
il.msg += f'{recipe.pk} - {recipe.name} \n'
self.handle_duplicates(recipe, import_duplicates)
except Exception as e:
il.msg += f'-------------------- \n ERROR \n{e}\n--------------------\n'
file_list.append(z)
il.total_recipes += len(file_list)
for z in file_list:
data_list = self.split_recipe_file(import_zip.read(z.filename).decode('utf-8'))
for d in data_list:
recipe = self.get_recipe_from_file(d)
recipe.keywords.add(self.keyword)
il.msg += f'{recipe.pk} - {recipe.name} \n'
self.handle_duplicates(recipe, import_duplicates)
il.imported_recipes += 1
il.save()
import_zip.close()
elif '.zip' in f['name'] or '.paprikarecipes' in f['name']:
import_zip = ZipFile(f['file'])
file_list = []
for z in import_zip.filelist:
if self.import_file_name_filter(z):
file_list.append(z)
il.total_recipes += len(file_list)
for z in file_list:
try:
recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename)))
recipe.keywords.add(self.keyword)
il.msg += f'{recipe.pk} - {recipe.name} \n'
self.handle_duplicates(recipe, import_duplicates)
il.imported_recipes += 1
il.save()
except Exception as e:
il.msg += f'-------------------- \n ERROR \n{e}\n--------------------\n'
import_zip.close()
elif '.json' in f['name'] or '.txt' in f['name']:
data_list = self.split_recipe_file(f['file'])
il.total_recipes += len(data_list)
for d in data_list:
try:
recipe = self.get_recipe_from_file(d)
recipe.keywords.add(self.keyword)
il.msg += f'{recipe.pk} - {recipe.name} \n'
self.handle_duplicates(recipe, import_duplicates)
il.imported_recipes += 1
il.save()
except Exception as e:
il.msg += f'-------------------- \n ERROR \n{e}\n--------------------\n'
elif '.rtk' in f['name']:
@ -131,12 +162,16 @@ class Integration:
for z in import_zip.filelist:
if self.import_file_name_filter(z):
data_list = self.split_recipe_file(import_zip.read(z.filename).decode('utf-8'))
il.total_recipes += len(data_list)
for d in data_list:
try:
recipe = self.get_recipe_from_file(d)
recipe.keywords.add(self.keyword)
il.msg += f'{recipe.pk} - {recipe.name} \n'
self.handle_duplicates(recipe, import_duplicates)
il.imported_recipes += 1
il.save()
except Exception as e:
il.msg += f'-------------------- \n ERROR \n{e}\n--------------------\n'
import_zip.close()
@ -148,6 +183,9 @@ class Integration:
except BadZipFile:
il.msg += 'ERROR ' + _(
'Importer expected a .zip file. Did you choose the correct importer type for your data ?') + '\n'
except:
il.msg += 'ERROR ' + _(
'An unexpected error occurred during the import. Please make sure you have uploaded a valid file.') + '\n'
if len(self.ignored_recipes) > 0:
il.msg += '\n' + _(
@ -170,13 +208,14 @@ class Integration:
self.ignored_recipes.append(recipe.name)
@staticmethod
def import_recipe_image(recipe, image_file):
def import_recipe_image(recipe, image_file, filetype='.jpeg'):
"""
Adds an image to a recipe naming it correctly
:param recipe: Recipe object
:param image_file: ByteIO stream containing the image
:param filetype: type of file to write bytes to, default to .jpeg if unknown
"""
recipe.image = File(image_file, name=f'{uuid.uuid4()}_{recipe.pk}.png')
recipe.image = File(image_file, name=f'{uuid.uuid4()}_{recipe.pk}{filetype}')
recipe.save()
def get_recipe_from_file(self, file):

View File

@ -3,6 +3,7 @@ import re
from io import BytesIO
from zipfile import ZipFile
from cookbook.helper.image_processing import get_filetype
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
from cookbook.integration.integration import Integration
from cookbook.models import Recipe, Step, Food, Unit, Ingredient
@ -11,40 +12,55 @@ from cookbook.models import Recipe, Step, Food, Unit, Ingredient
class Mealie(Integration):
def import_file_name_filter(self, zip_info_object):
return re.match(r'^recipes/([A-Za-z\d-])+.json$', zip_info_object.filename)
return re.match(r'^recipes/([A-Za-z\d-])+/([A-Za-z\d-])+.json$', zip_info_object.filename)
def get_recipe_from_file(self, file):
recipe_json = json.loads(file.getvalue().decode("utf-8"))
description = '' if len(recipe_json['description'].strip()) > 500 else recipe_json['description'].strip()
recipe = Recipe.objects.create(
name=recipe_json['name'].strip(), description=recipe_json['description'].strip(),
name=recipe_json['name'].strip(), description=description,
created_by=self.request.user, internal=True, space=self.request.space)
# TODO parse times (given in PT2H3M )
ingredients_added = False
for s in recipe_json['recipeInstructions']:
for s in recipe_json['recipe_instructions']:
step = Step.objects.create(
instruction=s['text']
instruction=s['text'], space=self.request.space,
)
if not ingredients_added:
ingredients_added = True
for ingredient in recipe_json['recipeIngredient']:
amount, unit, ingredient, note = parse(ingredient)
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note
))
if len(recipe_json['description'].strip()) > 500:
step.instruction = recipe_json['description'].strip() + '\n\n' + step.instruction
for ingredient in recipe_json['recipe_ingredient']:
try:
if ingredient['food']:
f = get_food(ingredient['food'], self.request.space)
u = get_unit(ingredient['unit'], self.request.space)
amount = ingredient['quantity']
note = ingredient['note']
else:
amount, unit, ingredient, note = parse(ingredient['note'])
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))
except:
pass
recipe.steps.add(step)
for f in self.files:
if '.zip' in f['name']:
import_zip = ZipFile(f['file'])
for z in import_zip.filelist:
if re.match(f'^images/{recipe_json["slug"]}.jpg$', z.filename):
self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)))
try:
self.import_recipe_image(recipe, BytesIO(import_zip.read(f'recipes/{recipe_json["slug"]}/images/min-original.webp')), filetype=get_filetype(f'recipes/{recipe_json["slug"]}/images/original'))
except:
pass
return recipe

View File

@ -44,7 +44,7 @@ class MealMaster(Integration):
recipe.keywords.add(keyword)
step = Step.objects.create(
instruction='\n'.join(directions) + '\n\n'
instruction='\n'.join(directions) + '\n\n', space=self.request.space,
)
for ingredient in ingredients:
@ -53,7 +53,7 @@ class MealMaster(Integration):
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))
recipe.steps.add(step)

View File

@ -3,6 +3,7 @@ import re
from io import BytesIO
from zipfile import ZipFile
from cookbook.helper.image_processing import get_filetype
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
from cookbook.integration.integration import Integration
from cookbook.models import Recipe, Step, Food, Unit, Ingredient
@ -16,8 +17,10 @@ class NextcloudCookbook(Integration):
def get_recipe_from_file(self, file):
recipe_json = json.loads(file.getvalue().decode("utf-8"))
description = '' if len(recipe_json['description'].strip()) > 500 else recipe_json['description'].strip()
recipe = Recipe.objects.create(
name=recipe_json['name'].strip(), description=recipe_json['description'].strip(),
name=recipe_json['name'].strip(), description=description,
created_by=self.request.user, internal=True,
servings=recipe_json['recipeYield'], space=self.request.space)
@ -27,9 +30,12 @@ class NextcloudCookbook(Integration):
ingredients_added = False
for s in recipe_json['recipeInstructions']:
step = Step.objects.create(
instruction=s
instruction=s, space=self.request.space,
)
if not ingredients_added:
if len(recipe_json['description'].strip()) > 500:
step.instruction = recipe_json['description'].strip() + '\n\n' + step.instruction
ingredients_added = True
for ingredient in recipe_json['recipeIngredient']:
@ -37,7 +43,7 @@ class NextcloudCookbook(Integration):
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))
recipe.steps.add(step)
@ -46,7 +52,7 @@ class NextcloudCookbook(Integration):
import_zip = ZipFile(f['file'])
for z in import_zip.filelist:
if re.match(f'^Recipes/{recipe.name}/full.jpg$', z.filename):
self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)))
self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)), filetype=get_filetype(z.filename))
return recipe

View File

@ -0,0 +1,71 @@
import json
import re
from django.utils.translation import gettext as _
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
from cookbook.integration.integration import Integration
from cookbook.models import Recipe, Step, Food, Unit, Ingredient
class OpenEats(Integration):
def get_recipe_from_file(self, file):
recipe = Recipe.objects.create(name=file['name'].strip(), created_by=self.request.user, internal=True,
servings=file['servings'], space=self.request.space, waiting_time=file['cook_time'], working_time=file['prep_time'])
instructions = ''
if file["info"] != '':
instructions += file["info"]
if file["directions"] != '':
instructions += file["directions"]
if file["source"] != '':
instructions += file["source"]
step = Step.objects.create(instruction=instructions, space=self.request.space,)
for ingredient in file['ingredients']:
f = get_food(ingredient['food'], self.request.space)
u = get_unit(ingredient['unit'], self.request.space)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=ingredient['amount'], space=self.request.space,
))
recipe.steps.add(step)
return recipe
def split_recipe_file(self, file):
recipe_json = json.loads(file.read())
recipe_dict = {}
ingredient_group_dict = {}
for o in recipe_json:
if o['model'] == 'recipe.recipe':
recipe_dict[o['pk']] = {
'name': o['fields']['title'],
'info': o['fields']['info'],
'directions': o['fields']['directions'],
'source': o['fields']['source'],
'prep_time': o['fields']['prep_time'],
'cook_time': o['fields']['cook_time'],
'servings': o['fields']['servings'],
'ingredients': [],
}
if o['model'] == 'ingredient.ingredientgroup':
ingredient_group_dict[o['pk']] = o['fields']['recipe']
for o in recipe_json:
if o['model'] == 'ingredient.ingredient':
ingredient = {
'food': o['fields']['title'],
'unit': o['fields']['measurement'],
'amount': round(o['fields']['numerator'] / o['fields']['denominator'], 2),
}
recipe_dict[ingredient_group_dict[o['fields']['ingredient_group']]]['ingredients'].append(ingredient)
return list(recipe_dict.values())
def get_file_from_recipe(self, recipe):
raise NotImplementedError('Method not implemented in storage integration')

View File

@ -23,10 +23,10 @@ class Paprika(Integration):
name=recipe_json['name'].strip(), created_by=self.request.user, internal=True, space=self.request.space)
if 'description' in recipe_json:
recipe.description = recipe_json['description'].strip()
recipe.description = '' if len(recipe_json['description'].strip()) > 500 else recipe_json['description'].strip()
try:
if re.match(r'([0-9])+\s(.)*', recipe_json['servings'] ):
if re.match(r'([0-9])+\s(.)*', recipe_json['servings']):
s = recipe_json['servings'].split(' ')
recipe.servings = s[0]
recipe.servings_text = s[1]
@ -55,9 +55,12 @@ class Paprika(Integration):
pass
step = Step.objects.create(
instruction=instructions
instruction=instructions, space=self.request.space,
)
if len(recipe_json['description'].strip()) > 500:
step.instruction = recipe_json['description'].strip() + '\n\n' + step.instruction
if 'categories' in recipe_json:
for c in recipe_json['categories']:
keyword, created = Keyword.objects.get_or_create(name=c.strip(), space=self.request.space)
@ -70,7 +73,7 @@ class Paprika(Integration):
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))
except AttributeError:
pass
@ -78,6 +81,6 @@ class Paprika(Integration):
recipe.steps.add(step)
if recipe_json.get("photo_data", None):
self.import_recipe_image(recipe, BytesIO(base64.b64decode(recipe_json['photo_data'])))
self.import_recipe_image(recipe, BytesIO(base64.b64decode(recipe_json['photo_data'])), filetype='.jpeg')
return recipe

View File

@ -7,6 +7,7 @@ from zipfile import ZipFile
import imghdr
from django.utils.translation import gettext as _
from cookbook.helper.image_processing import get_filetype
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
from cookbook.integration.integration import Integration
from cookbook.models import Recipe, Step, Food, Unit, Ingredient, Keyword
@ -25,7 +26,7 @@ class RecetteTek(Integration):
recipe_list = [r for r in recipe_json]
return recipe_list
def get_recipe_from_file(self, file):
# Create initial recipe with just a title and a decription
@ -44,7 +45,7 @@ class RecetteTek(Integration):
if not instructions:
instructions = ''
step = Step.objects.create(instruction=instructions)
step = Step.objects.create(instruction=instructions, space=self.request.space,)
# Append the original import url to the step (if it exists)
try:
@ -53,7 +54,7 @@ class RecetteTek(Integration):
step.save()
except Exception as e:
print(recipe.name, ': failed to import source url ', str(e))
try:
# Process the ingredients. Assumes 1 ingredient per line.
for ingredient in file['ingredients'].split('\n'):
@ -62,7 +63,7 @@ class RecetteTek(Integration):
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))
except Exception as e:
print(recipe.name, ': failed to parse recipe ingredients ', str(e))
@ -96,7 +97,7 @@ class RecetteTek(Integration):
recipe.waiting_time = int(file['cookingTime'])
except Exception as e:
print(recipe.name, ': failed to parse cooking time ', str(e))
recipe.save()
# Import the recipe keywords
@ -110,20 +111,20 @@ class RecetteTek(Integration):
pass
# TODO: Parse Nutritional Information
# Import the original image from the zip file, if we cannot do that, attempt to download it again.
try:
if file['pictures'][0] !='':
if file['pictures'][0] != '':
image_file_name = file['pictures'][0].split('/')[-1]
for f in self.files:
if '.rtk' in f['name']:
import_zip = ZipFile(f['file'])
self.import_recipe_image(recipe, BytesIO(import_zip.read(image_file_name)))
self.import_recipe_image(recipe, BytesIO(import_zip.read(image_file_name)), filetype=get_filetype(image_file_name))
else:
if file['originalPicture'] != '':
response=requests.get(file['originalPicture'])
response = requests.get(file['originalPicture'])
if imghdr.what(BytesIO(response.content)) != None:
self.import_recipe_image(recipe, BytesIO(response.content))
self.import_recipe_image(recipe, BytesIO(response.content), filetype=get_filetype(file['originalPicture']))
else:
raise Exception("Original image failed to download.")
except Exception as e:

View File

@ -0,0 +1,80 @@
import re
from bs4 import BeautifulSoup
from io import BytesIO
from zipfile import ZipFile
from django.utils.translation import gettext as _
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
from cookbook.helper.recipe_url_import import parse_servings, iso_duration_to_minutes
from cookbook.integration.integration import Integration
from cookbook.models import Recipe, Step, Food, Unit, Ingredient, Keyword
class RecipeKeeper(Integration):
def import_file_name_filter(self, zip_info_object):
return re.match(r'^recipes.html$', zip_info_object.filename)
def split_recipe_file(self, file):
recipe_html = BeautifulSoup(file, 'html.parser')
return recipe_html.find_all('div', class_='recipe-details')
def get_recipe_from_file(self, file):
# 'file' comes is as a beautifulsoup object
recipe = Recipe.objects.create(name=file.find("h2", {"itemprop": "name"}).text.strip(), created_by=self.request.user, internal=True, space=self.request.space, )
# add 'Courses' and 'Categories' as keywords
for course in file.find_all("span", {"itemprop": "recipeCourse"}):
keyword, created = Keyword.objects.get_or_create(name=course.text, space=self.request.space)
recipe.keywords.add(keyword)
for category in file.find_all("meta", {"itemprop": "recipeCategory"}):
keyword, created = Keyword.objects.get_or_create(name=category.get("content"), space=self.request.space)
recipe.keywords.add(keyword)
try:
recipe.servings = parse_servings(file.find("span", {"itemprop": "recipeYield"}).text.strip())
recipe.working_time = iso_duration_to_minutes(file.find("span", {"meta": "prepTime"}).text.strip())
recipe.waiting_time = iso_duration_to_minutes(file.find("span", {"meta": "cookTime"}).text.strip())
recipe.save()
except AttributeError:
pass
step = Step.objects.create(instruction='', space=self.request.space,)
for ingredient in file.find("div", {"itemprop": "recipeIngredients"}).findChildren("p"):
if ingredient.text == "":
continue
amount, unit, ingredient, note = parse(ingredient.text.strip())
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))
for s in file.find("div", {"itemprop": "recipeDirections"}).find_all("p"):
if s.text == "":
continue
step.instruction += s.text + ' \n'
if file.find("span", {"itemprop": "recipeSource"}).text != '':
step.instruction += "\n\nImported from: " + file.find("span", {"itemprop": "recipeSource"}).text
step.save()
source_url_added = True
recipe.steps.add(step)
# import the Primary recipe image that is stored in the Zip
try:
for f in self.files:
if '.zip' in f['name']:
import_zip = ZipFile(f['file'])
self.import_recipe_image(recipe, BytesIO(import_zip.read(file.find("img", class_="recipe-photo").get("src"))), filetype='.jpeg')
except Exception as e:
pass
return recipe
def get_file_from_recipe(self, recipe):
raise NotImplementedError('Method not implemented in storage integration')

View File

@ -36,7 +36,7 @@ class RecipeSage(Integration):
ingredients_added = False
for s in file['recipeInstructions']:
step = Step.objects.create(
instruction=s['text']
instruction=s['text'], space=self.request.space,
)
if not ingredients_added:
ingredients_added = True
@ -46,7 +46,7 @@ class RecipeSage(Integration):
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))
recipe.steps.add(step)

View File

@ -43,7 +43,7 @@ class RezKonv(Integration):
recipe.keywords.add(keyword)
step = Step.objects.create(
instruction='\n'.join(directions) + '\n\n'
instruction='\n'.join(directions) + '\n\n', space=self.request.space,
)
for ingredient in ingredients:
@ -52,7 +52,7 @@ class RezKonv(Integration):
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))
recipe.steps.add(step)

View File

@ -43,14 +43,14 @@ class Safron(Integration):
recipe = Recipe.objects.create(name=title, description=description, created_by=self.request.user, internal=True, space=self.request.space, )
step = Step.objects.create(instruction='\n'.join(directions))
step = Step.objects.create(instruction='\n'.join(directions), space=self.request.space,)
for ingredient in ingredients:
amount, unit, ingredient, note = parse(ingredient)
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))
recipe.steps.add(step)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2 on 2021-04-22 21:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0123_invitelink_email'),
]
operations = [
migrations.AlterField(
model_name='userpreference',
name='search_style',
field=models.CharField(choices=[('SMALL', 'Small'), ('LARGE', 'Large'), ('NEW', 'New')], default='LARGE', max_length=64),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 3.2.3 on 2021-05-30 15:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0123_invitelink_email'),
]
operations = [
migrations.AlterField(
model_name='userpreference',
name='theme',
field=models.CharField(choices=[('BOOTSTRAP', 'Bootstrap'), ('DARKLY', 'Darkly'), ('FLATLY', 'Flatly'), ('SUPERHERO', 'Superhero'), ('TANDOOR', 'Tandoor')], default='FLATLY', max_length=128),
),
]

View File

@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0124_alter_userpreference_theme'),
('cookbook', '0124_alter_userpreference_search_style'),
]
operations = [

View File

@ -0,0 +1,17 @@
# Generated by Django 3.2.3 on 2021-06-07 14:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0126_alter_userpreference_theme'),
]
operations = [
migrations.RemoveField(
model_name='invitelink',
name='username',
),
]

View File

@ -0,0 +1,30 @@
# Generated by Django 3.2.3 on 2021-06-08 10:23
import cookbook.models
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('cookbook', '0127_remove_invitelink_username'),
]
operations = [
migrations.CreateModel(
name='UserFile',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=128)),
('file', models.FileField(upload_to='files/')),
('file_size_kb', models.IntegerField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('space', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.space')),
],
bases=(models.Model, cookbook.models.PermissionModelMixin),
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 3.2.3 on 2021-06-08 10:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0128_userfile'),
]
operations = [
migrations.RemoveField(
model_name='space',
name='allow_files',
),
migrations.AddField(
model_name='space',
name='max_file_storage_mb',
field=models.IntegerField(default=0, help_text='Maximum file storage for space in MB. 0 for unlimited, -1 to disable file upload.'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.3 on 2021-06-08 10:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0129_auto_20210608_1233'),
]
operations = [
migrations.AlterField(
model_name='userfile',
name='file_size_kb',
field=models.IntegerField(blank=True, default=0),
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 3.2.4 on 2021-06-08 17:29
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0130_alter_userfile_file_size_kb'),
]
operations = [
migrations.AddField(
model_name='step',
name='file',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='cookbook.userfile'),
),
migrations.AlterField(
model_name='step',
name='type',
field=models.CharField(choices=[('TEXT', 'Text'), ('TIME', 'Time'), ('FILE', 'File')], default='TEXT', max_length=16),
),
]

View File

@ -0,0 +1,111 @@
# Generated by Django 3.1.7 on 2021-04-07 20:00
import annoying.fields
from django.conf import settings
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField, SearchVector
from django.db import migrations, models
from django.db.models import deletion
from django_scopes import scopes_disabled
from django.utils import translation
from cookbook.managers import DICTIONARY
from cookbook.models import Recipe, Step, Index, PermissionModelMixin, nameSearchField, allSearchFields
def set_default_search_vector(apps, schema_editor):
if settings.DATABASES['default']['ENGINE'] not in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']:
return
language = DICTIONARY.get(translation.get_language(), 'simple')
with scopes_disabled():
# TODO this approach doesn't work terribly well if multiple languages are in use
# I'm also uncertain about forcing unaccent here
Recipe.objects.all().update(
name_search_vector=SearchVector('name__unaccent', weight='A', config=language),
desc_search_vector=SearchVector('description__unaccent', weight='B', config=language)
)
Step.objects.all().update(search_vector=SearchVector('instruction__unaccent', weight='B'))
class Migration(migrations.Migration):
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('cookbook', '0131_auto_20210608_1929'),
]
operations = [
migrations.AddField(
model_name='recipe',
name='desc_search_vector',
field=SearchVectorField(null=True),
),
migrations.AddField(
model_name='recipe',
name='name_search_vector',
field=SearchVectorField(null=True),
),
migrations.AddIndex(
model_name='recipe',
index=GinIndex(fields=['name_search_vector', 'desc_search_vector'], name='cookbook_re_name_se_bdf3ca_gin'),
),
migrations.AddField(
model_name='step',
name='search_vector',
field=SearchVectorField(null=True),
),
migrations.AddIndex(
model_name='step',
index=GinIndex(fields=['search_vector'], name='cookbook_st_search__2ef7fa_gin'),
),
migrations.AddIndex(
model_name='cooklog',
index=Index(fields=['id', 'recipe', '-created_at', 'rating'], name='cookbook_co_id_37485a_idx'),
),
migrations.AddIndex(
model_name='food',
index=Index(fields=['id', 'name'], name='cookbook_fo_id_22b733_idx'),
),
migrations.AddIndex(
model_name='ingredient',
index=Index(fields=['id', 'food', 'unit'], name='cookbook_in_id_3368be_idx'),
),
migrations.AddIndex(
model_name='keyword',
index=Index(fields=['id', 'name'], name='cookbook_ke_id_ebc03f_idx'),
),
migrations.AddIndex(
model_name='recipe',
index=Index(fields=['id', 'name', 'description'], name='cookbook_re_id_e4c2d4_idx'),
),
migrations.AddIndex(
model_name='recipebook',
index=Index(fields=['name', 'description'], name='cookbook_re_name_bbe446_idx'),
),
migrations.AddIndex(
model_name='viewlog',
index=Index(fields=['recipe', '-created_at'], name='cookbook_vi_recipe__5cd178_idx'),
),
migrations.CreateModel(
name='SearchFields',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=32, unique=True)),
('field', models.CharField(max_length=64, unique=True)),
],
bases=(models.Model, PermissionModelMixin),
),
migrations.CreateModel(
name='SearchPreference',
fields=[
('user', annoying.fields.AutoOneToOneField(on_delete=deletion.CASCADE, primary_key=True, serialize=False, to='auth.user')),
('search', models.CharField(choices=[('plain', 'Simple'), ('phrase', 'Phrase'), ('websearch', 'Web'), ('raw', 'Raw')], default='plain', max_length=32)),
('lookup', models.BooleanField(default=False)),
('fulltext', models.ManyToManyField(blank=True, related_name='fulltext_fields', to='cookbook.SearchFields')),
('icontains', models.ManyToManyField(blank=True, default=nameSearchField, related_name='icontains_fields', to='cookbook.SearchFields')),
('istartswith', models.ManyToManyField(blank=True, related_name='istartswith_fields', to='cookbook.SearchFields')),
('trigram', models.ManyToManyField(blank=True, related_name='trigram_fields', to='cookbook.SearchFields')),
('unaccent', models.ManyToManyField(blank=True, default=allSearchFields, related_name='unaccent_fields', to='cookbook.SearchFields')),
],
bases=(models.Model, PermissionModelMixin),
),
migrations.RunPython(
set_default_search_vector
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.4 on 2021-06-12 18:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0131_auto_20210608_1929'),
]
operations = [
migrations.AddField(
model_name='sharelink',
name='request_count',
field=models.IntegerField(default=0),
),
]

View File

@ -0,0 +1,23 @@
from cookbook.models import SearchFields
from django.db import migrations
def create_searchfields(apps, schema_editor):
SearchFields.objects.create(name='Name', field='name')
SearchFields.objects.create(name='Description', field='description')
SearchFields.objects.create(name='Instructions', field='steps__instruction')
SearchFields.objects.create(name='Ingredients', field='steps__ingredients__food__name')
SearchFields.objects.create(name='Keywords', field='keywords__name')
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0132_build_full_text_index'),
]
operations = [
migrations.RunPython(
create_searchfields
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.4 on 2021-06-12 18:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0132_sharelink_request_count'),
]
operations = [
migrations.AddField(
model_name='sharelink',
name='abuse_blocked',
field=models.BooleanField(default=False),
),
]

View File

@ -0,0 +1,70 @@
# Generated by Django 3.1.7 on 2021-03-30 19:42
from treebeard.mp_tree import MP_Node
from django.db import migrations, models
from django_scopes import scopes_disabled
# update if needed
steplen = MP_Node.steplen
alphabet = MP_Node.alphabet
node_order_by = ["name"]
def update_paths(apps, schema_editor):
with scopes_disabled():
Node = apps.get_model("cookbook", "Keyword")
nodes = Node.objects.all().order_by(*node_order_by)
for i, node in enumerate(nodes, 1):
# for default values, this resolves to: "{:04d}".format(i)
node.path = f"{{:{alphabet[0]}{steplen}d}}".format(i)
if nodes:
Node.objects.bulk_update(nodes, ["path"])
def backwards(apps, schema_editor):
"""nothing to do"""
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0133_create_searchfields'),
]
operations = [
migrations.AddField(
model_name='keyword',
name='depth',
field=models.PositiveIntegerField(default=1),
preserve_default=False,
),
migrations.AddField(
model_name='keyword',
name='numchild',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='keyword',
name='path',
field=models.CharField(default="", max_length=255, unique=False),
preserve_default=False,
),
migrations.AlterField(
model_name='userpreference',
name='use_fractions',
field=models.BooleanField(default=True),
),
migrations.RunPython(update_paths, backwards),
migrations.AlterField(
model_name="keyword",
name="path",
field=models.CharField(max_length=255, unique=True),
),
migrations.AlterUniqueTogether(
name='keyword',
unique_together=set(),
),
migrations.AddConstraint(
model_name='keyword',
constraint=models.UniqueConstraint(fields=('space', 'name'), name='unique_name_per_space'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.4 on 2021-06-15 19:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0133_sharelink_abuse_blocked'),
]
operations = [
migrations.AddField(
model_name='space',
name='allow_sharing',
field=models.BooleanField(default=True),
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 3.2.4 on 2021-06-15 20:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0134_space_allow_sharing'),
]
operations = [
migrations.AddField(
model_name='ingredient',
name='space',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
),
migrations.AddField(
model_name='nutritioninformation',
name='space',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
),
migrations.AddField(
model_name='step',
name='space',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.4 on 2021-06-17 11:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0135_auto_20210615_2210'),
]
operations = [
migrations.AddField(
model_name='importlog',
name='imported_recipes',
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name='importlog',
name='total_recipes',
field=models.IntegerField(default=0),
),
]

View File

@ -0,0 +1,34 @@
# Generated by Django 3.2.4 on 2021-06-17 13:01
from django.db import migrations
from django.db.models import Subquery, OuterRef
from django_scopes import scopes_disabled
from django.db import migrations, models
import django.db.models.deletion
def migrate_spaces(apps, schema_editor):
with scopes_disabled():
Recipe = apps.get_model('cookbook', 'Recipe')
Step = apps.get_model('cookbook', 'Step')
Ingredient = apps.get_model('cookbook', 'Ingredient')
NutritionInformation = apps.get_model('cookbook', 'NutritionInformation')
Step.objects.filter(recipe__isnull=True).delete()
Ingredient.objects.filter(step__recipe__isnull=True).delete()
NutritionInformation.objects.filter(recipe__isnull=True).delete()
Step.objects.update(space=Subquery(Step.objects.filter(pk=OuterRef('pk')).values('recipe__space')[:1]))
Ingredient.objects.update(space=Subquery(Ingredient.objects.filter(pk=OuterRef('pk')).values('step__recipe__space')[:1]))
NutritionInformation.objects.update(space=Subquery(NutritionInformation.objects.filter(pk=OuterRef('pk')).values('recipe__space')[:1]))
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0136_auto_20210617_1343'),
]
operations = [
migrations.RunPython(migrate_spaces),
]

View File

@ -0,0 +1,31 @@
# Generated by Django 3.2.4 on 2021-06-17 14:02
from django.db import migrations
from django.db.models import Subquery, OuterRef
from django_scopes import scopes_disabled
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0137_auto_20210617_1501'),
]
operations = [
migrations.AlterField(
model_name='ingredient',
name='space',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
),
migrations.AlterField(
model_name='nutritioninformation',
name='space',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
),
migrations.AlterField(
model_name='step',
name='space',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 3.2.4 on 2021-06-22 16:14
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0138_auto_20210617_1602'),
]
operations = [
migrations.AddField(
model_name='space',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 3.2.4 on 2021-06-22 16:19
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0139_space_created_at'),
]
operations = [
migrations.AddField(
model_name='userpreference',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
]

View File

@ -1,3 +1,5 @@
import operator
import pathlib
import re
import uuid
from datetime import date, timedelta
@ -8,14 +10,15 @@ from django.contrib import auth
from django.contrib.auth.models import Group, User
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField
from django.core.files.uploadedfile import UploadedFile, InMemoryUploadedFile
from django.core.validators import MinLengthValidator
from django.db import models
from django.db.models import Index
from django.utils import timezone
from django.utils.translation import gettext as _
from treebeard.mp_tree import MP_Node, MP_NodeManager
from django_scopes import ScopedManager, scopes_disabled
from django_prometheus.models import ExportModelOperationsMixin
from django_scopes import ScopedManager
from recipes.settings import (COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT,
STICKY_NAV_PREF_DEFAULT)
@ -57,18 +60,22 @@ class PermissionModelMixin:
def get_space(self):
p = '.'.join(self.get_space_key())
if getattr(self, p, None):
return getattr(self, p)
raise NotImplementedError('get space for method not implemented and standard fields not available')
try:
if space := operator.attrgetter(p)(self):
return space
except AttributeError:
raise NotImplementedError('get space for method not implemented and standard fields not available')
class Space(ExportModelOperationsMixin('space'), models.Model):
name = models.CharField(max_length=128, default='Default')
created_by = models.ForeignKey(User, on_delete=models.PROTECT, null=True)
created_at = models.DateTimeField(auto_now_add=True)
message = models.CharField(max_length=512, default='', blank=True)
max_recipes = models.IntegerField(default=0)
allow_files = models.BooleanField(default=True)
max_file_storage_mb = models.IntegerField(default=0, help_text=_('Maximum file storage for space in MB. 0 for unlimited, -1 to disable file upload.'))
max_users = models.IntegerField(default=0)
allow_sharing = models.BooleanField(default=True)
demo = models.BooleanField(default=False)
def __str__(self):
@ -152,6 +159,7 @@ class UserPreference(models.Model, PermissionModelMixin):
shopping_auto_sync = models.IntegerField(default=5)
sticky_navbar = models.BooleanField(default=STICKY_NAV_PREF_DEFAULT)
created_at = models.DateTimeField(auto_now_add=True)
space = models.ForeignKey(Space, on_delete=models.CASCADE, null=True)
objects = ScopedManager(space='space')
@ -209,6 +217,7 @@ class SupermarketCategory(models.Model, PermissionModelMixin):
return self.name
class Meta:
# TODO according to this https://docs.djangoproject.com/en/3.1/ref/models/options/#unique-together should not be used
unique_together = (('space', 'name'),)
@ -224,6 +233,7 @@ class Supermarket(models.Model, PermissionModelMixin):
return self.name
class Meta:
# TODO according to this https://docs.djangoproject.com/en/3.1/ref/models/options/#unique-together should not be used
unique_together = (('space', 'name'),)
@ -254,7 +264,9 @@ class SyncLog(models.Model, PermissionModelMixin):
return f"{self.created_at}:{self.sync} - {self.status}"
class Keyword(ExportModelOperationsMixin('keyword'), models.Model, PermissionModelMixin):
class Keyword(ExportModelOperationsMixin('keyword'), MP_Node, PermissionModelMixin):
# TODO create get_or_create method
node_order_by = ['name']
name = models.CharField(max_length=64)
icon = models.CharField(max_length=16, blank=True, null=True)
description = models.TextField(default="", blank=True)
@ -262,7 +274,9 @@ class Keyword(ExportModelOperationsMixin('keyword'), models.Model, PermissionMod
updated_at = models.DateTimeField(auto_now=True)
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space')
objects = ScopedManager(space='space', _manager_class=MP_NodeManager)
_full_name_separator = ' > '
def __str__(self):
if self.icon:
@ -270,6 +284,63 @@ class Keyword(ExportModelOperationsMixin('keyword'), models.Model, PermissionMod
else:
return f"{self.name}"
@property
def parent(self):
parent = self.get_parent()
if parent:
return self.get_parent().id
return None
@classmethod
def get_or_create(self, **kwargs):
# an attempt to mimic get_or_create functionality with Keywords
# function attempts to get the keyword,
# if the length of the return is 0 will add a root node
kwargs['name'] = kwargs['name'].strip()
q = self.get_tree().filter(name=kwargs['name'], space=kwargs['space'])
if len(q) != 0:
return q[0]
else:
return Keyword.add_root(**kwargs)
@property
def full_name(self):
"""
Returns a string representation of the keyword and it's ancestors,
e.g. 'Cuisine > Asian > Chinese > Catonese'.
"""
names = [keyword.name for keyword in self.get_ancestors_and_self()]
return self._full_name_separator.join(names)
def get_ancestors_and_self(self):
"""
Gets ancestors and includes itself. Use treebeard's get_ancestors
if you don't want to include the keyword itself. It's a separate
function as it's commonly used in templates.
"""
if self.is_root():
return [self]
return list(self.get_ancestors()) + [self]
def get_descendants_and_self(self):
"""
Gets descendants and includes itself. Use treebeard's get_descendants
if you don't want to include the keyword itself. It's a separate
function as it's commonly used in templates.
"""
return self.get_tree(self)
def has_children(self):
return self.get_num_children() > 0
def get_num_children(self):
return self.get_children().count()
@classmethod
def add_root(self, **kwargs):
with scopes_disabled():
return super().add_root(**kwargs)
class Meta:
unique_together = (('space', 'name'),)
indexes = (Index(fields=['id', 'name']), )
@ -286,6 +357,7 @@ class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixi
return self.name
class Meta:
# TODO according to this https://docs.djangoproject.com/en/3.1/ref/models/options/#unique-together should not be used
unique_together = (('space', 'name'),)
@ -303,6 +375,7 @@ class Food(ExportModelOperationsMixin('food'), models.Model, PermissionModelMixi
return self.name
class Meta:
# TODO according to this https://docs.djangoproject.com/en/3.1/ref/models/options/#unique-together should not be used
unique_together = (('space', 'name'),)
indexes = (Index(fields=['id', 'name']), )
@ -316,14 +389,8 @@ class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, Permiss
no_amount = models.BooleanField(default=False)
order = models.IntegerField(default=0)
objects = ScopedManager(space='step__recipe__space')
@staticmethod
def get_space_key():
return 'step', 'recipe', 'space'
def get_space(self):
return self.step_set.first().recipe_set.first().space
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space')
def __str__(self):
return str(self.amount) + ' ' + str(self.unit) + ' ' + str(self.food)
@ -336,10 +403,11 @@ class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, Permiss
class Step(ExportModelOperationsMixin('step'), models.Model, PermissionModelMixin):
TEXT = 'TEXT'
TIME = 'TIME'
FILE = 'FILE'
name = models.CharField(max_length=128, default='', blank=True)
type = models.CharField(
choices=((TEXT, _('Text')), (TIME, _('Time')),),
choices=((TEXT, _('Text')), (TIME, _('Time')), (FILE, _('File')),),
default=TEXT,
max_length=16
)
@ -347,17 +415,12 @@ class Step(ExportModelOperationsMixin('step'), models.Model, PermissionModelMixi
ingredients = models.ManyToManyField(Ingredient, blank=True)
time = models.IntegerField(default=0, blank=True)
order = models.IntegerField(default=0)
file = models.ForeignKey('UserFile', on_delete=models.PROTECT, null=True, blank=True)
show_as_header = models.BooleanField(default=True)
search_vector = SearchVectorField(null=True)
objects = ScopedManager(space='recipe__space')
@staticmethod
def get_space_key():
return 'recipe', 'space'
def get_space(self):
return self.recipe_set.first().space
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space')
def get_instruction_render(self):
from cookbook.helper.template_helper import render_instructions
@ -379,17 +442,11 @@ class NutritionInformation(models.Model, PermissionModelMixin):
max_length=512, default="", null=True, blank=True
)
objects = ScopedManager(space='recipe__space')
@staticmethod
def get_space_key():
return 'recipe', 'space'
def get_space(self):
return self.recipe_set.first().space
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space')
def __str__(self):
return 'Nutrition'
return f'Nutrition {self.pk}'
class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModelMixin):
@ -501,6 +558,7 @@ class RecipeBookEntry(ExportModelOperationsMixin('book_entry'), models.Model, Pe
return None
class Meta:
# TODO according to this https://docs.djangoproject.com/en/3.1/ref/models/options/#unique-together should not be used
unique_together = (('recipe', 'book'),)
@ -615,6 +673,8 @@ class ShoppingList(ExportModelOperationsMixin('shopping_list'), models.Model, Pe
class ShareLink(ExportModelOperationsMixin('share_link'), models.Model, PermissionModelMixin):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
uuid = models.UUIDField(default=uuid.uuid4)
request_count = models.IntegerField(default=0)
abuse_blocked = models.BooleanField(default=False)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
@ -631,7 +691,6 @@ def default_valid_until():
class InviteLink(ExportModelOperationsMixin('invite_link'), models.Model, PermissionModelMixin):
uuid = models.UUIDField(default=uuid.uuid4)
username = models.CharField(blank=True, max_length=64)
email = models.EmailField(blank=True)
group = models.ForeignKey(Group, on_delete=models.CASCADE)
valid_until = models.DateField(default=default_valid_until)
@ -699,6 +758,10 @@ class ImportLog(models.Model, PermissionModelMixin):
running = models.BooleanField(default=True)
msg = models.TextField(default="")
keyword = models.ForeignKey(Keyword, null=True, blank=True, on_delete=models.SET_NULL)
total_recipes = models.IntegerField(default=0)
imported_recipes = models.IntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
@ -764,3 +827,20 @@ class SearchPreference(models.Model, PermissionModelMixin):
istartswith = models.ManyToManyField(SearchFields, related_name="istartswith_fields", blank=True)
trigram = models.ManyToManyField(SearchFields, related_name="trigram_fields", blank=True)
fulltext = models.ManyToManyField(SearchFields, related_name="fulltext_fields", blank=True)
class UserFile(ExportModelOperationsMixin('user_files'), models.Model, PermissionModelMixin):
name = models.CharField(max_length=128)
file = models.FileField(upload_to='files/')
file_size_kb = models.IntegerField(default=0, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
objects = ScopedManager(space='space')
space = models.ForeignKey(Space, on_delete=models.CASCADE)
def save(self, *args, **kwargs):
if hasattr(self.file, 'file') and isinstance(self.file.file, UploadedFile) or isinstance(self.file.file, InMemoryUploadedFile):
self.file.name = f'{uuid.uuid4()}' + pathlib.Path(self.file.name).suffix
self.file_size_kb = round(self.file.size / 1000)
super(UserFile, self).save(*args, **kwargs)

86
cookbook/schemas.py Normal file
View File

@ -0,0 +1,86 @@
from rest_framework.schemas.openapi import AutoSchema
from rest_framework.schemas.utils import is_list_view
# TODO move to separate class to cleanup
class RecipeSchema(AutoSchema):
def get_path_parameters(self, path, method):
if not is_list_view(path, method, self.view):
return super(RecipeSchema, self).get_path_parameters(path, method)
parameters = super().get_path_parameters(path, method)
parameters.append({
"name": 'query', "in": "query", "required": False,
"description": 'Query string matched (fuzzy) against recipe name. In the future also fulltext search.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'keywords', "in": "query", "required": False,
"description": 'Id of keyword a recipe should have. For multiple repeat parameter.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'foods', "in": "query", "required": False,
"description": 'Id of food a recipe should have. For multiple repeat parameter.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'books', "in": "query", "required": False,
"description": 'Id of book a recipe should have. For multiple repeat parameter.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'keywords_or', "in": "query", "required": False,
"description": 'If recipe should have all (AND) or any (OR) of the provided keywords.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'foods_or', "in": "query", "required": False,
"description": 'If recipe should have all (AND) or any (OR) any of the provided foods.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'books_or', "in": "query", "required": False,
"description": 'If recipe should be in all (AND) or any (OR) any of the provided books.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'internal', "in": "query", "required": False,
"description": 'true or false. If only internal recipes should be returned or not.',
'schema': {'type': 'string', },
})
parameters.append({
"name": 'random', "in": "query", "required": False,
"description": 'true or false. returns the results in randomized order.',
'schema': {'type': 'string', },
})
return parameters
# TODO move to separate class to cleanup
class TreeSchema(AutoSchema):
def get_path_parameters(self, path, method):
if not is_list_view(path, method, self.view):
return super(TreeSchema, self).get_path_parameters(path, method)
api_name = path.split('/')[2]
parameters = super().get_path_parameters(path, method)
parameters.append({
"name": 'query', "in": "query", "required": False,
"description": 'Query string matched against {} name.'.format(api_name),
'schema': {'type': 'string', },
})
parameters.append({
"name": 'root', "in": "query", "required": False,
"description": 'Return first level children of {obj} with ID [int]. Integer 0 will return root {obj}s.'.format(obj=api_name),
'schema': {'type': 'int', },
})
parameters.append({
"name": 'tree', "in": "query", "required": False,
"description": 'Return all self and children of {} with ID [int].'.format(api_name),
'schema': {'type': 'int', },
})
return parameters

View File

@ -1,13 +1,14 @@
import random
from decimal import Decimal
from gettext import gettext as _
from django.contrib.auth.models import User
from django.db.models import QuerySet
from django.db.models import QuerySet, Sum, Avg
from drf_writable_nested import (UniqueFieldsMixin,
WritableNestedModelSerializer)
from rest_framework import serializers
from rest_framework.exceptions import ValidationError, NotAuthenticated, NotFound, ParseError
from rest_framework.fields import ModelField
from rest_framework.serializers import BaseSerializer, Serializer
from rest_framework.exceptions import ValidationError, NotFound
from treebeard.mp_tree import MP_NodeQuerySet
from cookbook.models import (Comment, CookLog, Food, Ingredient, Keyword,
MealPlan, MealType, NutritionInformation, Recipe,
@ -15,7 +16,7 @@ from cookbook.models import (Comment, CookLog, Food, Ingredient, Keyword,
ShareLink, ShoppingList, ShoppingListEntry,
ShoppingListRecipe, Step, Storage, Sync, SyncLog,
Unit, UserPreference, ViewLog, SupermarketCategory, Supermarket,
SupermarketCategoryRelation, ImportLog, BookmarkletImport)
SupermarketCategoryRelation, ImportLog, BookmarkletImport, UserFile)
from cookbook.templatetags.custom_tags import markdown
@ -46,7 +47,7 @@ class CustomDecimalField(serializers.Field):
class SpaceFilterSerializer(serializers.ListSerializer):
def to_representation(self, data):
if type(data) == QuerySet and data.query.is_sliced:
if (type(data) == QuerySet and data.query.is_sliced) or type(data) == MP_NodeQuerySet:
# if query is sliced it came from api request not nested serializer
return super().to_representation(data)
if self.child.Meta.model == User:
@ -103,6 +104,51 @@ class UserPreferenceSerializer(serializers.ModelSerializer):
)
class UserFileSerializer(serializers.ModelSerializer):
def check_file_limit(self, validated_data):
if self.context['request'].space.max_file_storage_mb == -1:
raise ValidationError(_('File uploads are not enabled for this Space.'))
try:
current_file_size_mb = UserFile.objects.filter(space=self.context['request'].space).aggregate(Sum('file_size_kb'))['file_size_kb__sum'] / 1000
except TypeError:
current_file_size_mb = 0
if (validated_data['file'].size / 1000 / 1000 + current_file_size_mb - 5) > self.context['request'].space.max_file_storage_mb != 0:
raise ValidationError(_('You have reached your file upload limit.'))
def create(self, validated_data):
self.check_file_limit(validated_data)
validated_data['created_by'] = self.context['request'].user
validated_data['space'] = self.context['request'].space
return super().create(validated_data)
def update(self, instance, validated_data):
self.check_file_limit(validated_data)
return super().update(instance, validated_data)
class Meta:
model = UserFile
fields = ('name', 'file', 'file_size_kb', 'id',)
read_only_fields = ('id', 'file_size_kb')
extra_kwargs = {"file": {"required": False, }}
class UserFileViewSerializer(serializers.ModelSerializer):
def create(self, validated_data):
raise ValidationError('Cannot create File over this view')
def update(self, instance, validated_data):
return instance
class Meta:
model = UserFile
fields = ('name', 'file', 'id',)
read_only_fields = ('id', 'file')
class StorageSerializer(SpacedModelSerializer):
def create(self, validated_data):
@ -156,20 +202,36 @@ class KeywordLabelSerializer(serializers.ModelSerializer):
class KeywordSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
label = serializers.SerializerMethodField('get_label')
image = serializers.SerializerMethodField('get_image')
numrecipe = serializers.SerializerMethodField('count_recipes')
def get_label(self, obj):
return str(obj)
def get_image(self, obj):
recipes = obj.recipe_set.all().exclude(image__isnull=True).exclude(image__exact='')
if len(recipes) == 0:
recipes = Recipe.objects.filter(keywords__in=Keyword.get_tree(obj)).exclude(image__isnull=True).exclude(image__exact='') # if no recipes found - check whole tree
if len(recipes) != 0:
return random.choice(recipes).image.url
else:
return None
def count_recipes(self, obj):
return obj.recipe_set.all().count()
def create(self, validated_data):
obj, created = Keyword.objects.get_or_create(name=validated_data['name'].strip(), space=self.context['request'].space)
# since multi select tags dont have id's
# duplicate names might be routed to create
validated_data['space'] = self.context['request'].space
obj = Keyword.get_or_create(**validated_data)
return obj
class Meta:
list_serializer_class = SpaceFilterSerializer
# list_serializer_class = SpaceFilterSerializer
model = Keyword
fields = ('id', 'name', 'icon', 'label', 'description', 'created_at', 'updated_at')
read_only_fields = ('id',)
fields = ('id', 'name', 'icon', 'label', 'description', 'image', 'parent', 'numchild', 'numrecipe', 'created_at', 'updated_at')
read_only_fields = ('id', 'numchild',)
class UnitSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
@ -239,6 +301,10 @@ class IngredientSerializer(WritableNestedModelSerializer):
unit = UnitSerializer(allow_null=True)
amount = CustomDecimalField()
def create(self, validated_data):
validated_data['space'] = self.context['request'].space
return super().create(validated_data)
class Meta:
model = Ingredient
fields = (
@ -251,6 +317,11 @@ class StepSerializer(WritableNestedModelSerializer):
ingredients = IngredientSerializer(many=True)
ingredients_markdown = serializers.SerializerMethodField('get_ingredients_markdown')
ingredients_vue = serializers.SerializerMethodField('get_ingredients_vue')
file = UserFileViewSerializer(allow_null=True, required=False)
def create(self, validated_data):
validated_data['space'] = self.context['request'].space
return super().create(validated_data)
def get_ingredients_vue(self, obj):
return obj.get_instruction_render()
@ -262,18 +333,39 @@ class StepSerializer(WritableNestedModelSerializer):
model = Step
fields = (
'id', 'name', 'type', 'instruction', 'ingredients', 'ingredients_markdown',
'ingredients_vue', 'time', 'order', 'show_as_header'
'ingredients_vue', 'time', 'order', 'show_as_header', 'file',
)
class NutritionInformationSerializer(serializers.ModelSerializer):
def create(self, validated_data):
validated_data['space'] = self.context['request'].space
return super().create(validated_data)
class Meta:
model = NutritionInformation
fields = ('id', 'carbohydrates', 'fats', 'proteins', 'calories', 'source')
class RecipeOverviewSerializer(WritableNestedModelSerializer):
class RecipeBaseSerializer(WritableNestedModelSerializer):
def get_recipe_rating(self, obj):
rating = obj.cooklog_set.filter(created_by=self.context['request'].user, rating__gt=0).aggregate(Avg('rating'))
if rating['rating__avg']:
return rating['rating__avg']
return 0
def get_recipe_last_cooked(self, obj):
last = obj.cooklog_set.filter(created_by=self.context['request'].user).last()
if last:
return last.created_at
return None
class RecipeOverviewSerializer(RecipeBaseSerializer):
keywords = KeywordLabelSerializer(many=True)
rating = serializers.SerializerMethodField('get_recipe_rating')
last_cooked = serializers.SerializerMethodField('get_recipe_last_cooked')
def create(self, validated_data):
pass
@ -286,22 +378,24 @@ class RecipeOverviewSerializer(WritableNestedModelSerializer):
fields = (
'id', 'name', 'description', 'image', 'keywords', 'working_time',
'waiting_time', 'created_by', 'created_at', 'updated_at',
'internal', 'servings', 'file_path'
'internal', 'servings', 'servings_text', 'rating', 'last_cooked',
)
read_only_fields = ['image', 'created_by', 'created_at']
class RecipeSerializer(WritableNestedModelSerializer):
class RecipeSerializer(RecipeBaseSerializer):
nutrition = NutritionInformationSerializer(allow_null=True, required=False)
steps = StepSerializer(many=True)
keywords = KeywordSerializer(many=True)
rating = serializers.SerializerMethodField('get_recipe_rating')
last_cooked = serializers.SerializerMethodField('get_recipe_last_cooked')
class Meta:
model = Recipe
fields = (
'id', 'name', 'description', 'image', 'keywords', 'steps', 'working_time',
'waiting_time', 'created_by', 'created_at', 'updated_at',
'internal', 'nutrition', 'servings', 'file_path', 'servings_text',
'internal', 'nutrition', 'servings', 'file_path', 'servings_text', 'rating', 'last_cooked',
)
read_only_fields = ['image', 'created_by', 'created_at']
@ -475,7 +569,7 @@ class ImportLogSerializer(serializers.ModelSerializer):
class Meta:
model = ImportLog
fields = ('id', 'type', 'msg', 'running', 'keyword', 'created_by', 'created_at')
fields = ('id', 'type', 'msg', 'running', 'keyword', 'total_recipes', 'imported_recipes', 'created_by', 'created_at')
read_only_fields = ('created_by',)
@ -533,6 +627,10 @@ class IngredientExportSerializer(WritableNestedModelSerializer):
unit = UnitExportSerializer(allow_null=True)
amount = CustomDecimalField()
def create(self, validated_data):
validated_data['space'] = self.context['request'].space
return super().create(validated_data)
class Meta:
model = Ingredient
fields = ('food', 'unit', 'amount', 'note', 'order', 'is_header', 'no_amount')
@ -541,6 +639,10 @@ class IngredientExportSerializer(WritableNestedModelSerializer):
class StepExportSerializer(WritableNestedModelSerializer):
ingredients = IngredientExportSerializer(many=True)
def create(self, validated_data):
validated_data['space'] = self.context['request'].space
return super().create(validated_data)
class Meta:
model = Step
fields = ('name', 'type', 'instruction', 'ingredients', 'time', 'order', 'show_as_header')

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -18,7 +18,7 @@
}
function initBookmarklet() {
(window.bookmarkletTandoor = function() {
let recipe = document.documentElement.innerHTML
let recipe = document.documentElement.outerHTML
let windowName = "ImportRecipe"
let url = localStorage.getItem('importURL')
let redirect = localStorage.getItem('redirectURL')

View File

@ -4190,12 +4190,12 @@ input[type=button].btn-block, input[type=reset].btn-block, input[type=submit].bt
}
.navbar-dark .navbar-toggler {
color: hsla(0, 0%, 18%, .5);
border-color: hsla(0, 0%, 18%, .1)
color: rgba(46, 46, 46, 0.5);
border-color: rgba(46, 46, 46, 0.5);
}
.navbar-dark .navbar-toggler-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(255, 255, 255, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E")
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(46, 46, 46, 1)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E")
}
.navbar-dark .navbar-text {
@ -4261,7 +4261,7 @@ input[type=button].btn-block, input[type=reset].btn-block, input[type=submit].bt
.card-header {
padding: .75rem 1.25rem;
margin-bottom: 0;
background-color: rgba(0, 0, 0, .03);
background-color: #ffffff;
border-bottom: 1px solid rgba(0, 0, 0, .125)
}
@ -10402,16 +10402,6 @@ footer a:hover {
box-shadow: none
}
.modal-content {
width: 500px
}
@media (max-width: 575px) {
.modal-content {
width: 300px
}
}
.modal-content .modal-header {
justify-content: center;
border: none
@ -10427,6 +10417,12 @@ footer a:hover {
padding: 5px 0 20px 39px
}
.modal-content .modal-footer {
border: none;
justify-content: flex-start;
padding: 5px 0 20px 39px
}
/*# sourceMappingURL=maps/style.min.css.map */
.bg-header {

View File

@ -0,0 +1 @@
.shake[data-v-88855b04]{-webkit-animation:shake-data-v-88855b04 .82s cubic-bezier(.36,.07,.19,.97) both;animation:shake-data-v-88855b04 .82s cubic-bezier(.36,.07,.19,.97) both;transform:translateZ(0);-webkit-backface-visibility:hidden;backface-visibility:hidden;perspective:1000px}@-webkit-keyframes shake-data-v-88855b04{10%,90%{transform:translate3d(-1px,0,0)}20%,80%{transform:translate3d(2px,0,0)}30%,50%,70%{transform:translate3d(-4px,0,0)}40%,60%{transform:translate3d(4px,0,0)}}@keyframes shake-data-v-88855b04{10%,90%{transform:translate3d(-1px,0,0)}20%,80%{transform:translate3d(2px,0,0)}30%,50%,70%{transform:translate3d(-4px,0,0)}40%,60%{transform:translate3d(4px,0,0)}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
<!DOCTYPE html><html lang=""><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Vue App</title><link href="css/chunk-vendors.css" rel="preload" as="style"><link href="css/keyword_list_view.css" rel="preload" as="style"><link href="js/chunk-vendors.js" rel="preload" as="script"><link href="js/keyword_list_view.js" rel="preload" as="script"><link href="css/chunk-vendors.css" rel="stylesheet"><link rel="icon" type="image/png" sizes="32x32" href="img/icons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="img/icons/favicon-16x16.png"><link rel="manifest" href="manifest.json"><meta name="theme-color" content="#4DBA87"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black"><meta name="apple-mobile-web-app-title" content="Recipes"><link rel="apple-touch-icon" href="img/icons/apple-touch-icon-152x152.png"><link rel="mask-icon" href="img/icons/safari-pinned-tab.svg" color="#4DBA87"><meta name="msapplication-TileImage" content="img/icons/msapplication-icon-144x144.png"><meta name="msapplication-TileColor" content="#000000"></head><body><div id="app"></div><script src="js/chunk-vendors.js"></script></body></html>

View File

@ -0,0 +1 @@
<!DOCTYPE html><html lang=""><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Vue App</title><link href="css/chunk-vendors.css" rel="preload" as="style"><link href="js/chunk-vendors.js" rel="preload" as="script"><link href="js/user_file_view.js" rel="preload" as="script"><link href="css/chunk-vendors.css" rel="stylesheet"><link rel="icon" type="image/png" sizes="32x32" href="img/icons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="img/icons/favicon-16x16.png"><link rel="manifest" href="manifest.json"><meta name="theme-color" content="#4DBA87"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black"><meta name="apple-mobile-web-app-title" content="Recipes"><link rel="apple-touch-icon" href="img/icons/apple-touch-icon-152x152.png"><link rel="mask-icon" href="img/icons/safari-pinned-tab.svg" color="#4DBA87"><meta name="msapplication-TileImage" content="img/icons/msapplication-icon-144x144.png"><meta name="msapplication-TileColor" content="#000000"></head><body><div id="app"></div><script src="js/chunk-vendors.js"></script></body></html>

View File

@ -52,15 +52,6 @@ class RecipeTable(tables.Table):
)
class KeywordTable(tables.Table):
id = tables.LinkColumn('edit_keyword', args=[A('id')])
class Meta:
model = Keyword
template_name = 'generic/table_template.html'
fields = ('id', 'icon', 'name')
class IngredientTable(tables.Table):
id = tables.LinkColumn('edit_food', args=[A('id')])
@ -141,17 +132,17 @@ class ShoppingListTable(tables.Table):
class InviteLinkTable(tables.Table):
link = tables.TemplateColumn(
"<a href='{% url 'view_signup' record.uuid %}' >" + _('Link') + "</a>"
"<input value='{{ request.scheme }}://{{ request.get_host }}{% url 'view_invite' record.uuid %}' class='form-control' />"
)
delete_link = tables.TemplateColumn(
"<a href='{% url 'delete_invite_link' record.pk %}' >" + _('Delete') + "</a>", verbose_name=_('Delete')
)
# delete = tables.TemplateColumn(
# "<a href='{% url 'delete_invite_link' record.pk %}' >" + _('Delete') + "</a>"
# )
class Meta:
model = InviteLink
template_name = 'generic/table_template.html'
fields = (
'username', 'group', 'valid_until', 'created_by', 'created_at'
'username', 'group', 'valid_until',
)

View File

@ -6,6 +6,14 @@
{% block title %}{% trans "E-mail Addresses" %}{% endblock %}
{% block content %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'view_settings' %}">{% trans 'Settings' %}</a></li>
<li class="breadcrumb-item active" aria-current="page">{% trans 'Email' %}</li>
</ol>
</nav>
<h3>{% trans "E-mail Addresses" %}</h3>
{% if user.emailaddress_set.all %}
<p>{% trans 'The following e-mail addresses are associated with your account:' %}</p>

View File

@ -18,7 +18,7 @@
<div class="row">
<div class="col-6 offset-3">
<div class="col-sm-12 col-lg-6 col-md-6 offset-lg-3 offset-md-3">
<hr>
<form class="login" method="POST" action="{% url 'account_login' %}">
{% csrf_token %}
@ -32,8 +32,9 @@
<a class="btn btn-secondary" href="{% url 'account_signup' %}">{% trans "Sign Up" %}</a>
{% if EMAIL_ENABLED %}
<a class="btn btn-warning float-right"
<a class="btn btn-warning float-right d-none d-xl-block d-lg-block"
href="{% url 'account_reset_password' %}">{% trans "Reset My Password" %}</a>
<p class="d-xl-none d-lg-none">{% trans 'Lost your password?' %} <a href="{% url 'account_reset_password' %}">{% trans "Reset My Password" %}</a></p>
{% endif %}
</form>
</div>
@ -43,13 +44,13 @@
{% if socialaccount_providers %}
<div class="row" style="margin-top: 2vh">
<div class="col-6 offset-3">
<div class="col-sm-12 col-lg-6 col-md-6 offset-lg-3 offset-md-3">
<h5>{% trans "Social Login" %}</h5>
<span>{% trans 'You can use any of the following providers to sign in.' %}</span>
<br/>
<br/>
<ul class="socialaccount_providers">
<ul class="socialaccount_providers list-unstyled">
{% include "socialaccount/snippets/provider_list.html" with process="login" %}
</ul>

View File

@ -0,0 +1,24 @@
{% extends "base.html" %}
{% load crispy_forms_filters %}
{% load i18n %}
{% block head_title %}{% trans "Change Password" %}{% endblock %}
{% block content %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'view_settings' %}">{% trans 'Settings' %}</a></li>
<li class="breadcrumb-item active" aria-current="page">{% trans 'Password' %}</li>
</ol>
</nav>
<h1>{% trans "Change Password" %}</h1>
<form method="POST" action="{% url 'account_change_password' %}" class="password_change">
{% csrf_token %}
{{ form | crispy }}
<button type="submit" name="action" class="btn btn-success">{% trans "Change Password" %}</button>
<a href="{% url 'account_reset_password' %}">{% trans "Forgot Password?" %}</a>
</form>
{% endblock %}

View File

@ -18,7 +18,7 @@
</div>
<div class="row">
<div class="col-6 offset-3">
<div class="col-sm-12 col-lg-6 col-md-6 offset-lg-3 offset-md-3">
<hr>
{% if EMAIL_ENABLED %}
<p>{% trans "Forgotten your password? Enter your e-mail address below, and we'll send you an e-mail allowing you to reset it." %}</p>

View File

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% load crispy_forms_filters %}
{% load i18n %}
{% block head_title %}{% trans "Set Password" %}{% endblock %}
{% block content %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'view_settings' %}">{% trans 'Settings' %}</a></li>
<li class="breadcrumb-item active" aria-current="page">{% trans 'Password' %}</li>
</ol>
</nav>
<h1>{% trans "Set Password" %}</h1>
<form method="POST" action="{% url 'account_set_password' %}" class="password_set">
{% csrf_token %}
{{ form | crispy }}
<input type="submit" class="btn btn-primary" name="action" value="{% trans 'Set Password' %}"/>
</form>
{% endblock %}

View File

@ -14,7 +14,7 @@
</div>
<div class="row">
<div class="col-6 offset-3">
<div class="col-sm-12 col-lg-6 col-md-6 offset-lg-3 offset-md-3">
<hr>
<form method="post">
{% csrf_token %}
@ -25,6 +25,9 @@
<div class="form-group">
{{ form.email |as_crispy_field }}
</div>
<div class="form-group">
{{ form.email2 |as_crispy_field }}
</div>
<div class="form-group">
{{ form.password1 |as_crispy_field }}
</div>

View File

@ -67,9 +67,9 @@
</button>
{% if not request.user.is_authenticated or request.user.userpreference.theme == request.user.userpreference.TANDOOR %}
<a class="navbar-brand p-0 me-2 justify-content-center" href="/" aria-label="Tandoor">
<img class="brand-icon" src="{% static 'assets/brand_logo.png' %}" alt="" style="height: 5vh;">
</a>
<a class="navbar-brand p-0 me-2 justify-content-center" href="/" aria-label="Tandoor">
<img class="brand-icon" src="{% static 'assets/brand_logo.png' %}" alt="" style="height: 5vh;">
</a>
{% endif %}
<div class="collapse navbar-collapse" id="navbarText">
<ul class="navbar-nav mr-auto">
@ -140,7 +140,7 @@
{% page_help request.resolver_match.url_name as help_button %}
{% if help_button %}{{ help_button|safe }}{% endif %}
<li class="nav-item dropdown {% if request.resolver_match.url_name in 'view_settings,view_history,view_system,docs_markdown' %}active{% endif %}">
<li class="nav-item dropdown {% if request.resolver_match.url_name in 'view_space,view_settings,view_history,view_system,docs_markdown' %}active{% endif %}">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false"><i
class="fas fa-user-alt"></i> {{ user.get_user_name }}
@ -152,12 +152,18 @@
<a class="dropdown-item" href="{% url 'view_history' %}"><i
class="fas fa-history"></i> {% trans 'History' %}</a>
{% if request.user == request.space.created_by %}
<a class="dropdown-item" href="{% url 'view_space' %}"><i class="fas fa-server fa-fw"></i> {% trans 'Space Settings' %}</a>
<a class="dropdown-item" href="{% url 'view_space' %}"><i
class="fas fa-server fa-fw"></i> {% trans 'Space Settings' %}</a>
{% endif %}
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="{% url 'view_system' %}"><i
class="fas fa-server fa-fw"></i> {% trans 'System' %}</a>
<a class="dropdown-item" href="{% url 'admin:index' %}"><i
class="fas fa-user-shield fa-fw"></i> {% trans 'Admin' %}</a>
{% if user.is_superuser %}
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="{% url 'view_system' %}"><i
class="fas fa-server fa-fw"></i> {% trans 'System' %}</a>
<a class="dropdown-item" href="{% url 'list_keyword' %}"><i
class="fas fa-tags"></i> {% trans 'Keywords' %}</a>
<a class="dropdown-item" href="{% url 'admin:index' %}"><i
class="fas fa-user-shield fa-fw"></i> {% trans 'Admin' %}</a>
{% endif %}
@ -192,10 +198,7 @@
</div>
{% endif %}
<br/>
<br/>
<div class="container-fluid" id="id_base_container">
<div class="container-fluid mt-2 mt-md-5 mt-xl-5 mt-lg-5" id="id_base_container">
<div class="row">
<div class="col-xl-2 d-none d-xl-block">
{% block content_xl_left %}
@ -236,9 +239,11 @@
{% endblock script %}
<script type="application/javascript">
localStorage.setItem('SCRIPT_NAME', "{% base_path request 'script' %}")
localStorage.setItem('BASE_PATH', "{% base_path request 'base' %}")
window.addEventListener("load", () => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("{% url 'service_worker' %}", {scope: '/'}).then(function (reg) {
navigator.serviceWorker.register("{% url 'service_worker' %}", {scope: "{% base_path request 'base' %}" + '/'}).then(function (reg) {
}).catch(function (err) {
console.warn('Error whilst registering service worker', err);

View File

@ -0,0 +1,39 @@
{% extends "base.html" %}
{% load render_bundle from webpack_loader %}
{% load static %}
{% load i18n %}
{% load l10n %}
{% block title %}{% trans 'Files' %}{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{% static 'css/vue-multiselect-bs4.min.css' %}">
{% endblock %}
{% block content %}
<div id="app" >
<user-file-view></user-file-view>
</div>
{% endblock %}
{% block script %}
{% if debug %}
<script src="{% url 'js_reverse' %}"></script>
{% else %}
<script src="{% static 'django_js_reverse/reverse.js' %}"></script>
{% endif %}
<script type="application/javascript">
window.CUSTOM_LOCALE = '{{ request.LANGUAGE_CODE }}'
window.CURRENT_FILE_SIZE_MB = {{ current_file_size_mb|unlocalize }}
window.MAX_FILE_SIZE_MB = {{ max_file_size_mb|unlocalize }}
</script>
{% render_bundle 'user_file_view' %}
{% endblock %}

View File

@ -67,7 +67,7 @@
<img src="{% if recipe.image %}{{ recipe.image.url }}{% endif %}" id="id_image"
class="img img-fluid img-responsive"
style="max-height: 20vh">
<input type="file" @change="imageChanged">
<input class="mt-2" type="file" @change="imageChanged">
</div>
<div class="col-md-6">
<label for="id_name"> {% trans 'Preparation Time' %}</label>
@ -201,17 +201,40 @@
<select class="form-control" id="id_type" v-model="step.type">
<option value="TEXT">{% trans 'Text' %}</option>
<option value="TIME">{% trans 'Time' %}</option>
<option value="FILE">{% trans 'File' %}</option>
</select>
</div>
</div>
<div class="row" style="margin-top: 12px">
<div class="col-md-12">
<div class="col-md-3">
<label :for="'id_step_' + step.id + '_time'">{% trans 'Step time in Minutes' %}</label>
<input class="form-control" v-model="step.time"
:id="'id_step_' + step.id + '_time'">
</div>
<div class="col-md-9">
<label :for="'id_step_' + step.id + '_file'">{% trans 'File' %}</label>
<multiselect
v-tabindex
ref="file"
v-model="step.file"
:options="files"
:close-on-select="true"
:clear-on-select="true"
:allow-empty="true"
:preserve-search="true"
placeholder="{% trans 'Select File' %}"
select-label="{% trans 'Select' %}"
:id="'id_step_' + step.id + '_file'"
label="name"
track-by="name"
:multiple="false"
:loading="files_loading"
@search-change="searchFiles">
</multiselect>
</div>
</div>
<template v-if="step.type == 'TEXT'">
@ -378,7 +401,8 @@
<label :for="'id_instruction_' + step.id">{% trans 'Instructions' %}</label>
<b-form-textarea class="form-control" rows="2" max-rows="20" v-model="step.instruction"
:id="'id_instruction_' + step.id"></b-form-textarea>
<small class="text-muted">{% trans 'You can use markdown to format this field. See the <a href="/docs/markdown/">docs here</a>' %}</small>
{% markdown_link as markdown_link %}
<small class="text-muted">{{ markdown_link|safe }}</small>
</div>
</div>
</div>
@ -498,6 +522,8 @@
foods_loading: false,
units: [],
units_loading: false,
files: [],
files_loading: false,
message: '',
},
directives: {
@ -523,6 +549,7 @@
this.searchUnits('')
this.searchFoods('')
this.searchKeywords('')
this.searchFiles('')
this._keyListener = function (e) {
if (e.code === "Space" && e.ctrlKey) {
@ -572,6 +599,7 @@
this.sortSteps()
for (let s of this.recipe.steps) {
this.sortIngredients(s)
}
this.$http.put("{% url 'api:recipe-detail' recipe.pk %}", this.recipe,
{}).then((response) => {
@ -606,8 +634,6 @@
}
reader.readAsDataURL(event.target.files[0]);
}
},
addStep: function () { //TODO see if default can be generated from options request
this.recipe.steps.push(
@ -678,13 +704,24 @@
searchKeywords: function (query) {
this.keywords_loading = true
this.$http.get("{% url 'api:keyword-list' %}" + '?query=' + query + '&limit=10').then((response) => {
this.keywords = response.data;
console.log(response.data)
this.keywords = response.data.results;
this.keywords_loading = false
}).catch((err) => {
console.log(err)
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
})
},
searchFiles: function (query) {
this.files_loading = true
this.$http.get("{% url 'api:userfile-list' %}" + '?query=' + query + '&limit=10').then((response) => {
this.files = response.data
this.files_loading = false
}).catch((err) => {
console.log(err)
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
})
},
searchUnits: function (query) {
this.units_loading = true
this.$http.get("{% url 'api:unit-list' %}" + '?query=' + query + '&limit=10').then((response) => {

View File

@ -12,13 +12,16 @@
<h3>{% trans 'Delete' %} {{ title }}</h3>
<form action="." method="post">
{% csrf_token %}
<div class="alert alert-danger" role="alert">
{% blocktrans %}Are you sure you want to delete the {{ title }}: <b>{{ object }}</b> {% endblocktrans %}
</div>
{{ form|crispy }}
<button class="btn btn-success" type="submit"><i class="fas fa-trash-alt"></i> {% trans 'Confirm' %}</button>
<button class="btn btn-success" type="submit" href="{{ success_url }}"><i class="fas fa-trash-alt"></i> {% trans 'Confirm' %}</button>
<a href="javascript:history.back()" class="btn btn-danger"><i class="fas fa-undo-alt"></i> {% trans 'Cancel' %}</a>
</form>
{% endblock %}

View File

@ -20,11 +20,13 @@
<form action="." method="post">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-success" type="submit"><i class="fas fa-save"></i> {% trans 'Save' %}</button>
<button href="{{ view.get_success_url }}" class="btn btn-success" type="submit"><i class="fas fa-save"></i> {% trans 'Save' %}</button>
{% delete_url form.instance|get_class form.instance.pk as delete_view_url %}
{% if delete_view_url %}
<a href="{{ delete_view_url }}"
class="btn btn-danger"><i class="fas fa-trash-alt"></i> {% trans 'Delete' %}</a>
{% if not object.numchild or object.numchild == 0 %}
<a href="{{ delete_view_url }}"
class="btn btn-danger"><i class="fas fa-trash-alt"></i> {% trans 'Delete' %}</a>
{% endif %}
{% endif %}
{% if view_url %}
<a href="{{ view_url }}" class="btn btn-info"><i class="far fa-eye"></i> {% trans 'View' %}</a>

View File

@ -0,0 +1,31 @@
{% extends "base.html" %}
{% load render_bundle from webpack_loader %}
{% load static %}
{% load i18n %}
{% load l10n %}
{% block title %}{% trans 'Keywords' %}{% endblock %}
{% block content_fluid %}
<div id="app" >
<keyword-list-view></keyword-list-view>
</div>
{% endblock %}
{% block script %}
{% if debug %}
<script src="{% url 'js_reverse' %}"></script>
{% else %}
<script src="{% static 'django_js_reverse/reverse.js' %}"></script>
{% endif %}
<script type="application/javascript">
window.IMAGE_PLACEHOLDER = "{% static 'assets/recipe_no_image.svg' %}"
</script>
{% render_bundle 'keyword_list_view' %}
{% endblock %}

View File

@ -701,10 +701,10 @@
let first = true
for (let se of this.shopping_list) {
if (first) {
url += `?r=[${se.recipe},${se.servings}]`
url += `?r=[${se.recipe.id},${se.servings}]`
first = false
} else {
url += `&r=[${se.recipe},${se.servings}]`
url += `&r=[${se.recipe.id},${se.servings}]`
}
}
return url

View File

@ -45,7 +45,7 @@
<span class="badge badge-pill badge-light">{{ x }}</span>
{% endfor %}
</p>
<p class="card-text"><small class="text-muted">
<p class="card-text">
{% if row.cells.working_time != 0 %}
<span class="badge badge-secondary"><i
class="fas fa-user-clock"></i> {% trans 'Preparation time ca.' %} {{ row.cells.working_time }} min </span>
@ -63,7 +63,7 @@
{% if last_cooked %}
<span class="badge badge-primary">{% trans 'Last cooked' %} {{ last_cooked|date }}</span>
{% endif %}
</small></p>
</p>
</div>
<div>
<div class="dropdown">

View File

@ -12,9 +12,11 @@
{% block content %}
<h3>
{% trans 'Settings' %}
</h3>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'view_settings' %}">{% trans 'Settings' %}</a></li>
</ol>
</nav>
<!-- Nav tabs -->
<ul class="nav nav-tabs" id="myTab" role="tablist" style="margin-bottom: 2vh">
@ -56,23 +58,12 @@
class="fas fa-save"></i> {% trans 'Save' %}</button>
</form>
<h4>{% trans 'Password Settings' %}</h4>
<form action="." method="post">
{% csrf_token %}
{{ password_form|crispy }}
<button class="btn btn-success" type="submit" name="password_form"><i
class="fas fa-save"></i> {% trans 'Save' %}</button>
</form>
<h4>{% trans 'Account Settings' %}</h4>
<h4>{% trans 'Email Settings' %}</h4>
<a href="{% url 'account_email'%}" class="btn btn-primary">{% trans 'Emails' %}</a>
<a href="{% url 'account_change_password'%}" class="btn btn-primary">{% trans 'Password' %}</a>
<a href="{% url 'account_email'%}" class="btn btn-primary">{% trans 'Manage Email Settings' %}</a>
<br/><br/>
<h4>{% trans 'Social' %}</h4>
<a href="{% url 'socialaccount_connections' %}" class="btn btn-primary">{% trans 'Manage Social Accounts' %}</a>
<a href="{% url 'socialaccount_connections' %}" class="btn btn-primary">{% trans 'Social' %}</a>
<br/>
<br/>
<br/>

View File

@ -4,6 +4,14 @@
{% block head_title %}{% trans "Account Connections" %}{% endblock %}
{% block content %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'view_settings' %}">{% trans 'Settings' %}</a></li>
<li class="breadcrumb-item active" aria-current="page">{% trans 'Social' %}</li>
</ol>
</nav>
<h3>{% trans "Account Connections" %}</h3>
{% if form.accounts %}
@ -46,7 +54,7 @@
<h4>{% trans 'Add a 3rd Party Account' %}</h4>
<ul class="socialaccount_providers">
<ul class="socialaccount_providers list-unstyled">
{% include "socialaccount/snippets/provider_list.html" with process="connect" %}
</ul>

View File

@ -0,0 +1,60 @@
{% extends "base.html" %}
{% load crispy_forms_filters %}
{% load i18n %}
{% block head_title %}{% trans "Signup" %}{% endblock %}
{% block content %}
<h1>{% trans "Sign Up" %}</h1>
<p>{% blocktrans with provider_name=account.get_provider.name site_name=site.name %}You are about to use your
{{ provider_name }} account to login to
{{ site_name }}. As a final step, please complete the following form:{% endblocktrans %}</p>
<form class="signup" id="signup_form" method="post" action="{% url 'socialaccount_signup' %}">
{% csrf_token %}
{% if redirect_field_value %}
<input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}"/>
{% endif %}
<div class="form-group">
{{ form.username |as_crispy_field }}
</div>
<div class="form-group">
{{ form.email |as_crispy_field }}
</div>
<div class="form-group">
{{ form.email2 |as_crispy_field }}
</div>
{% if TERMS_URL != '' or PRIVACY_URL != '' %}
<div class="form-group">
{{ form.terms |as_crispy_field }}
<small>
{% trans 'I accept the follwoing' %}
{% if TERMS_URL != '' %}
<a href="{{ TERMS_URL }}" target="_blank"
rel="noreferrer nofollow">{% trans 'Terms and Conditions' %}</a>
{% endif %}
{% if TERMS_URL != '' or PRIVACY_URL != '' %}
{% trans 'and' %}
{% endif %}
{% if PRIVACY_URL != '' %}
<a href="{{ PRIVACY_URL }}" target="_blank"
rel="noreferrer nofollow">{% trans 'Privacy Policy' %}</a>
{% endif %}
</small>
</div>
{% endif %}
{% if CAPTCHA_ENABLED %}
<div class="form-group">
{{ form.captcha.errors }}
{{ form.captcha }}
</div>
{% endif %}
<button type="submit" class="btn btn-primary">{% trans "Sign Up" %} &raquo;</button>
</form>
{% endblock %}

View File

@ -0,0 +1,133 @@
{% load i18n %}
{% load socialaccount %}
{% get_providers as socialaccount_providers %}
{% for provider in socialaccount_providers %}
{% if provider.id == "openid" %}
{% for brand in provider.get_brands %}
<li>
<a title="{{ brand.name }}"
class="socialaccount_provider {{ provider.id }} {{ brand.id }}"
href="{% provider_login_url provider.id openid=brand.openid_url process=process %}"
>{{ brand.name }}</a>
</li>
{% endfor %}
{% endif %}
<li CLASS="mb-1">
{% if provider.id == 'discord' %}
<a title="{{ provider.name }}"
class="socialaccount_provider {{ provider.id }}"
href="{% provider_login_url provider.id process=process scope=scope auth_params=auth_params %}">
<button class="btn discord-login-button btn-social"><i
class="fab fa-discord"></i> {% trans 'Sign in using' %} Discord
</button>
</a>
{% elif provider.id == 'github' %}
<a title="{{ provider.name }}"
class="socialaccount_provider {{ provider.id }}"
href="{% provider_login_url provider.id process=process scope=scope auth_params=auth_params %}">
<button class="btn btn-social btn-github"><i
class="fab fa-github"></i> {% trans 'Sign in using' %} Github
</button>
</a>
{% elif provider.id == 'reddit' %}
<a title="{{ provider.name }}"
class="socialaccount_provider {{ provider.id }}"
href="{% provider_login_url provider.id process=process scope=scope auth_params=auth_params %}">
<button class="btn btn-social btn-reddit"><i
class="fab fa-reddit"></i> {% trans 'Sign in using' %} Reddit
</button>
</a>
{% elif provider.id == 'twitter' %}
<a title="{{ provider.name }}"
class="socialaccount_provider {{ provider.id }}"
href="{% provider_login_url provider.id process=process scope=scope auth_params=auth_params %}">
<button class="btn btn-social btn-twitter"><i
class="fab fa-twitter"></i> {% trans 'Sign in using' %} Twitter
</button>
</a>
{% elif provider.id == 'dropbox' %}
<a title="{{ provider.name }}"
class="socialaccount_provider {{ provider.id }}"
href="{% provider_login_url provider.id process=process scope=scope auth_params=auth_params %}">
<button class="btn btn-social btn-dropbox"><i
class="fab fa-dropbox"></i> {% trans 'Sign in using' %} Dropbox
</button>
</a>
{% elif provider.id == 'google' %}
<a title="{{ provider.name }}"
class="socialaccount_provider {{ provider.id }}"
href="{% provider_login_url provider.id process=process scope=scope auth_params=auth_params %}">
<button class="btn btn-social btn-google"><i
class="fab fa-google"></i> {% trans 'Sign in using' %} Google
</button>
</a>
{% elif provider.id == 'facebook' %}
<a title="{{ provider.name }}"
class="socialaccount_provider {{ provider.id }}"
href="{% provider_login_url provider.id process=process scope=scope auth_params=auth_params %}">
<button class="btn btn-social btn-facebook"><i
class="fab fa-facebook"></i> {% trans 'Sign in using' %} Facebook
</button>
</a>
{% elif provider.id == 'instagram' %}
<a title="{{ provider.name }}"
class="socialaccount_provider {{ provider.id }}"
href="{% provider_login_url provider.id process=process scope=scope auth_params=auth_params %}">
<button class="btn btn-social btn-instagram"><i
class="fab fa-instagram"></i> {% trans 'Sign in using' %} Instagram
</button>
</a>
{% elif provider.id == 'flickr' %}
<a title="{{ provider.name }}"
class="socialaccount_provider {{ provider.id }}"
href="{% provider_login_url provider.id process=process scope=scope auth_params=auth_params %}">
<button class="btn btn-social btn-flickr"><i
class="fab fa-flickr"></i> {% trans 'Sign in using' %} Flickr
</button>
</a>
{% elif provider.id == 'apple' %}
<a title="{{ provider.name }}"
class="socialaccount_provider {{ provider.id }}"
href="{% provider_login_url provider.id process=process scope=scope auth_params=auth_params %}">
<button class="btn btn-social btn-apple"><i
class="fab fa-apple"></i> {% trans 'Sign in using' %} Apple
</button>
</a>
{% elif provider.id == 'pinterest' %}
<a title="{{ provider.name }}"
class="socialaccount_provider {{ provider.id }}"
href="{% provider_login_url provider.id process=process scope=scope auth_params=auth_params %}">
<button class="btn btn-social btn-pinterest"><i
class="fab fa-pinterest"></i> {% trans 'Sign in using' %} Pinterest
</button>
</a>
{% elif provider.id == 'windowslive' %}
<a title="{{ provider.name }}"
class="socialaccount_provider {{ provider.id }}"
href="{% provider_login_url provider.id process=process scope=scope auth_params=auth_params %}">
<button class="btn btn-social btn-microsoft"><i
class="fab fa-microsoft"></i> {% trans 'Sign in using' %} Microsoft Live
</button>
</a>
{% elif provider.id == 'yahoo' %}
<a title="{{ provider.name }}"
class="socialaccount_provider {{ provider.id }}"
href="{% provider_login_url provider.id process=process scope=scope auth_params=auth_params %}">
<button class="btn btn-social btn-yahoo"><i
class="fab fa-yahoo"></i> {% trans 'Sign in using' %} Yahoo
</button>
</a>
{% else %}
<a title="{{ provider.name }}"
class="socialaccount_provider {{ provider.id }}"
href="{% provider_login_url provider.id process=process scope=scope auth_params=auth_params %}">
<button class="btn btn-social btn-success"><i
class="fas fa-sign-in-alt"></i> {% trans 'Sign in using' %} {{ provider.name }}
</button>
</a>
{% endif %}
</li>
{% endfor %}

View File

@ -1,4 +1,5 @@
{% extends "base.html" %}
{% load django_tables2 %}
{% load crispy_forms_filters %}
{% load static %}
{% load i18n %}
@ -13,7 +14,15 @@
{% block content %}
<h3>{{ request.space.name }}</h3>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'view_space' %}">{% trans 'Space Settings' %}</a></li>
</ol>
</nav>
<h3><span class="text-muted">{% trans 'Space:' %}</span> {{ request.space.name }} <small>{% if HOSTED %}
<a href="https://tandoor.dev/manage">{% trans 'Manage Subscription' %}</a>{% endif %}</small></h3>
<br/>
<div class="row">
@ -90,18 +99,19 @@
</td>
<td>
{% if u.user != request.user %}
<div class="input-group mb-3">
<select v-model="users['{{ u.pk }}']" class="custom-select form-control" style="height: 44px">
<option>{% trans 'admin' %}</option>
<option>{% trans 'user' %}</option>
<option>{% trans 'guest' %}</option>
<option>{% trans 'remove' %}</option>
</select>
<span class="input-group-append">
<div class="input-group mb-3">
<select v-model="users['{{ u.pk }}']" class="custom-select form-control"
style="height: 44px">
<option value="admin">{% trans 'admin' %}</option>
<option value="user">{% trans 'user' %}</option>
<option value="guest">{% trans 'guest' %}</option>
<option value="remove">{% trans 'remove' %}</option>
</select>
<span class="input-group-append">
<a class="btn btn-warning"
:href="editUserUrl({{ u.pk }}, {{ u.space.pk }})" >{% trans 'Update' %}</a>
:href="editUserUrl({{ u.pk }}, {{ u.space.pk }})">{% trans 'Update' %}</a>
</span>
</div>
</div>
{% else %}
{% trans 'You cannot edit yourself.' %}
{% endif %}
@ -115,6 +125,17 @@
</div>
</div>
<div class="row">
<div class="col col-md-12">
<h4>{% trans 'Invite Links' %}</h4>
{% render_table invite_links %}
</div>
</div>
<br/>
<br/>
<br/>
{% endblock %}
{% block script %}

Some files were not shown because too many files have changed in this diff Show More