reworked invite system

This commit is contained in:
vabene1111 2021-05-28 16:49:03 +02:00
parent a14e33973c
commit c8054349b2
17 changed files with 410 additions and 52 deletions

View File

@ -68,7 +68,8 @@ GUNICORN_MEDIA=0
# EMAIL_HOST_PASSWORD=
# EMAIL_USE_TLS=0
# EMAIL_USE_SSL=0
# ACCOUNT_EMAIL_SUBJECT_PREFIX
# DEFAULT_FROM_EMAIL= # email sender address (default 'webmaster@localhost')
# ACCOUNT_EMAIL_SUBJECT_PREFIX= # prefix used for account related emails (default "[Tandoor Recipes] ")
# allow authentication via reverse proxy (e.g. authelia), leave off if you dont know what you are doing
# see docs for more information https://vabene1111.github.io/recipes/features/authentication/

View File

@ -1,6 +1,8 @@
from django import forms
from django.core.exceptions import ValidationError
from django.forms import widgets
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
@ -42,10 +44,15 @@ class UserPreferenceForm(forms.ModelForm):
)
help_texts = {
'nav_color': _('Color of the top navigation bar. Not all colors work with all themes, just try them out!'), # noqa: E501
'nav_color': _('Color of the top navigation bar. Not all colors work with all themes, just try them out!'),
# noqa: E501
'default_unit': _('Default Unit to be used when inserting a new ingredient into a recipe.'), # noqa: E501
'use_fractions': _('Enables support for fractions in ingredient amounts (e.g. convert decimals to fractions automatically)'), # noqa: E501
'plan_share': _('Users with whom newly created meal plan/shopping list entries should be shared by default.'), # noqa: E501
'use_fractions': _(
'Enables support for fractions in ingredient amounts (e.g. convert decimals to fractions automatically)'),
# noqa: E501
'plan_share': _(
'Users with whom newly created meal plan/shopping list entries should be shared by default.'),
# noqa: E501
'show_recent': _('Show recently viewed recipes on search page.'), # noqa: E501
'ingredient_decimals': _('Number of decimals to round ingredients.'), # noqa: E501
'comments': _('If you want to be able to create and see comments underneath recipes.'), # noqa: E501
@ -69,7 +76,8 @@ class UserNameForm(forms.ModelForm):
fields = ('first_name', 'last_name')
help_texts = {
'first_name': _('Both fields are optional. If none are given the username will be displayed instead') # noqa: E501
'first_name': _('Both fields are optional. If none are given the username will be displayed instead')
# noqa: E501
}
@ -128,7 +136,9 @@ class ImportExportBase(forms.Form):
class ImportForm(ImportExportBase):
files = forms.FileField(required=True, widget=forms.ClearableFileInput(attrs={'multiple': True}))
duplicates = forms.BooleanField(help_text=_('To prevent duplicates recipes with the same name as existing ones are ignored. Check this box to import everything.'), required=False)
duplicates = forms.BooleanField(help_text=_(
'To prevent duplicates recipes with the same name as existing ones are ignored. Check this box to import everything.'),
required=False)
class ExportForm(ImportExportBase):
@ -251,7 +261,8 @@ class StorageForm(forms.ModelForm):
fields = ('name', 'method', 'username', 'password', 'token', 'url', 'path')
help_texts = {
'url': _('Leave empty for dropbox and enter only base url for nextcloud (<code>/remote.php/webdav/</code> is added automatically)'),
'url': _(
'Leave empty for dropbox and enter only base url for nextcloud (<code>/remote.php/webdav/</code> is added automatically)'),
}
@ -366,7 +377,8 @@ class MealPlanForm(forms.ModelForm):
help_texts = {
'shared': _('You can list default users to share recipes with in the settings.'), # noqa: E501
'note': _('You can use markdown to format this field. See the <a href="/docs/markdown/">docs here</a>') # noqa: E501
'note': _('You can use markdown to format this field. See the <a href="/docs/markdown/">docs here</a>')
# noqa: E501
}
widgets = {
@ -387,17 +399,50 @@ class InviteLinkForm(forms.ModelForm):
super().__init__(*args, **kwargs)
self.fields['space'].queryset = Space.objects.filter(created_by=user).all()
def clean_email(self):
email = self.cleaned_data['email']
with scopes_disabled():
if User.objects.filter(email=email).exists():
raise ValidationError(_('Email address already taken!'))
return email
def clean_username(self):
username = self.cleaned_data['username']
with scopes_disabled():
if 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', 'group', 'valid_until', 'space')
fields = ('username', 'email', 'group', 'valid_until', 'space')
help_texts = {
'username': _('A username is not required, if left blank the new user can choose one.') # noqa: E501
'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.')
}
field_classes = {
'space': SafeModelChoiceField,
}
class SpaceCreateForm(forms.Form):
prefix = 'create'
name = forms.CharField()
def clean_name(self):
name = self.cleaned_data['name']
with scopes_disabled():
if Space.objects.filter(name=name).exists():
raise ValidationError(_('Name already taken.'))
return name
class SpaceJoinForm(forms.Form):
prefix = 'join'
token = forms.CharField()
class UserCreateForm(forms.Form):
name = forms.CharField(label='Username')
password = forms.CharField(

View File

@ -16,7 +16,7 @@ class AllAuthCustomAdapter(DefaultAccountAdapter):
# disable password reset for now
def send_mail(self, template_prefix, email, context):
if settings.EMAIL_HOST != '':
if settings.EMAIL_HOST == '':
super(AllAuthCustomAdapter, self).send_mail(template_prefix, email, context)
else:
pass

View File

@ -16,6 +16,9 @@ class ScopeMiddleware:
with scopes_disabled():
return self.get_response(request)
if request.path.startswith('/signup/'):
return self.get_response(request)
with scopes_disabled():
if request.user.userpreference.space is None and not reverse('account_logout') in request.path:
return views.no_space(request)

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.3 on 2021-05-27 15:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0121_auto_20210518_1638'),
]
operations = [
migrations.AddField(
model_name='space',
name='allow_files',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='space',
name='max_users',
field=models.IntegerField(default=0),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.3 on 2021-05-28 12:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0122_auto_20210527_1712'),
]
operations = [
migrations.AddField(
model_name='invitelink',
name='email',
field=models.EmailField(blank=True, max_length=254),
),
]

View File

@ -62,6 +62,8 @@ class Space(models.Model):
created_by = models.ForeignKey(User, on_delete=models.PROTECT, null=True)
message = models.CharField(max_length=512, default='', blank=True)
max_recipes = models.IntegerField(default=0)
allow_files = models.BooleanField(default=True)
max_users = models.IntegerField(default=0)
def __str__(self):
return self.name
@ -607,6 +609,7 @@ def default_valid_until():
class InviteLink(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)
used_by = models.ForeignKey(

View File

@ -19,7 +19,7 @@
{% csrf_token %}
{{ form | crispy}}
<input type="submit" class="btn btn-primary" value="{% trans 'Reset My Password' %}" />
<a class="btn btn-primary" href="{% url 'account_signup' %}">{% trans "Sign In" %}</a>
<a class="btn btn-primary" href="{% url 'account_login' %}">{% trans "Sign In" %}</a>
<a class="btn btn-success" href="{% url 'account_signup' %}">{% trans "Sign Up" %}</a>
</form>

View File

@ -11,8 +11,9 @@
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-success" type="submit"><i class="fas fa-save"></i> {% trans 'Create User' %}
</button>
<button class="btn btn-success" type="submit"><i class="fas fa-save"></i> {% trans 'Create User' %}</button>
</form>
<p>{% trans 'Already have an account?' %} <a href="{% url 'account_login' %}">{% trans "Sign In" %}</a></p>
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% load i18n %}
{% block head_title %}{% trans "Sign Up Closed" %}{% endblock %}
{% block content %}
<h1>{% trans "Sign Up Closed" %}</h1>
<p>{% trans "We are sorry, but the sign up is currently closed." %}</p>
<a class="btn btn-primary" href="{% url 'account_login' %}">{% trans "Sign In" %}</a>
<a class="btn btn-secondary" href="{% url 'account_reset_password' %}">{% trans "Reset Password" %}</a>
{% endblock %}

View File

@ -144,6 +144,9 @@
class="fas fa-user-cog fa-fw"></i> {% trans 'Settings' %}</a>
<a class="dropdown-item" href="{% url 'view_history' %}"><i
class="fas fa-history"></i> {% trans 'History' %}</a>
{% if 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>
{% endif %}
{% if user.is_superuser %}
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="{% url 'view_system' %}"><i

View File

@ -1,4 +1,5 @@
{% extends "base.html" %}
{% load crispy_forms_filters %}
{% load static %}
{% load i18n %}
@ -9,10 +10,59 @@
<div style="text-align: center">
<h1 class="">{% trans 'No Space' %}</h1>
<br/>
<h3 class="">{% trans 'No Space' %}</h3>
<span>{% trans 'You are not a member of any space.' %} {% trans 'Please contact your administrator.' %}</span> <br/>
<div class="row">
<div class="col col-md-12">
{% trans 'Recipes, foods, shopping lists and more are organized in spaces of one or more people.' %}
{% trans 'You can either be invited into an existing space or create your own one.' %}
</div>
</div>
<div class="row" style="margin-top: 2vh">
<div class="col col-md-12">
<div class="card-group">
<div class="card">
<div class="card-header">
{% trans 'Join Space' %}
</div>
<div class="card-body">
<h5 class="card-title">{% trans 'Join an existing space.' %}</h5>
<p class="card-text">{% trans 'To join an existing space either enter your invite token or click on the invite link the space owner send you.' %}</p>
<form method="POST" action="{% url 'view_no_space' %}">
{% csrf_token %}
{{ join_form | crispy }}
<input type="submit" class="btn btn-primary" value="{% trans 'Join Space' %}"/>
</form>
</div>
</div>
<div class="card">
<div class="card-header">
{% trans 'Create Space' %}
</div>
<div class="card-body">
<h5 class="card-title">{% trans 'Create your own recipe space.' %}</h5>
<p class="card-text">{% trans 'Start your own recipe space and invite other users to it.' %}</p>
<form method="POST" action="{% url 'view_no_space' %}">
{% csrf_token %}
{{ create_form | crispy }}
<input type="submit" class="btn btn-primary" value="{% trans 'Create Space' %}"/>
</form>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,93 @@
{% extends "base.html" %}
{% load crispy_forms_filters %}
{% load static %}
{% load i18n %}
{% block title %}{% trans "Space Settings" %}{% endblock %}
{% block extra_head %}
{{ form.media }}
{% endblock %}
{% block content %}
<h3>{{ request.space.name }}</h3>
<br/>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">
{% trans 'Number of objects' %}
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">{% trans 'Recipes' %} : <span
class="badge badge-pill badge-info">{{ counts.recipes }} /
{% if request.space.max_recipes > 0 %}
{{ request.space.max_recipes }}{% else %}∞{% endif %}</span></li>
<li class="list-group-item">{% trans 'Keywords' %} : <span
class="badge badge-pill badge-info">{{ counts.keywords }}</span></li>
<li class="list-group-item">{% trans 'Units' %} : <span
class="badge badge-pill badge-info">{{ counts.units }}</span></li>
<li class="list-group-item">{% trans 'Ingredients' %} : <span
class="badge badge-pill badge-info">{{ counts.ingredients }}</span></li>
<li class="list-group-item">{% trans 'Recipe Imports' %} : <span
class="badge badge-pill badge-info">{{ counts.recipe_import }}</span></li>
</ul>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
{% trans 'Objects stats' %}
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">{% trans 'Recipes without Keywords' %} : <span
class="badge badge-pill badge-info">{{ counts.recipes_no_keyword }}</span></li>
<li class="list-group-item">{% trans 'External Recipes' %} : <span
class="badge badge-pill badge-info">{{ counts.recipes_external }}</span></li>
<li class="list-group-item">{% trans 'Internal Recipes' %} : <span
class="badge badge-pill badge-info">{{ counts.recipes_internal }}</span></li>
<li class="list-group-item">{% trans 'Comments' %} : <span
class="badge badge-pill badge-info">{{ counts.comments }}</span></li>
</ul>
</div>
</div>
</div>
<br/>
<br/>
<div class="row">
<div class="col col-md-12">
<h4>{% trans 'Members' %} <small class="text-muted">{{ space_users|length }}/
{% if request.space.max_users > 0 %}
{{ request.space.max_users }}{% else %}∞{% endif %}</small>
</h4><a href="{% url 'new_invite_link' %}">{% trans 'Invite User' %}</a>
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% if space_users %}
<table class="table table-bordered">
{% for u in space_users %}
<tr>
<td>
{{ u.user.username }}
</td>
<td>
<a class="btn btn-success btn-sm" href="">{% trans 'Remove' %}</a>
</td>
</tr>
{% endfor %}
</table>
{% else %}
<p>{% trans 'There are no members in your space yet!' %}</p>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -41,6 +41,7 @@ router.register(r'bookmarklet-import', api.BookmarkletImportViewSet)
urlpatterns = [
path('', views.index, name='index'),
path('setup/', views.setup, name='view_setup'),
path('space/', views.space, name='view_space'),
path('no-group', views.no_groups, name='view_no_group'),
path('no-space', views.no_space, name='view_no_space'),
path('no-perm', views.no_perm, name='view_no_perm'),

View File

@ -1,7 +1,11 @@
import re
from datetime import datetime
from datetime import datetime, timedelta
from html import escape
from smtplib import SMTPException
from django.contrib import messages
from django.contrib.auth.models import Group
from django.core.mail import send_mail, BadHeaderError
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse, reverse_lazy
@ -154,7 +158,8 @@ class MealPlanCreate(GroupRequiredMixin, CreateView, SpaceFormMixing):
def get_form(self, form_class=None):
form = self.form_class(**self.get_form_kwargs())
form.fields['meal_type'].queryset = MealType.objects.filter(created_by=self.request.user, space=self.request.space).all()
form.fields['meal_type'].queryset = MealType.objects.filter(created_by=self.request.user,
space=self.request.space).all()
return form
def get_initial(self):
@ -207,6 +212,32 @@ class InviteLinkCreate(GroupRequiredMixin, CreateView):
obj.created_by = self.request.user
obj.space = self.request.space
obj.save()
if obj.email:
try:
if InviteLink.objects.filter(space=self.request.space, created_at__gte=datetime.now() - timedelta(hours=4)).count() < 20:
message = _('Hello') + '!\n\n' + _('You have been invited by ') + escape(self.request.user.username)
message += _(' to join their Tandoor Recipes space ') + escape(self.request.space.name) + '.\n\n'
message += _('Click the following link to activate your account: ') + self.request.build_absolute_uri(reverse('view_signup', args=[str(obj.uuid)])) + '\n\n'
message += _('If the link does not work use the following code to manually join the space: ') + str(obj.uuid) + '\n\n'
message += _('The invitation is valid until ') + obj.valid_until + '\n\n'
message += _('Tandoor Recipes is an Open Source recipe manager. Check it out on GitHub ') + 'https://github.com/vabene1111/recipes/'
send_mail(
_('Tandoor Recipes Invite'),
message,
None,
[obj.email],
fail_silently=False,
)
messages.add_message(self.request, messages.SUCCESS,
_('Invite link successfully send to user.'))
else:
messages.add_message(self.request, messages.ERROR,
_('You have send to many emails, please share the link manually or wait a few hours.'))
except (SMTPException, BadHeaderError, TimeoutError):
messages.add_message(self.request, messages.ERROR, _('Email to user could not be send, please share link manually.'))
return HttpResponseRedirect(reverse('list_invite_link'))
def get_context_data(self, **kwargs):
@ -218,3 +249,9 @@ class InviteLinkCreate(GroupRequiredMixin, CreateView):
kwargs = super().get_form_kwargs()
kwargs.update({'user': self.request.user})
return kwargs
def get_initial(self):
return dict(
space=self.request.space,
group=Group.objects.get(name='user')
)

View File

@ -6,6 +6,7 @@ from uuid import UUID
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth.models import Group
from django.contrib.auth.password_validation import validate_password
@ -24,12 +25,14 @@ from rest_framework.authtoken.models import Token
from cookbook.filters import RecipeFilter
from cookbook.forms import (CommentForm, Recipe, RecipeBookEntryForm, User,
UserCreateForm, UserNameForm, UserPreference,
UserPreferenceForm)
UserPreferenceForm, SpaceJoinForm, SpaceCreateForm)
from cookbook.helper.permission_helper import group_required, share_link_valid, has_group_permission
from cookbook.models import (Comment, CookLog, InviteLink, MealPlan,
RecipeBook, RecipeBookEntry, ViewLog, ShoppingList, Space)
RecipeBook, RecipeBookEntry, ViewLog, ShoppingList, Space, Keyword, RecipeImport, Unit,
Food)
from cookbook.tables import (CookLogTable, RecipeTable, RecipeTableSmall,
ViewLogTable)
from cookbook.views.data import Object
from recipes.settings import DEMO
from recipes.version import BUILD_REF, VERSION_NUMBER
@ -99,13 +102,38 @@ def no_groups(request):
return render(request, 'no_groups_info.html')
@login_required
def no_space(request):
if settings.SOCIAL_DEFAULT_ACCESS:
request.user.userpreference.space = Space.objects.first()
request.user.userpreference.save()
request.user.groups.add(Group.objects.get(name=settings.SOCIAL_DEFAULT_GROUP))
if request.user.userpreference.space:
return HttpResponseRedirect(reverse('index'))
return render(request, 'no_space_info.html')
if request.POST:
create_form = SpaceCreateForm(request.POST, prefix='create')
join_form = SpaceJoinForm(request.POST, prefix='join')
if create_form.is_valid():
created_space = Space.objects.create(name=create_form.cleaned_data['name'], created_by=request.user)
request.user.userpreference.space = created_space
request.user.userpreference.save()
request.user.groups.add(Group.objects.filter(name='admin').get())
messages.add_message(request, messages.SUCCESS, _('You have successfully created your own recipe space. Start by adding some recipes or invite other people to join you.'))
return HttpResponseRedirect(reverse('index'))
if join_form.is_valid():
return HttpResponseRedirect(reverse('view_signup', args=[join_form.cleaned_data['token']]))
else:
if settings.SOCIAL_DEFAULT_ACCESS:
request.user.userpreference.space = Space.objects.first()
request.user.userpreference.save()
request.user.groups.add(Group.objects.get(name=settings.SOCIAL_DEFAULT_GROUP))
return HttpResponseRedirect(reverse('index'))
if 'signup_token' in request.session:
return HttpResponseRedirect(reverse('view_signup', args=[request.session.pop('signup_token', '')]))
create_form = SpaceCreateForm()
join_form = SpaceJoinForm()
return render(request, 'no_space_info.html', {'create_form': create_form, 'join_form': join_form})
def no_perm(request):
@ -390,36 +418,53 @@ def signup(request, token):
return HttpResponseRedirect(reverse('index'))
if link := InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, uuid=token).first():
if request.method == 'POST':
updated_request = request.POST.copy()
if link.username != '':
updated_request.update({'name': link.username})
if request.user.is_authenticated:
if request.user.userpreference.space:
messages.add_message(request, messages.WARNING, _('You are already member of a space and therefore cannot join this one.'))
return HttpResponseRedirect(reverse('index'))
form = UserCreateForm(updated_request)
link.used_by = request.user
link.save()
request.user.groups.add(link.group)
if form.is_valid():
if form.cleaned_data['password'] != form.cleaned_data['password_confirm']: # noqa: E501
form.add_error('password', _('Passwords dont match!'))
else:
user = User(username=form.cleaned_data['name'], )
try:
validate_password(form.cleaned_data['password'], user=user)
user.set_password(form.cleaned_data['password'])
user.save()
messages.add_message(request, messages.SUCCESS, _('User has been created, please login!'))
request.user.userpreference.space = link.space
request.user.userpreference.save()
link.used_by = user
link.save()
user.groups.add(link.group)
user.userpreference.space = link.space
user.userpreference.save()
return HttpResponseRedirect(reverse('account_login'))
except ValidationError as e:
for m in e:
form.add_error('password', m)
messages.add_message(request, messages.SUCCESS, _('Successfully joined space.'))
return HttpResponseRedirect(reverse('index'))
else:
form = UserCreateForm()
request.session['signup_token'] = token
if request.method == 'POST':
updated_request = request.POST.copy()
if link.username != '':
updated_request.update({'name': link.username})
form = UserCreateForm(updated_request)
if form.is_valid():
if form.cleaned_data['password'] != form.cleaned_data['password_confirm']: # noqa: E501
form.add_error('password', _('Passwords dont match!'))
else:
user = User(username=form.cleaned_data['name'], )
try:
validate_password(form.cleaned_data['password'], user=user)
user.set_password(form.cleaned_data['password'])
user.save()
messages.add_message(request, messages.SUCCESS, _('User has been created, please login!'))
link.used_by = user
link.save()
user.groups.add(link.group)
user.userpreference.space = link.space
user.userpreference.save()
return HttpResponseRedirect(reverse('account_login'))
except ValidationError as e:
for m in e:
form.add_error('password', m)
else:
form = UserCreateForm()
if link.username != '':
form.fields['name'].initial = link.username
@ -430,6 +475,26 @@ def signup(request, token):
return HttpResponseRedirect(reverse('index'))
@group_required('admin')
def space(request):
space_users = UserPreference.objects.filter(space=request.space).all()
counts = Object()
counts.recipes = Recipe.objects.filter(space=request.space).count()
counts.keywords = Keyword.objects.filter(space=request.space).count()
counts.recipe_import = RecipeImport.objects.filter(space=request.space).count()
counts.units = Unit.objects.filter(space=request.space).count()
counts.ingredients = Food.objects.filter(space=request.space).count()
counts.comments = Comment.objects.filter(recipe__space=request.space).count()
counts.recipes_internal = Recipe.objects.filter(internal=True, space=request.space).count()
counts.recipes_external = counts.recipes - counts.recipes_internal
counts.recipes_no_keyword = Recipe.objects.filter(keywords=None, space=request.space).count()
return render(request, 'space.html', {'space_users': space_users, 'counts': counts})
def markdown_info(request):
return render(request, 'markdown_info.html', {})

View File

@ -330,4 +330,5 @@ EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '')
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '')
EMAIL_USE_TLS = bool(int(os.getenv('EMAIL_USE_TLS', False)))
EMAIL_USE_SSL = bool(int(os.getenv('EMAIL_USE_SSL', False)))
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'webmaster@localhost')
ACCOUNT_EMAIL_SUBJECT_PREFIX = os.getenv('ACCOUNT_EMAIL_SUBJECT_PREFIX', '[Tandoor Recipes] ') # allauth sender prefix