Add email change feature to members

parent 73fa3a39
......@@ -3,7 +3,6 @@ This module registers admin pages for the models
"""
import csv
import datetime
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import User
......@@ -12,6 +11,7 @@ from django.http import HttpResponse
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from members.models import EmailChange
from . import forms, models
......@@ -145,6 +145,8 @@ class MemberAdmin(UserAdmin):
return False
admin.site.register(EmailChange)
# re-register User admin
admin.site.unregister(User)
admin.site.register(User, UserAdmin)
from base64 import b64encode
from django.contrib.staticfiles.finders import find as find_static_file
from django.templatetags.static import static
from django.urls import reverse
from rest_framework import serializers
from thaliawebsite.api.services import create_image_thumbnail_dict
from events.api.serializers import CalenderJSSerializer
from members.models import Member
from members.services import member_achievements
from thaliawebsite.api.services import create_image_thumbnail_dict
from utils.templatetags.thumbnail import thumbnail
......
import copy
from datetime import datetime
from django.utils import timezone
from pytz.exceptions import InvalidTimeError
from rest_framework import permissions
from rest_framework import viewsets, filters
from rest_framework.decorators import list_route
from rest_framework.exceptions import ParseError
from rest_framework.response import Response
from pytz.exceptions import InvalidTimeError
from members.api.serializers import (MemberBirthdaySerializer,
MemberRetrieveSerializer,
......
from datetime import timedelta
from django.core import mail
from django.template import loader
from django.template.defaultfilters import floatformat
from django.urls import reverse
from django.utils import translation
from django.utils.datetime_safe import datetime
from django.utils.translation import ugettext as _
from django.template.defaultfilters import floatformat
from members.models import Member
from thaliawebsite import settings
......@@ -92,7 +92,7 @@ def send_expiration_announcement(dry_run=False):
'members/email/expiration_announcement.txt',
{'name': member.get_full_name(),
'membership_price': floatformat(
settings.MEMBERSHIP_PRICES['year'], 2
settings.MEMBERSHIP_PRICES['year'], 2
)})
mail.EmailMessage(
_('Membership expiration announcement'),
......@@ -125,3 +125,54 @@ def send_welcome_message(user, password, language):
user.email_user(
_('Welcome to Study Association Thalia'),
email_body)
def send_email_change_confirmation_messages(change_request):
member = change_request.member
with translation.override(member.profile.language):
mail.EmailMessage(
'[THALIA] {}'.format(_('Please confirm your email change')),
loader.render_to_string(
'members/email/email_change_confirm.txt',
{
'confirm_link': '{}{}'.format(
'https://thalia.nu',
reverse(
'members:email-change-confirm',
args=[change_request.confirm_key]
)),
'name': member.first_name
}
),
settings.WEBSITE_FROM_ADDRESS,
[change_request.email]
).send()
mail.EmailMessage(
'[THALIA] {}'.format(_('Please verify your email address')),
loader.render_to_string(
'members/email/email_change_verify.txt',
{
'confirm_link': '{}{}'.format(
'https://thalia.nu',
reverse(
'members:email-change-verify',
args=[change_request.verify_key]
)),
'name': member.first_name
}
),
settings.WEBSITE_FROM_ADDRESS,
[change_request.email]
).send()
def send_email_change_completion_message(change_request):
change_request.member.email_user(
'[THALIA] {}'.format(_('Your email address has been changed')),
loader.render_to_string(
'members/email/email_change_completed.txt',
{
'name': change_request.member.first_name
}
))
......@@ -6,8 +6,8 @@ from django.contrib.auth.forms import UserCreationForm as BaseUserCreationForm
from django.contrib.auth.models import User
from django.utils.translation import ugettext_lazy as _
from members import emails, models
from .models import Profile
from members import emails
class ProfileForm(forms.ModelForm):
......@@ -116,3 +116,9 @@ class UserChangeForm(BaseUserChangeForm):
self.cleaned_data['username'] = (self.cleaned_data['username']
.lower())
super().clean()
class EmailChangeForm(forms.ModelForm):
class Meta:
model = models.EmailChange
fields = ['email', 'member']
This diff was suppressed by a .gitattributes entry.
......@@ -2,11 +2,11 @@
# Generated by Django 1.10b1 on 2016-07-07 15:04
from __future__ import unicode_literals
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import localflavor.generic.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
......
......@@ -3,6 +3,7 @@
from __future__ import unicode_literals
from django.db import migrations, models
import utils.validators
......
......@@ -2,10 +2,9 @@
# Generated by Django 1.10 on 2016-08-05 12:35
from __future__ import unicode_literals
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
......
# Generated by Django 2.0.2 on 2018-06-07 11:42
import django.utils.timezone
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('members', '0020_max_length_on_profile'),
]
operations = [
migrations.CreateModel(
name='EmailChange',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created at')),
('email', models.EmailField(max_length=254, verbose_name='email')),
('verify_key', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('confirm_key', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('verified', models.BooleanField(default=False, help_text='the new email address is valid', verbose_name='verified')),
('confirmed', models.BooleanField(default=False, help_text='the old email address was checked', verbose_name='confirmed')),
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='members.Member', verbose_name='member')),
],
),
]
import logging
import operator
import os
import logging
from datetime import timedelta
from functools import reduce
import uuid
from PIL import Image
from datetime import timedelta
from django.conf import settings
from django.contrib.auth.models import User, UserManager
from django.core import validators
......@@ -14,12 +13,12 @@ from django.db.models import Q
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from functools import reduce
from localflavor.generic.countries.sepa import IBAN_SEPA_COUNTRIES
from localflavor.generic.models import IBANField
from activemembers.models import Committee, CommitteeMembership
logger = logging.getLogger(__name__)
......@@ -537,3 +536,40 @@ class Membership(models.Model):
def is_active(self):
return not self.until or self.until > timezone.now().date()
class EmailChange(models.Model):
created_at = models.DateTimeField(_('created at'), default=timezone.now)
member = models.ForeignKey(
'members.Member',
on_delete=models.CASCADE,
verbose_name=_('member'),
)
email = models.EmailField(_('email'), max_length=254)
verify_key = models.UUIDField(unique=True, default=uuid.uuid4,
editable=False)
confirm_key = models.UUIDField(unique=True, default=uuid.uuid4,
editable=False)
verified = models.BooleanField(
_('verified'), default=False,
help_text=_('the new email address is valid')
)
confirmed = models.BooleanField(
_('confirmed'), default=False,
help_text=_('the old email address was checked')
)
@property
def completed(self):
return self.verified and self.confirmed
def clean(self):
super().clean()
if self.email == self.member.email:
raise ValidationError(
{'email': _("Please enter a new email address.")})
from datetime import date
from django.db.models import Q
from members import emails
from members.models import Membership
from utils.snippets import datetime_to_lectureyear
......@@ -98,3 +98,41 @@ def gen_stats_year(member_types):
stats_year.append(new)
return stats_year
def verify_email_change(change_request):
"""
Mark the email change request as verified
:param change_request: the email change request
"""
change_request.verified = True
change_request.save()
process_email_change(change_request)
def confirm_email_change(change_request):
"""
Mark the email change request as verified
:param change_request: the email change request
"""
change_request.confirmed = True
change_request.save()
process_email_change(change_request)
def process_email_change(change_request):
"""
Change the user's email address if the request was completed and
send the completion email
:param change_request: the email change request
"""
if not change_request.completed:
return
member = change_request.member
member.email = change_request.email
member.save()
emails.send_email_change_completion_message(change_request)
......@@ -43,7 +43,7 @@
<label class="control-label">{% trans "Email Address" %}</label>
<div class="controls">
<input type="text" readonly="readonly" value="{{ request.member.email }}" />
<span class="help-block">{% trans "Want to change your name or email address? Please send an email to info@thalia.nu." %}</span>
<span class="help-block"><a href="{% url 'members:email-change' %}">{% trans "Click here to change your email address." %}</a></span>
</div>
</div>
......
{% load i18n %}{% blocktrans %}Dear {{ name }},
We've successfully updated the email address of your account.
If you have any questions, then don't hesitate and send an email to info@thalia.nu.
With kind regards,
The board of Study Association Thalia
————
This email was automatically generated.{% endblocktrans %}
{% load i18n %}{% blocktrans %}Dear {{ name }},
We've received a request to change the email address of your account.
If you did not submit such a request then please ignore this email.
Please open the following link in your web browser to confirm the change:
{{ confirm_link }}
If you have any questions, then don't hesitate and send an email to info@thalia.nu.
With kind regards,
The board of Study Association Thalia
————
This email was automatically generated.{% endblocktrans %}
{% load i18n %}{% blocktrans %}Dear {{ name }},
We've received a request to change the email address of your account.
If you did not submit such a request then please ignore this email.
Please open the following link in your web browser to confirm your new email address:
{{ confirm_link }}
If you have any questions, then don't hesitate and send an email to info@thalia.nu.
With kind regards,
The board of Study Association Thalia
————
This email was automatically generated.{% endblocktrans %}
{% extends "base.html" %}
{% load static i18n fieldtype form_field %}
{% block title %}{% trans "change email"|capfirst %} — {% trans "members"|capfirst %} — {{ block.super }}{% endblock %}
{% block opengraph_title %}{% trans "change email"|capfirst %} — {% trans "members"|capfirst %} — {{ block.super }}{% endblock %}
{% block body %}
<h1>{% trans "change email"|capfirst %}</h1>
<form method="post" enctype="multipart/form-data" class="form-horizontal span8 offset2">
{% csrf_token %}
<div class="control-group row {% if form.email.errors %}error{% endif %}">
<label class="control-label" for="id_{{ form.email.name }}">{{ form.email.label }}:</label>
<div class="controls">
{{ form.email }}
{% for error in form.email.errors %}
<span class="help-block">{{ error|escape }}</span>
{% endfor %}
{% if field.help_text %}
<span class="help-block">{{ form.email.help_text|safe }}</span>
{% endif %}
</div>
</div>
<input type="submit" value="{% trans 'change'|capfirst %}" class="btn btn-style1 pull-right login" />
</form>
{% endblock %}
{% extends "base.html" %}
{% load i18n %}
{% block title %}{% trans "change email"|capfirst %} - {% trans "members"|capfirst %} — {{ block.super }}{% endblock %}
{% block body %}
<h1>{% trans "change email" %}</h1>
<p class="tcenter">
{% blocktrans trimmed %}
The confirmation of your email change was successful. Check your new inbox for the verification email.
{% endblocktrans %}
</p>
{% endblock %}
{% extends "base.html" %}
{% load i18n %}
{% block title %}{% trans "change email"|capfirst %} - {% trans "members"|capfirst %} — {{ block.super }}{% endblock %}
{% block body %}
<h1>{% trans "change email" %}</h1>
<p class="tcenter">
{% blocktrans trimmed %}
We have received the request to change your email address.
You should receive a message in both your new and old mailboxes to verify the change.
{% endblocktrans %}
</p>
{% endblock %}
{% extends "base.html" %}
{% load i18n %}
{% block title %}{% trans "change email"|capfirst %} - {% trans "members"|capfirst %} — {{ block.super }}{% endblock %}
{% block body %}
<h1>{% trans "change email" %}</h1>
<p class="tcenter">
{% blocktrans trimmed %}
The verification of your email change was successful. Check your old inbox for the confirmation email.
{% endblocktrans %}
</p>
{% endblock %}
from datetime import date, datetime, timedelta
from django.test import TestCase
from django.utils import timezone
......
from datetime import timedelta, date
from django.test import TestCase
from django.utils import timezone
from unittest import mock
from members.models import Member, Membership, Profile
from members import services
from members.models import Member, Membership, Profile, EmailChange
from members.services import gen_stats_year, gen_stats_member_type
from utils.snippets import datetime_to_lectureyear
......@@ -155,3 +156,61 @@ class StatisticsTest(TestCase):
# one >5 year student
self.assertEqual(1, result[5]['member'])
class EmailChangeTest(TestCase):
fixtures = ['members.json']
@classmethod
def setUpTestData(cls):
# Add 10 members with default membership
cls.member = Member.objects.get(pk=2)
def setUp(self):
self.member.refresh_from_db()
def test_verify_email_change(self):
change_request = EmailChange(
member=self.member,
email='new@example.org'
)
with mock.patch('members.services.process_email_change') as proc:
services.verify_email_change(change_request)
self.assertTrue(change_request.verified)
proc.assert_called_once_with(change_request)
def test_confirm_email_change(self):
change_request = EmailChange(
member=self.member,
email='new@example.org'
)
with mock.patch('members.services.process_email_change') as proc:
services.confirm_email_change(change_request)
self.assertTrue(change_request.confirmed)
proc.assert_called_once_with(change_request)
@mock.patch('members.emails.send_email_change_completion_message')
def test_process_email_change(self, send_message_mock):
change_request = EmailChange(
member=self.member,
email='new@example.org'
)
original_email = self.member.email
with self.subTest('Uncompleted request'):
services.process_email_change(change_request)
self.assertEqual(self.member.email, original_email)
send_message_mock.assert_not_called()
with self.subTest('Completed request'):
change_request.verified = True
change_request.confirmed = True
services.process_email_change(change_request)
self.assertEqual(self.member.email, change_request.email)
send_message_mock.assert_called_once_with(change_request)
from django.conf.urls import url
from django.urls import path
from . import views
......@@ -8,5 +9,11 @@ urlpatterns = [
url('^profile/(?P<pk>[0-9]*)$', views.profile, name='profile'),
url('^profile/edit/$', views.edit_profile, name='edit-profile'),
url('^members/iban-export/$', views.iban_export, name='iban-export'),
path('profile/change-email/', views.EmailChangeFormView.as_view(),
name='email-change'),
path('profile/change-email/verify/<uuid:key>/',
views.EmailChangeVerifyView.as_view(), name='email-change-verify'),
path('profile/change-email/confirm/<uuid:key>/',
views.EmailChangeConfirmView.as_view(), name='email-change-confirm'),
url('^$', views.index, name='index'),
]
import csv
import json
from datetime import date, datetime
from django.contrib.auth.decorators import login_required, permission_required
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.db.models import Q
from django.http import HttpResponse
from django.http import HttpResponse, Http404
from django.shortcuts import get_object_or_404, render
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _
from django.views.generic import FormView
from django.views.generic.base import TemplateResponseMixin, View
from rest_framework.authtoken.models import Token
from rest_framework.authtoken.views import ObtainAuthToken
from rest_framework.response import Response
from members import services
from .services import member_achievements
from . import models
from .forms import ProfileForm
import pizzas.services
from members import services, emails
from members.models import EmailChange
from . import models
from .forms import ProfileForm, EmailChangeForm
from .services import member_achievements
class ObtainThaliaAuthToken(ObtainAuthToken):
......@@ -73,9 +77,9 @@ def filter_users(tab, keywords, year_range):
memberships = models.Membership.objects.filter(memberships_query)
members_query &= Q(pk__in=memberships.values('user__pk'))
return (models.Member.objects
.filter(members_query)
.order_by('-profile__starting_year',
'first_name'))
.filter(members_query)
.order_by('-profile__starting_year',
'first_name'))
@login_required
......@@ -153,7 +157,7 @@ def profile(request, pk=None):
'achievements': achievements,
'member': member,
'membership_type': membership_type,
})
})
@login_required
......@@ -184,7 +188,7 @@ def iban_export(request):
rows = []
members = models.Member.current_members.filter(
profile__direct_debit_authorized=True)
profile__direct_debit_authorized=True)
for member in members:
if member.current_membership.type != 'honorary':
......@@ -225,3 +229,64 @@ def statistics(request):
}
return render(request, 'members/statistics.html', context)
@method_decorator(login_required, name='dispatch')
class EmailChangeFormView(FormView):
"""
View that renders the email change form
"""
form_class = EmailChangeForm
template_name = 'members/email_change.html'
def get_initial(self):
initial = super().get_initial()
initial['email'] = self.request.member.email
return initial
def post(self, request, *args, **kwargs):
request.POST = request.POST.dict()
request.POST['member'] = request.member.pk
return super().post(request, *args, **kwargs)
def form_valid(self, form):
change_request = form.save()
emails.send_email_change_confirmation_messages(change_request)
return TemplateResponse(request=self.request,
template='members/email_change_requested.html')
@method_decorator(login_required, name='dispatch')
class EmailChangeConfirmView(View, TemplateResponseMixin):
"""
View that renders an HTML template and confirms the old email address
"""
template_name = 'members/email_change_confirmed.html'
def get(self, request, *args, **kwargs):
if not EmailChange.objects.filter(confirm_key=kwargs['key']).exists():
raise Http404
change_request = EmailChange.objects.get(confirm_key=kwargs['key'])
services.confirm_email_change(change_request)
return self.render_to_response({})
@method_decorator(login_required, name='dispatch')
class EmailChangeVerifyView(View, TemplateResponseMixin):
"""
View that renders an HTML template and verifies the new email address
"""
template_name = 'members/email_change_verified.html'
def get(self, request, *args, **kwargs):
if not EmailChange.objects.filter(verify_key=kwargs['key']).exists():
raise Http404
change_request = EmailChange.objects.get(verify_key=kwargs['key'])
services.verify_email_change(change_request)
return self.render_to_response({})
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment