Add email confirmation step so that we can (in the future) send team and event notifications to a user. Fixes #29

This commit is contained in:
Michael Hall 2018-02-26 11:53:50 -05:00
parent 30e2aed4af
commit ba69749cc5
16 changed files with 294 additions and 13 deletions

View file

@ -0,0 +1,31 @@
# Generated by Django 2.0 on 2018-02-26 15:32
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),
('accounts', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='EmailConfirmation',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.CharField(max_length=256)),
('key', models.CharField(max_length=256)),
('expires', models.DateTimeField()),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddField(
model_name='account',
name='is_email_confirmed',
field=models.BooleanField(default=False),
),
]

View file

@ -2,6 +2,8 @@ from django.db import models
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.contrib.auth.models import User, Group, AnonymousUser from django.contrib.auth.models import User, Group, AnonymousUser
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.utils.crypto import get_random_string
from django.conf import settings
import pytz import pytz
import datetime import datetime
@ -12,12 +14,35 @@ class Account(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE) user = models.OneToOneField(User, on_delete=models.CASCADE)
acctname = models.CharField(_("Account Name"), max_length=150, blank=True) acctname = models.CharField(_("Account Name"), max_length=150, blank=True)
is_email_confirmed = models.BooleanField(default=False)
badges = models.ManyToManyField('Badge', through='BadgeGrant') badges = models.ManyToManyField('Badge', through='BadgeGrant')
class Meta: class Meta:
ordering = ('user__username',) ordering = ('user__username',)
def new_confirmation_request(self):
valid_for = getattr(settings, 'EMAIL_CONFIRMAION_EXPIRATION_DAYS', 5)
confirmation_key=get_random_string(length=32)
return EmailConfirmation.objects.create(
user=self.user,
email=self.user.email,
key=confirmation_key,
expires=datetime.datetime.now()+datetime.timedelta(days=valid_for)
)
def confirm_email(self, confirmation_key):
try:
confirmation_request = EmailConfirmation.objects.get(user=self.user, email=self.user.email, key=confirmation_key, expires__gt=datetime.datetime.now())
if confirmation_request is not None:
self.is_email_confirmed = True
self.save()
confirmation_request.delete()
return True
except Exception as e:
print(e)
return False
def __str__(self): def __str__(self):
try: try:
if self.acctname: if self.acctname:
@ -50,6 +75,12 @@ def _getAnonAccount(self):
User.account = property(_getUserAccount) User.account = property(_getUserAccount)
AnonymousUser.account = property(_getAnonAccount) AnonymousUser.account = property(_getAnonAccount)
class EmailConfirmation(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
email = models.CharField(max_length=256)
key = models.CharField(max_length=256)
expires = models.DateTimeField()
class Badge(models.Model): class Badge(models.Model):
name = models.CharField(_('Badge Name'), max_length=64, blank=False, null=False) name = models.CharField(_('Badge Name'), max_length=64, blank=False, null=False)
img_url = models.URLField(_('Badge Image'), blank=False, null=False) img_url = models.URLField(_('Badge Image'), blank=False, null=False)

View file

@ -215,7 +215,18 @@ class UserForm(forms.ModelForm):
class UserProfileForm(forms.ModelForm): class UserProfileForm(forms.ModelForm):
class Meta: class Meta:
model = UserProfile model = UserProfile
fields = ['realname', 'avatar'] fields = ['realname', 'avatar', 'send_notifications']
labels = {
'send_notifications': _('Send me notification emails'),
}
class SendNotificationsForm(forms.ModelForm):
class Meta:
model = UserProfile
fields = ['send_notifications']
labels = {
'send_notifications': _('Send me notification emails'),
}
class SearchForm(forms.Form): class SearchForm(forms.Form):
city = forms.IntegerField(required=False, widget=Lookup(source='/api/cities/', label='name')) city = forms.IntegerField(required=False, widget=Lookup(source='/api/cities/', label='name'))

View file

@ -0,0 +1,18 @@
# Generated by Django 2.0 on 2018-02-26 15:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0009_auto_20180224_0556'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='send_notifications',
field=models.BooleanField(default=True, verbose_name='Send notification emails'),
),
]

View file

@ -13,7 +13,7 @@ class UserProfile(models.Model):
" Store profile information about a user " " Store profile information about a user "
user = models.OneToOneField(User, on_delete=models.CASCADE) user = models.OneToOneField(User, on_delete=models.CASCADE)
realname = models.CharField(_("Real Name"), max_length=150, blank=True) realname = models.CharField(verbose_name=_("Real Name"), max_length=150, blank=True)
tz = models.CharField(max_length=32, verbose_name=_('Timezone'), default='UTC', choices=[(tz, tz) for tz in pytz.all_timezones], blank=False, null=False, help_text=_('The most commonly used timezone for this User.')) tz = models.CharField(max_length=32, verbose_name=_('Timezone'), default='UTC', choices=[(tz, tz) for tz in pytz.all_timezones], blank=False, null=False, help_text=_('The most commonly used timezone for this User.'))
avatar = models.URLField(verbose_name=_("Photo Image"), max_length=150, blank=True, null=True) avatar = models.URLField(verbose_name=_("Photo Image"), max_length=150, blank=True, null=True)
@ -21,6 +21,8 @@ class UserProfile(models.Model):
twitter = models.CharField(verbose_name=_('Twitter Name'), max_length=32, blank=True, null=True) twitter = models.CharField(verbose_name=_('Twitter Name'), max_length=32, blank=True, null=True)
facebook = models.URLField(verbose_name=_('Facebook URL'), max_length=32, blank=True, null=True) facebook = models.URLField(verbose_name=_('Facebook URL'), max_length=32, blank=True, null=True)
send_notifications = models.BooleanField(verbose_name=_('Send notification emails'), default=True)
class Meta: class Meta:
ordering = ('user__username',) ordering = ('user__username',)

View file

@ -0,0 +1,29 @@
{% load static %}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Email from GetTogether.Community</title>
{% block meta %}{% endblock %}
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="https://gettogether.community{% static 'css/bootstrap/css/bootstrap.min.css' %}">
{%block styles %}{% endblock %}
</head>
<body>
<nav class="navbar navbar-toggleable-md navbar-inverse bg-inverse fixed-top">
<a class="navbar-brand" href="/">GetTogether</a>
</nav>
<main role="main" class="container">
{% block content %}{% endblock %}
</main>
</html>

View file

@ -0,0 +1,13 @@
{% extends "get_together/emails/base.html" %}
{% block content %}
<h3>Request Email Confirmation </h3>
<p>Please confirm this email address with GetTogether.Community by clicking on the link below:</p>
<p><a href="{{confirmation_url}}" class="btn btn-success">Confirm email</a></p>
<hr/>
<p>This is an automated email sent by <a href="https://gettogether.community">https://gettogether.community</a></p>
<p>Learn more at <a href="https://github.com/GetTogetherComm/GetTogether/">https://github.com/GetTogetherComm/GetTogether/</a></p>
{% endblock %}

View file

@ -0,0 +1,9 @@
== Request Email Confirmation ==
Please confirm this email address with GetTogether.Community by clicking on the link below:
Confirm email: {{confirmation_url}}
--
This is an automated email sent by https://gettogether.community
Learn more at https://github.com/GetTogetherComm/GetTogether/

View file

@ -0,0 +1,16 @@
{% extends "get_together/base.html" %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-12 align-left">
<h2>Unable to confirm email</h2>
<p>The confirmation link is invalid. Either it does not below to {{request.user.email}} or the confirmation key has expired.</p>
<p><a href={% url 'send-confirm-email' %}" class="btn btn-primary">Resend confirmation email</a></p>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,24 @@
{% extends "get_together/base.html" %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-12 align-left">
<h2>Manage email notifications</h2>
<p>GetTogether can send notifications to <strong>{{request.user.email}}</strong> about your upcoming events or new events for teams you are a member of.</p>
<p>Please confirm whether or not you would like to receive these notifcation emails.</p>
<p>
<form action={% url 'confirm-notifications' %} method="POST">
{% csrf_token %}
{{notifications_form}}
<br/>
<button type="submit" class="btn btn-primary">Save</button>
</form>
</p>
</div>
</div>
</div>
{% endblock %}

View file

@ -1,13 +1,30 @@
{% extends "get_together/base.html" %} {% extends "get_together/base.html" %}
{% block content %} {% block content %}
<h2>Profile: {{request.user}}</h2> <div class="container">
<form action="{% url "edit-profile" %}" method="post"> <div class="row">
{% csrf_token %} <div class="col">
{% include "events/profile_form.html" %} <h2>Profile: {{request.user}}</h2>
<br /> <form action="{% url "edit-profile" %}" method="post">
<button type="submit" class="btn btn-primary">Save</button> {% csrf_token %}
</form> {% include "events/profile_form.html" %}
<br />
<button type="submit" class="btn btn-primary">Save</button>
</form>
</div>
</div>
{% if request.user.email and not request.user.account.is_email_confirmed %}
<div class="row">
<div class="col">
<hr/>
<div class="alerts"><div class="alert alert-danger">Your email address has not been confirmed.</div></div>
<p>You will not be able to receive email notifications from GetTogether until you confirm that this address belongs to you.</p>
<p><a href="{% url 'send-confirm-email' %}" class="btn btn-success">Confirm your email</a></p>
</div>
</div>
{% endif %}
</div>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,16 @@
{% extends "get_together/base.html" %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-12 align-left">
<h2>Confirmation email sent</h2>
<p>An email has been sent to <strong>{{request.user.email}}</strong>. Click the link it contains to confirm that you have access to this email address.</p>
<p>This confirmation request will expire on {{confirmation.expires}}</p>
</div>
</div>
</div>
{% endblock %}

View file

@ -36,6 +36,9 @@ urlpatterns = [
path('api/find_city/', event_views.find_city), path('api/find_city/', event_views.find_city),
path('profile/+edit', views.edit_profile, name='edit-profile'), path('profile/+edit', views.edit_profile, name='edit-profile'),
path('profile/+send_confirmation_email', views.user_send_confirmation_email, name='send-confirm-email'),
path('profile/+confirm_email/<str:confirmation_key>', views.user_confirm_email, name='confirm-email'),
path('profile/+confirm_notifications', views.user_confirm_notifications, name='confirm-notifications'),
path('profile/<int:user_id>/', views.show_profile, name='show-profile'), path('profile/<int:user_id>/', views.show_profile, name='show-profile'),
path('events/', views.events_list, name='events'), path('events/', views.events_list, name='events'),

View file

@ -23,6 +23,7 @@ from .teams import *
from .events import * from .events import *
from .places import * from .places import *
from .user import * from .user import *
from .new_user import *
KM_PER_DEGREE_LAT = 110.574 KM_PER_DEGREE_LAT = 110.574
KM_PER_DEGREE_LNG = 111.320 # At the equator KM_PER_DEGREE_LNG = 111.320 # At the equator

View file

@ -1,14 +1,17 @@
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.contrib.sites.models import Site
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import logout as logout_user from django.contrib.auth.decorators import login_required
from django.shortcuts import render, redirect from django.shortcuts import render, redirect
from django.http import HttpResponse, JsonResponse from django.urls import reverse
from django.core.mail import send_mail
from django.template.loader import get_template, render_to_string
from django.conf import settings
from events.models.profiles import Team, UserProfile, Member from events.models.profiles import Team, UserProfile, Member
from events.forms import TeamForm, NewTeamForm, TeamEventForm, NewTeamEventForm, NewPlaceForm
from events.models.events import Event, Place, Attendee from events.models.events import Event, Place, Attendee
from events.forms import SendNotificationsForm
import datetime import datetime
import simplejson import simplejson
@ -22,3 +25,50 @@ def new_user_find_teams(request):
def new_user_find_events(request): def new_user_find_events(request):
pass pass
# These views are for confirming a user's email address before sending them mail
@login_required
def user_send_confirmation_email(request):
confirmation_request = request.user.account.new_confirmation_request()
site = Site.objects.get(id=1)
confirmation_url = "https://%s%s" % (site.domain, reverse('confirm-email', kwargs={'confirmation_key':confirmation_request.key}))
context = {
'confirmation': confirmation_request,
'confirmation_url': confirmation_url,
}
email_subject = '[GetTogether] Confirm email address'
email_body_text = render_to_string('get_together/emails/confirm_email.txt', context, request)
email_body_html = render_to_string('get_together/emails/confirm_email.html', context, request)
email_recipients = [request.user.email]
email_from = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@gettogether.community')
send_mail(
subject=email_subject,
message=email_body_text,
from_email=email_from,
recipient_list=email_recipients,
html_message=email_body_html
)
return render(request, 'get_together/users/sent_email_confirmation.html', context)
@login_required
def user_confirm_email(request, confirmation_key):
if request.user.account.confirm_email(confirmation_key):
messages.add_message(request, messages.SUCCESS, message=_('Your email address has been confirmed.'))
return redirect('confirm-notifications')
else:
return render(request, 'get_together/users/bad_email_confirmation.html')
@login_required
def user_confirm_notifications(request):
if request.method == 'GET':
form = SendNotificationsForm(instance=request.user.profile)
context = {
'notifications_form': form
}
return render(request, 'get_together/users/confirm_notifications.html', context)
elif request.method == 'POST':
form = SendNotificationsForm(request.POST, instance=request.user.profile)
if form.is_valid():
form.save()
return redirect('home')

View file

@ -55,6 +55,7 @@ def edit_profile(request):
user = request.user user = request.user
profile = request.user.profile profile = request.user.profile
account = request.user.account
if request.method == 'GET': if request.method == 'GET':
user_form = UserForm(instance=user) user_form = UserForm(instance=user)
@ -66,11 +67,20 @@ def edit_profile(request):
} }
return render(request, 'get_together/users/edit_profile.html', context) return render(request, 'get_together/users/edit_profile.html', context)
elif request.method == 'POST': elif request.method == 'POST':
old_email = request.user.email
user_form = UserForm(request.POST, instance=user) user_form = UserForm(request.POST, instance=user)
profile_form = UserProfileForm(request.POST, instance=profile) profile_form = UserProfileForm(request.POST, instance=profile)
if user_form.is_valid() and profile_form.is_valid(): if user_form.is_valid() and profile_form.is_valid():
user = user_form.save() user = user_form.save()
profile = profile_form.save() profile = profile_form.save()
if user.email != old_email:
if user.email is None or user.email == "":
messages.add_message(request, messages.ERROR, message=_('Your email address has been removed.'))
account.is_email_confirmed = False
account.save()
else:
messages.add_message(request, messages.WARNING, message=_('Your email address has changed, please confirm your new address.'))
return redirect('send-confirm-email')
return redirect('home') return redirect('home')
else: else:
context = { context = {