diff --git a/projectroles/models.py b/projectroles/models.py index 11e8afc4..82286ac1 100644 --- a/projectroles/models.py +++ b/projectroles/models.py @@ -62,7 +62,7 @@ 'Invalid project type "{project_type}" for ' 'role "{role_name}"' ) CAT_PUBLIC_ACCESS_MSG = 'Public guest access is not allowed for categories' -ADD_EMAIL_ALREADY_SET_MSG = 'Email already set as primary email for user' +ADD_EMAIL_ALREADY_SET_MSG = 'Email already set as {email_type} email for user' # Project ---------------------------------------------------------------------- @@ -1453,7 +1453,9 @@ def _validate_email_unique(self): Assert the same email has not yet been set for the user. """ if self.email == self.user.email: - raise ValidationError(ADD_EMAIL_ALREADY_SET_MSG) + raise ValidationError( + ADD_EMAIL_ALREADY_SET_MSG.format(email_type='primary') + ) def save(self, *args, **kwargs): self._validate_email_unique() diff --git a/userprofile/forms.py b/userprofile/forms.py index 955c8644..41ec6013 100644 --- a/userprofile/forms.py +++ b/userprofile/forms.py @@ -7,12 +7,19 @@ from projectroles.app_settings import AppSettingAPI from projectroles.forms import ( SODARForm, + SODARModelForm, SETTING_CUSTOM_VALIDATE_MSG, SETTING_DISABLE_LABEL, SETTING_SOURCE_ONLY_MSG, ) -from projectroles.models import APP_SETTING_VAL_MAXLENGTH, SODAR_CONSTANTS +from projectroles.models import ( + SODARUserAdditionalEmail, + SODAR_CONSTANTS, + APP_SETTING_VAL_MAXLENGTH, + ADD_EMAIL_ALREADY_SET_MSG, +) from projectroles.plugins import get_active_plugins +from projectroles.utils import build_secret app_settings = AppSettingAPI() @@ -24,11 +31,8 @@ APP_SETTING_SCOPE_USER = SODAR_CONSTANTS['APP_SETTING_SCOPE_USER'] -# User Settings Form ----------------------------------------------------------- - - class UserSettingsForm(SODARForm): - """The form for configuring user settings.""" + """Form for configuring user settings""" def __init__(self, *args, **kwargs): self.user = kwargs.pop('current_user') @@ -203,3 +207,38 @@ def clean(self): ), ) return self.cleaned_data + + +class UserEmailForm(SODARModelForm): + """Form for creating additional email addresses for user""" + + class Meta: + model = SODARUserAdditionalEmail + fields = ['email', 'user', 'secret'] + + def __init__(self, current_user=None, *args, **kwargs): + super().__init__(*args, **kwargs) + if current_user: + self.current_user = current_user + self.fields['user'].widget = forms.HiddenInput() + self.initial['user'] = current_user + self.fields['secret'].widget = forms.HiddenInput() + self.initial['secret'] = build_secret(32) + + def clean(self): + if self.cleaned_data['email'] == self.current_user.email: + self.add_error( + 'email', ADD_EMAIL_ALREADY_SET_MSG.format(email_type='primary') + ) + return self.cleaned_data + if ( + SODARUserAdditionalEmail.objects.filter( + user=self.current_user, email=self.cleaned_data['email'] + ).count() + > 0 + ): + self.add_error( + 'email', + ADD_EMAIL_ALREADY_SET_MSG.format(email_type='additional'), + ) + return self.cleaned_data diff --git a/userprofile/rules.py b/userprofile/rules.py index 6ae2f366..e4d47477 100644 --- a/userprofile/rules.py +++ b/userprofile/rules.py @@ -18,3 +18,6 @@ # Allow viewing user detail rules.add_perm('userprofile.view_detail', rules.is_authenticated) + +# Allow adding additional email +rules.add_perm('userprofile.add_email', rules.is_authenticated) diff --git a/userprofile/templates/userprofile/detail.html b/userprofile/templates/userprofile/detail.html index 214b99fc..1fecf1be 100644 --- a/userprofile/templates/userprofile/detail.html +++ b/userprofile/templates/userprofile/detail.html @@ -6,7 +6,27 @@ {% block css %} {{ block.super }} - {# TODO: Add styles for email table #} + {% endblock %} {% block projectroles %} @@ -97,74 +117,77 @@

-
-
-

- Additional Email Addresses - {% if local_user %} - - - Add Email - - - {% endif %} -

-
-
- - - - - - - - - - - - {% if add_emails.count > 0 %} - {% for email in add_emails %} - - - - - - +
AddressStatusCreatedModified
{{ email.email }} - {% if email.verified %}Verified{% else %}Unverified{% endif %} - {{ email.date_created | date:'Y-m-d H:i'}}{{ email.date_modified | date:'Y-m-d H:i'}} - - + {% get_django_setting 'PROJECTROLES_SEND_EMAIL' as send_email %} + {% if send_email %} +
+
+

+ Additional Email Addresses + {% if local_user %} + + + Add Email + + + {% endif %} +

+
+
+ + + + + + + + + + + + {% if add_emails.count > 0 %} + {% for email in add_emails %} + + + + + + + + {% endfor %} + {% else %} + + - {% endfor %} - {% else %} - - - - {% endif %} - -
AddressStatusCreatedModified
{{ email.email }} + {% if email.verified %}Verified{% else %}Unverified{% endif %} + {{ email.date_created | date:'Y-m-d H:i'}}{{ email.date_modified | date:'Y-m-d H:i'}} + + +
+ No additional email addresses set.
- No additional email addresses set. -
+ {% endif %} +
+
- + {% endif %} {% endblock projectroles %} diff --git a/userprofile/templates/userprofile/email_form.html b/userprofile/templates/userprofile/email_form.html new file mode 100644 index 00000000..715333d6 --- /dev/null +++ b/userprofile/templates/userprofile/email_form.html @@ -0,0 +1,45 @@ +{% extends 'projectroles/base.html' %} + +{% load rules %} +{% load crispy_forms_filters %} +{% load projectroles_tags %} +{% load projectroles_common_tags %} + +{% block title %} + Add Email Address +{% endblock title %} + +{% block head_extend %} + {{ form.media }} +{% endblock head_extend %} + +{% block projectroles %} + +
+

{{ request.user.get_full_name }}

+
User Profile
+
+ +
+

Add Email Address

+
+ +
+
+ {% csrf_token %} + {{ form | crispy }} +
+
+ + Cancel + + +
+
+
+
+ +{% endblock projectroles %} diff --git a/userprofile/tests/test_permissions.py b/userprofile/tests/test_permissions.py index a34dd99d..fe3bd67d 100644 --- a/userprofile/tests/test_permissions.py +++ b/userprofile/tests/test_permissions.py @@ -35,3 +35,16 @@ def test_get_settings_update_anon(self): url = reverse('userprofile:settings_update') self.assert_response(url, [self.superuser, self.regular_user], 200) self.assert_response(url, self.anonymous, 302) + + def test_get_email_create(self): + """Test UserEmailCreateView GET""" + url = reverse('userprofile:email_create') + self.assert_response(url, [self.superuser, self.regular_user], 200) + self.assert_response(url, self.anonymous, 302) + + @override_settings(PROJECTROLES_ALLOW_ANONYMOUS=True) + def test_get_email_create_anon(self): + """Test UserEmailCreateView GET with anonymous access""" + url = reverse('userprofile:email_create') + self.assert_response(url, [self.superuser, self.regular_user], 200) + self.assert_response(url, self.anonymous, 302) diff --git a/userprofile/tests/test_ui.py b/userprofile/tests/test_ui.py index 03cdf702..f053adb1 100644 --- a/userprofile/tests/test_ui.py +++ b/userprofile/tests/test_ui.py @@ -33,7 +33,7 @@ def test_update_button(self): expected = [(self.local_user, 1), (self.ldap_user, 0)] self.assert_element_count(expected, self.url, 'sodar-user-btn-update') - def test_add_email_unset(self): + def test_additional_email_unset(self): """Test existence of additional email elements without email""" self.assert_element_count( [(self.local_user, 0)], @@ -48,7 +48,7 @@ def test_add_email_unset(self): True, ) - def test_add_email_set(self): + def test_additional_email_set(self): """Test existence of additional email elements with email""" self.make_email(self.local_user, 'add1@example.com') self.make_email(self.local_user, 'add2@example.com', verified=False) @@ -67,6 +67,16 @@ def test_add_email_set(self): False, ) + @override_settings(PROJECTROLES_SEND_EMAIL=False) + def test_additional_email_disabled(self): + """Test existence of email card with PROJECTROLES_SEND_EMAIL=False""" + self.assert_element_exists( + [self.local_user], + self.url, + 'sodar-user-email-card', + False, + ) + class TestUserSettings(UITestBase): """Tests for user settings page""" diff --git a/userprofile/tests/test_views.py b/userprofile/tests/test_views.py index 3ee035a6..bfa10179 100644 --- a/userprofile/tests/test_views.py +++ b/userprofile/tests/test_views.py @@ -2,18 +2,23 @@ from django.contrib import auth from django.contrib.messages import get_messages +from django.core import mail +from django.forms.models import model_to_dict +from django.test import override_settings from django.urls import reverse from test_plus.test import TestCase # Projectroles dependency from projectroles.app_settings import AppSettingAPI +from projectroles.models import SODARUserAdditionalEmail from projectroles.tests.test_models import ( EXAMPLE_APP_NAME, AppSettingMixin, SODARUserAdditionalEmailMixin, ) from projectroles.views import MSG_FORM_INVALID +from projectroles.utils import build_secret from userprofile.views import SETTING_UPDATE_MSG @@ -24,6 +29,9 @@ # Local constants INVALID_VALUE = 'INVALID VALUE' +ADD_EMAIL = 'add1@example.com' +ADD_EMAIL2 = 'add2@example.com' +ADD_EMAIL_SECRET = build_secret(32) class UserViewTestBase(TestCase): @@ -237,3 +245,95 @@ def test_post_custom_validation(self): MSG_FORM_INVALID, ) self.assertEqual(self._get_setting('user_str_setting'), 'test') + + +class TestUserEmailCreateView(SODARUserAdditionalEmailMixin, UserViewTestBase): + """Tests for UserEmailCreateView""" + + def setUp(self): + super().setUp() + self.url = reverse('userprofile:email_create') + self.url_redirect = reverse('userprofile:detail') + + def test_get(self): + """Test UserEmailCreateView GET""" + with self.login(self.user): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertIsNotNone(response.context['form']) + + @override_settings(PROJECTROLES_SEND_EMAIL=False) + def test_get_email_disabled(self): + with self.login(self.user): + response = self.client.get(self.url) + self.assertRedirects(response, self.url_redirect) + + def test_post(self): + """Test POST""" + self.assertEqual(SODARUserAdditionalEmail.objects.count(), 0) + self.assertEqual(len(mail.outbox), 0) + with self.login(self.user): + data = { + 'user': self.user.pk, + 'email': ADD_EMAIL, + 'secret': ADD_EMAIL_SECRET, + } + response = self.client.post(self.url, data) + self.assertRedirects(response, self.url_redirect) + self.assertEqual(SODARUserAdditionalEmail.objects.count(), 1) + email = SODARUserAdditionalEmail.objects.first() + expected = { + 'id': email.pk, + 'user': self.user.pk, + 'email': ADD_EMAIL, + 'secret': ADD_EMAIL_SECRET, + 'verified': False, + 'sodar_uuid': email.sodar_uuid, + } + self.assertEqual(model_to_dict(email), expected) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].recipients(), [ADD_EMAIL]) + # TODO: Assert sent URL + + def test_post_existing_primary(self): + """Test POST with email used as primary email for user""" + self.assertEqual(SODARUserAdditionalEmail.objects.count(), 0) + self.assertEqual(len(mail.outbox), 0) + with self.login(self.user): + data = { + 'user': self.user.pk, + 'email': self.user.email, + 'secret': ADD_EMAIL_SECRET, + } + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 200) + self.assertEqual(SODARUserAdditionalEmail.objects.count(), 0) + self.assertEqual(len(mail.outbox), 0) + + def test_post_existing_additional(self): + """Test POST with email used as additional email for user""" + self.make_email(self.user, ADD_EMAIL) + with self.login(self.user): + data = { + 'user': self.user.pk, + 'email': ADD_EMAIL, + 'secret': ADD_EMAIL_SECRET, + } + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 200) + + def test_post_multiple(self): + """Test POST with different existing additional email""" + self.make_email(self.user, ADD_EMAIL) + self.assertEqual(SODARUserAdditionalEmail.objects.count(), 1) + self.assertEqual(len(mail.outbox), 0) + with self.login(self.user): + data = { + 'user': self.user.pk, + 'email': ADD_EMAIL2, + 'secret': ADD_EMAIL_SECRET, + } + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(SODARUserAdditionalEmail.objects.count(), 2) + self.assertEqual(len(mail.outbox), 1) diff --git a/userprofile/urls.py b/userprofile/urls.py index a7bd9492..0462b9c3 100644 --- a/userprofile/urls.py +++ b/userprofile/urls.py @@ -15,4 +15,9 @@ view=views.UserSettingsView.as_view(), name='settings_update', ), + path( + route='profile/email/create', + view=views.UserEmailCreateView.as_view(), + name='email_create', + ), ] diff --git a/userprofile/views.py b/userprofile/views.py index ece23d1b..0b1f37f6 100644 --- a/userprofile/views.py +++ b/userprofile/views.py @@ -1,21 +1,25 @@ """UI views for the userprofile app""" +from django.conf import settings from django.contrib import auth, messages from django.contrib.auth.mixins import LoginRequiredMixin -from django.urls import reverse_lazy -from django.views.generic import TemplateView, FormView +from django.shortcuts import redirect +from django.urls import reverse, reverse_lazy +from django.views.generic import TemplateView, FormView, CreateView # Projectroles dependency from projectroles.app_settings import AppSettingAPI +from projectroles.email import send_generic_mail, get_email_user from projectroles.models import SODARUserAdditionalEmail, SODAR_CONSTANTS from projectroles.plugins import get_active_plugins from projectroles.views import ( LoggedInPermissionMixin, HTTPRefererMixin, InvalidFormMixin, + CurrentUserFormMixin, ) -from userprofile.forms import UserSettingsForm +from userprofile.forms import UserSettingsForm, UserEmailForm User = auth.get_user_model() @@ -28,6 +32,19 @@ # Local Constants SETTING_UPDATE_MSG = 'User settings updated.' +VERIFY_EMAIL_SUBJECT = 'Verify your additional email address for {site}' +VERIFY_EMAIL_BODY = r''' +{user} has added this address to their +additional emails on {site}. + +Once verified, the site may use this email to send automated email for +notifications as configured in user settings. + +Please verify this email address by following this URL: +{url} + +If this was not requested by you, this message can be ignored. +'''.lstrip() class UserDetailView(LoginRequiredMixin, LoggedInPermissionMixin, TemplateView): @@ -100,3 +117,47 @@ def form_valid(self, form): ) messages.success(self.request, SETTING_UPDATE_MSG) return result + + +class UserEmailCreateView( + LoginRequiredMixin, + LoggedInPermissionMixin, + CurrentUserFormMixin, + CreateView, +): + """User additional email creation view""" + + form_class = UserEmailForm + permission_required = 'userprofile.add_email' + template_name = 'userprofile/email_form.html' + + def get_success_url(self): + subject = VERIFY_EMAIL_SUBJECT.format(site=settings.SITE_INSTANCE_TITLE) + body = VERIFY_EMAIL_BODY.format( + user=get_email_user(self.object.user), + site=settings.SITE_INSTANCE_TITLE, + url='#', # TODO + ) + try: + send_generic_mail(subject, body, [self.object.email], self.request) + messages.success( + self.request, + 'Email added. A verification message has been sent to the ' + 'address. Follow the received verification link to activate ' + 'the address.', + ) + except Exception as ex: + messages.error( + self.request, 'Failed to send verification mail: {}'.format(ex) + ) + return reverse('userprofile:detail') + + def get(self, *args, **kwargs): + if not settings.PROJECTROLES_SEND_EMAIL: + messages.warning( + self.request, + 'Email sending disabled, adding email addresses is not ' + 'allowed.', + ) + return redirect(reverse('userprofile:detail')) + return super().get(*args, **kwargs)