Skip to content

Commit

Permalink
add additional email management and verification (#874)
Browse files Browse the repository at this point in the history
  • Loading branch information
mikkonie committed Apr 29, 2024
1 parent 4819bb7 commit d01420c
Show file tree
Hide file tree
Showing 28 changed files with 1,538 additions and 174 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,15 @@ Added
- Optional pagination for REST API list views (#1313)
- Email notification opt-out settings (#1417)
- CC and BCC field support in sending generic emails (#415)
- ``SODARUserAdditionalEmail`` model (#874)
- ``is_source_site()`` and ``is_target_site()`` rule predicates
- **Timeline**
- ``sodar_uuid`` field in ``TimelineEventObjectRef`` model (#1415)
- REST API views (#1350)
- ``get_project()`` helpers in ``TimelineEvent`` and ``TimelineEventObjectRef`` (#1350)
- Optional pagination for REST API list views (#1313)
- **Userprofile**
- Additional email address management and verification (#874)

Changed
-------
Expand Down Expand Up @@ -101,6 +105,7 @@ Removed
- ``PROJECTROLES_HIDE_APP_LINKS`` setting (#1143)
- ``CORE_API_*`` Django settings (#1278)
- Project starring timeline event creation (#1294)
- ``user_email_additional`` app setting (#874)


v0.13.4 (2024-02-16)
Expand Down
58 changes: 35 additions & 23 deletions adminalerts/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

# Projectroles dependency
from projectroles.app_settings import AppSettingAPI
from projectroles.tests.test_models import SODARUserAdditionalEmailMixin

from adminalerts.models import AdminAlert
from adminalerts.tests.test_models import AdminAlertMixin
Expand All @@ -26,9 +27,13 @@
ALERT_DESC_UPDATED = 'Updated description'
ALERT_DESC_MARKDOWN = '## Description'
EMAIL_DESC_LEGEND = 'Additional details'
ADD_EMAIL = '[email protected]'
ADD_EMAIL2 = '[email protected]'


class AdminalertsViewTestBase(AdminAlertMixin, TestCase):
class AdminalertsViewTestBase(
AdminAlertMixin, SODARUserAdditionalEmailMixin, TestCase
):
"""Base class for adminalerts view testing"""

def _make_alert(self):
Expand Down Expand Up @@ -199,16 +204,10 @@ def test_post_multiple_users(self):
self.assertIn(EMAIL_DESC_LEGEND, mail.outbox[0].body)
self.assertIn(ALERT_DESC, mail.outbox[0].body)

def test_post_alt_email_regular_user(self):
"""Test POST with alt emails on regular user"""
alt_email = '[email protected]'
alt_email2 = '[email protected]'
app_settings.set(
'projectroles',
'user_email_additional',
';'.join([alt_email, alt_email2]),
user=self.user_regular,
)
def test_post_add_email_regular_user(self):
"""Test POST with additional emails on regular user"""
self.make_email(self.user_regular, ADD_EMAIL)
self.make_email(self.user_regular, ADD_EMAIL2)
self.assertEqual(len(mail.outbox), 0)
data = self._get_post_data()
with self.login(self.superuser):
Expand All @@ -220,28 +219,41 @@ def test_post_alt_email_regular_user(self):
[
settings.EMAIL_SENDER,
self.user_regular.email,
alt_email,
alt_email2,
ADD_EMAIL,
ADD_EMAIL2,
],
)

def test_post_alt_email_superuser(self):
"""Test POST with alt emails on superuser"""
alt_email = '[email protected]'
alt_email2 = '[email protected]'
app_settings.set(
'projectroles',
'user_email_additional',
';'.join([alt_email, alt_email2]),
user=self.superuser,
def test_post_add_email_regular_user_unverified(self):
"""Test POST with additional and unverified emails on regular user"""
self.make_email(self.user_regular, ADD_EMAIL)
self.make_email(self.user_regular, ADD_EMAIL2, verified=False)
self.assertEqual(len(mail.outbox), 0)
data = self._get_post_data()
with self.login(self.superuser):
response = self.client.post(self.url, data)
self.assertEqual(response.status_code, 302)
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(
mail.outbox[0].recipients(),
[
settings.EMAIL_SENDER,
self.user_regular.email,
ADD_EMAIL,
],
)

def test_post_add_email_superuser(self):
"""Test POST with additional emails on superuser"""
self.make_email(self.superuser, ADD_EMAIL)
self.make_email(self.superuser, ADD_EMAIL2)
self.assertEqual(len(mail.outbox), 0)
data = self._get_post_data()
with self.login(self.superuser):
response = self.client.post(self.url, data)
self.assertEqual(response.status_code, 302)
self.assertEqual(len(mail.outbox), 1)
# Superuser alt emails should not be included
# Superuser additional emails should not be included
self.assertEqual(
mail.outbox[0].recipients(),
[settings.EMAIL_SENDER, self.user_regular.email],
Expand Down
26 changes: 23 additions & 3 deletions docs/source/app_userprofile.rst
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,6 @@ Receive Email for Admin Alerts
Display Project UUID Copying Link
If set true, display a link in the project title bar for copying the project
UUID into the clipboard.
Additional Email
In addition to the default user email, also send email notifications to
these addresses.
Receive Email for Project Updates
Receive email notifications for project or category creation, updating,
moving and archiving.
Expand All @@ -98,3 +95,26 @@ Receive Email for Project Membership Updates

In the development setup, the SODAR Core example site apps also provide
additional settings for demonstrating settings features.


Additional Emails
=================

The user can configure additional emails for their user account in case they
want to receive automated emails to addresses other than their primary address.
The user profile view displays additional emails and provides controls for
managing these addresses.

.. hint::

Managing addresses is only possible on a source site. On a target site,
emails will be visible but not mofifiable.

A new additional email address can be added with a form accessible by clicking
on the :guilabel:`Add Email` button. After creation, a verification email will
be sent to the specified address. Opening a link contained in the email and
logging into the site will verify the email. Only verified email addresses will
receive automated emails from the site.

For each email address displayed in the list, there are controls to re-send the
verification email (in case of an unverified email) and deleting the address.
10 changes: 10 additions & 0 deletions docs/source/major_changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ Release Highlights
- Add timeline REST API
- Add optional pagination for REST API list views
- Add admin alert email sending to all users
- Add improved additional email address management and verification
- Add user opt-out settings for email notifications
- Add target site user UUID updating in remote sync
- Add remote sync of existing target local users
- Add remote sync of USER scope app settings
- Add checkusers management command
- Add CC and BCC field support in sending generic emails
- Rewrite sodarcache REST API views
- Rewrite user additional email storing
- Rename AppSettingAPI "app_name" arguments to "plugin_name"
- Plugin API return data updates and deprecations
- Rename timeline app models
Expand Down Expand Up @@ -235,6 +237,14 @@ Note that a dict using the ``category`` variable as a key will still be
provided for your app's search template. Hence, modifying the template should
not be required after updating the method.

User Additional Email Changes
-----------------------------

The ``user_email_additional`` app setting has been removed. Instead, additional
user email addresses can be accessed via the ``SODARUserAdditionalEmail`` model
if needed. Using ``email.get_user_addr()`` to retrieve all user email addresses
is strongly recommended.

Remote Sync User Update Changes
-------------------------------

Expand Down
12 changes: 0 additions & 12 deletions projectroles/app_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,18 +98,6 @@
'user_modifiable': True,
'global': True,
},
'user_email_additional': {
'scope': APP_SETTING_SCOPE_USER,
'type': 'STRING',
'default': '',
'placeholder': '[email protected];[email protected]',
'label': 'Additional email',
'description': 'Also send user emails to these addresses. Separate '
'multiple emails with semicolon.',
'user_modifiable': True,
'global': True,
'project_types': [PROJECT_TYPE_PROJECT],
},
'project_star': {
'scope': APP_SETTING_SCOPE_PROJECT_USER,
'type': 'BOOLEAN',
Expand Down
28 changes: 9 additions & 19 deletions projectroles/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from django.utils.timezone import localtime

from projectroles.app_settings import AppSettingAPI
from projectroles.models import SODARUserAdditionalEmail
from projectroles.utils import build_invite_url, get_display_name


Expand Down Expand Up @@ -380,31 +381,20 @@ def get_role_change_body(

def get_user_addr(user):
"""
Return all the email addresses for a user as a list. Emails set with
user_email_additional are included. If a user has no main email set but
additional emails exist, the latter are returned.
Return all the email addresses for a user as a list. Verified emails set as
SODARUserAdditionalEmail objects are included. If a user has no main email
set but additional emails exist, the latter are returned.
:param user: User object
:return: list
"""

def _validate(user, email):
if re.match(EMAIL_RE, email):
return True
logger.error(
'Invalid email for user {}: {}'.format(user.username, email)
)

ret = []
if user.email and _validate(user, user.email):
if user.email:
ret.append(user.email)
add_email = app_settings.get(
'projectroles', 'user_email_additional', user=user
)
if add_email:
for e in add_email.strip().split(';'):
if _validate(user, e):
ret.append(e)
for e in SODARUserAdditionalEmail.objects.filter(
user=user, verified=True
).order_by('email'):
ret.append(e.email)
return ret


Expand Down
79 changes: 79 additions & 0 deletions projectroles/migrations/0029_sodaruseradditionalemail.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Generated by Django 4.2.11 on 2024-04-25 11:48

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("projectroles", "0028_populate_finder_role"),
]

operations = [
migrations.CreateModel(
name="SODARUserAdditionalEmail",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("email", models.EmailField(help_text="Email address", max_length=254)),
(
"verified",
models.BooleanField(
default=False, help_text="Email verification status"
),
),
(
"secret",
models.CharField(
help_text="Secret token for email verification",
max_length=255,
unique=True,
),
),
(
"date_created",
models.DateTimeField(
auto_now_add=True, help_text="DateTime of creation"
),
),
(
"date_modified",
models.DateTimeField(
auto_now=True, help_text="DateTime of last modification"
),
),
(
"sodar_uuid",
models.UUIDField(
default=uuid.uuid4,
help_text="SODARUserAdditionalEmail SODAR UUID",
unique=True,
),
),
(
"user",
models.ForeignKey(
help_text="User for whom the email is assigned",
on_delete=django.db.models.deletion.CASCADE,
related_name="additional_emails",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["user__username", "email"],
"unique_together": {("user", "email")},
},
),
]
48 changes: 48 additions & 0 deletions projectroles/migrations/0030_populate_sodaruseradditionalemail.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Generated by Django 4.2.11 on 2024-04-25 13:14

import random
import string

from django.db import migrations


def populate_model(apps, schema_editor):
"""
Move existing additional email app settings into the
SODARUserAdditionalEmail model as verified emais. Delete the settings
objects.
"""
AppSetting = apps.get_model('projectroles', 'AppSetting')
SODARUserAdditionalEmail = apps.get_model(
'projectroles', 'SODARUserAdditionalEmail'
)
for a in AppSetting.objects.filter(
app_plugin=None, name='user_email_additional'
):
for v in a.value.split(';'):
secret = ''.join(
random.SystemRandom().choice(
string.ascii_lowercase + string.digits
)
for _ in range(32)
)
try:
SODARUserAdditionalEmail.objects.create(
user=a.user, email=v, verified=True, secret=secret
)
except Exception:
pass
a.delete()


class Migration(migrations.Migration):

dependencies = [
("projectroles", "0029_sodaruseradditionalemail"),
]

operations = [
migrations.RunPython(
populate_model, reverse_code=migrations.RunPython.noop
)
]
Loading

0 comments on commit d01420c

Please sign in to comment.