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:
commit
5c0bdce37d
@ -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
2
.gitignore
vendored
@ -79,3 +79,5 @@ postgresql/
|
||||
/docker-compose.override.yml
|
||||
vue/node_modules
|
||||
.vscode/
|
||||
vue/yarn.lock
|
||||
vetur.config.js
|
||||
|
15
README.md
15
README.md
@ -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>
|
||||
|
||||

|
||||
|
@ -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)
|
||||
|
@ -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(
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
45
cookbook/helper/image_processing.py
Normal file
45
cookbook/helper/image_processing.py
Normal 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
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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'))
|
||||
|
@ -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)
|
||||
|
@ -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():
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
71
cookbook/integration/openeats.py
Normal file
71
cookbook/integration/openeats.py
Normal 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')
|
@ -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
|
||||
|
@ -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:
|
||||
|
80
cookbook/integration/recipekeeper.py
Normal file
80
cookbook/integration/recipekeeper.py
Normal 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')
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
@ -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),
|
||||
),
|
||||
]
|
@ -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),
|
||||
),
|
||||
]
|
@ -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 = [
|
||||
|
17
cookbook/migrations/0127_remove_invitelink_username.py
Normal file
17
cookbook/migrations/0127_remove_invitelink_username.py
Normal 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',
|
||||
),
|
||||
]
|
30
cookbook/migrations/0128_userfile.py
Normal file
30
cookbook/migrations/0128_userfile.py
Normal 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),
|
||||
),
|
||||
]
|
22
cookbook/migrations/0129_auto_20210608_1233.py
Normal file
22
cookbook/migrations/0129_auto_20210608_1233.py
Normal 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.'),
|
||||
),
|
||||
]
|
18
cookbook/migrations/0130_alter_userfile_file_size_kb.py
Normal file
18
cookbook/migrations/0130_alter_userfile_file_size_kb.py
Normal 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),
|
||||
),
|
||||
]
|
24
cookbook/migrations/0131_auto_20210608_1929.py
Normal file
24
cookbook/migrations/0131_auto_20210608_1929.py
Normal 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),
|
||||
),
|
||||
]
|
111
cookbook/migrations/0132_build_full_text_index.py
Normal file
111
cookbook/migrations/0132_build_full_text_index.py
Normal 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
|
||||
),
|
||||
]
|
18
cookbook/migrations/0132_sharelink_request_count.py
Normal file
18
cookbook/migrations/0132_sharelink_request_count.py
Normal 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),
|
||||
),
|
||||
]
|
23
cookbook/migrations/0133_create_searchfields.py
Normal file
23
cookbook/migrations/0133_create_searchfields.py
Normal 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
|
||||
),
|
||||
]
|
18
cookbook/migrations/0133_sharelink_abuse_blocked.py
Normal file
18
cookbook/migrations/0133_sharelink_abuse_blocked.py
Normal 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),
|
||||
),
|
||||
]
|
70
cookbook/migrations/0134_keyword_to_tree.py
Normal file
70
cookbook/migrations/0134_keyword_to_tree.py
Normal 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'),
|
||||
),
|
||||
]
|
18
cookbook/migrations/0134_space_allow_sharing.py
Normal file
18
cookbook/migrations/0134_space_allow_sharing.py
Normal 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),
|
||||
),
|
||||
]
|
29
cookbook/migrations/0135_auto_20210615_2210.py
Normal file
29
cookbook/migrations/0135_auto_20210615_2210.py
Normal 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'),
|
||||
),
|
||||
|
||||
]
|
23
cookbook/migrations/0136_auto_20210617_1343.py
Normal file
23
cookbook/migrations/0136_auto_20210617_1343.py
Normal 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),
|
||||
),
|
||||
]
|
34
cookbook/migrations/0137_auto_20210617_1501.py
Normal file
34
cookbook/migrations/0137_auto_20210617_1501.py
Normal 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),
|
||||
|
||||
]
|
31
cookbook/migrations/0138_auto_20210617_1602.py
Normal file
31
cookbook/migrations/0138_auto_20210617_1602.py
Normal 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'),
|
||||
),
|
||||
]
|
20
cookbook/migrations/0139_space_created_at.py
Normal file
20
cookbook/migrations/0139_space_created_at.py
Normal 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,
|
||||
),
|
||||
]
|
20
cookbook/migrations/0140_userpreference_created_at.py
Normal file
20
cookbook/migrations/0140_userpreference_created_at.py
Normal 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,
|
||||
),
|
||||
]
|
@ -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
86
cookbook/schemas.py
Normal 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
|
@ -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')
|
||||
|
1112
cookbook/static/css/app.min.css
vendored
1112
cookbook/static/css/app.min.css
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@ -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')
|
||||
|
24
cookbook/static/themes/tandoor.min.css
vendored
24
cookbook/static/themes/tandoor.min.css
vendored
@ -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 {
|
||||
|
1
cookbook/static/vue/css/keyword_list_view.css
Normal file
1
cookbook/static/vue/css/keyword_list_view.css
Normal 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
1
cookbook/static/vue/js/keyword_list_view.js
Normal file
1
cookbook/static/vue/js/keyword_list_view.js
Normal file
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
1
cookbook/static/vue/js/user_file_view.js
Normal file
1
cookbook/static/vue/js/user_file_view.js
Normal file
File diff suppressed because one or more lines are too long
1
cookbook/static/vue/keyword_list_view.html
Normal file
1
cookbook/static/vue/keyword_list_view.html
Normal 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>
|
1
cookbook/static/vue/user_file_view.html
Normal file
1
cookbook/static/vue/user_file_view.html
Normal 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>
|
@ -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',
|
||||
)
|
||||
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
||||
|
24
cookbook/templates/account/password_change.html
Normal file
24
cookbook/templates/account/password_change.html
Normal 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 %}
|
@ -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>
|
||||
|
23
cookbook/templates/account/password_set.html
Normal file
23
cookbook/templates/account/password_set.html
Normal 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 %}
|
@ -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>
|
||||
|
@ -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);
|
||||
|
39
cookbook/templates/files.html
Normal file
39
cookbook/templates/files.html
Normal 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 %}
|
@ -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) => {
|
||||
|
@ -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 %}
|
@ -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>
|
||||
|
31
cookbook/templates/generic/tree_template.html
Normal file
31
cookbook/templates/generic/tree_template.html
Normal 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 %}
|
@ -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
|
||||
|
@ -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">
|
||||
|
@ -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/>
|
||||
|
@ -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>
|
||||
|
||||
|
60
cookbook/templates/socialaccount/signup.html
Normal file
60
cookbook/templates/socialaccount/signup.html
Normal 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" %} »</button>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
133
cookbook/templates/socialaccount/snippets/provider_list.html
Normal file
133
cookbook/templates/socialaccount/snippets/provider_list.html
Normal 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 %}
|
@ -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
Loading…
Reference in New Issue
Block a user