Send invitation emails with OpenSlides login.

This commit is contained in:
FinnStutzenstein 2017-11-28 10:47:29 +01:00 committed by Emanuel Schütze
parent a34ad1485a
commit 2220112d27
22 changed files with 307 additions and 18 deletions

View File

@ -71,6 +71,7 @@ Users:
default is now disabled [#3400].
- Hide password in change password view [#3417].
- Added a change presence view [#3496].
- New feature to send invitation emails with OpenSlides login [#3503].
Core:
- No reload on logoff. OpenSlides is now a full single page

View File

@ -159,7 +159,7 @@
<div class="col-xs-11 main-header">
<span class="form-inline text-right pull-right">
<!-- clear all filters -->
<span class="sort-spacer pointer" ng-click="filter.reset()"
<span class="sort-spacer pointer" ng-click="filter.reset(isSelectMode)"
ng-if="filter.areFiltersSet()" ng-disabled="isSelectMode"
ng-class="{'disabled': isSelectMode}">
<i class="fa fa-times-circle"></i>

View File

@ -93,7 +93,7 @@
<div class="col-xs-11 main-header">
<span class="form-inline text-right pull-right">
<!-- clear all filters -->
<span class="sort-spacer pointer" ng-click="filter.reset()"
<span class="sort-spacer pointer" ng-click="filter.reset(isSelectMode)"
ng-if="filter.areFiltersSet()" ng-disabled="isSelectMode"
ng-class="{'disabled': isSelectMode}">
<i class="fa fa-times-circle"></i>

View File

@ -709,8 +709,9 @@ angular.module('OpenSlidesApp.core', [
message += gettextCatalog.getString("The server didn't respond.");
} else if (error.data.detail) {
message += error.data.detail;
} else if (error.status === 500) {
message += gettextCatalog.getString("A server error occurred. Please check the system logs.");
} else if (error.status > 500) { // Some kind of server error.
message += gettextCatalog.getString("A server error occurred (%%code%%). Please check the system logs.");
message = message.replace('%%code%%', error.status);
} else {
for (var e in error.data) {
message += e + ': ' + error.data[e] + ' ';

View File

@ -535,7 +535,10 @@ angular.module('OpenSlidesApp.core.site', [
areFiltersSet = areFiltersSet || (self.filterString !== '');
return areFiltersSet !== false;
};
self.reset = function () {
self.reset = function (danger) {
if (danger) {
return;
}
_.forEach(self.multiselectFilters, function (filterList, filter) {
self.multiselectFilters[filter] = [];
});

View File

@ -47,6 +47,11 @@ TEMPLATES = [
},
]
# Email
# https://docs.djangoproject.com/en/1.10/topics/email/
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_TIMEOUT = 5 # Timeout in seconds for blocking operations like the connection attempt
# Internationalization
# https://docs.djangoproject.com/en/1.10/topics/i18n/

View File

@ -160,7 +160,7 @@
<div class="col-xs-11 main-header">
<span class="form-inline text-right pull-right">
<!-- reset Filters -->
<span class="sort-spacer pointer" ng-click="filter.reset()"
<span class="sort-spacer pointer" ng-click="filter.reset(isSelectMode)"
ng-if="filter.areFiltersSet()" ng-disabled="isSelectMode"
ng-class="{'disabled': isSelectMode}">
<i class="fa fa-times-circle"></i>

View File

@ -1165,8 +1165,8 @@ angular.module('OpenSlidesApp.motions.site', [
$scope.filter.operateMultiselectFilter('state', id, danger);
updateStateFilter();
};
$scope.resetFilters = function () {
$scope.filter.reset();
$scope.resetFilters = function (danger) {
$scope.filter.reset(danger);
updateStateFilter();
};
// Sorting

View File

@ -396,7 +396,7 @@
<!-- show all selected multiselectoptions -->
<div>
<!-- clear all filters -->
<span class="spacer-left-lg pointer" ng-click="resetFilters()"
<span class="spacer-left-lg pointer" ng-click="resetFilters(isSelectMode)"
ng-if="filter.areFiltersSet()" ng-disabled="isSelectMode"
ng-class="{'disabled': isSelectMode}">
<i class="fa fa-times-circle"></i>

View File

@ -1,3 +1,5 @@
from textwrap import dedent
from openslides.core.config import ConfigVariable
@ -90,3 +92,43 @@ def get_config_variables():
weight=570,
group='Participants',
subgroup='PDF')
# Email
yield ConfigVariable(
name='users_email_sender',
default_value='noreply@yourdomain.com',
input_type='string',
label='Email sender',
weight=600,
group='Participants',
subgroup='Email')
yield ConfigVariable(
name='users_email_subject',
default_value='Your login for {event_name}',
input_type='string',
label='Email subject',
help_text='You can use {event_name} as a placeholder.',
weight=605,
group='Participants',
subgroup='Email')
yield ConfigVariable(
name='users_email_body',
default_value=dedent('''\
Dear {name},
this is your OpenSlides login for the event "{event_name}":
{url}
username: {username}
password: {password}
This email was generated automatically.'''),
input_type='text',
label='Email body',
help_text='Use these placeholders: {name}, {event_name}, {url}, {username}, {password}. The url referrs to the system url.',
weight=610,
group='Participants',
subgroup='Email')

View File

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.8 on 2017-11-28 08:15
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0005_personalnote_rework'),
]
operations = [
migrations.AddField(
model_name='user',
name='email',
field=models.EmailField(blank=True, max_length=254),
),
migrations.AddField(
model_name='user',
name='last_email_send',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@ -1,3 +1,5 @@
import smtplib
from random import choice
from django.contrib.auth.hashers import make_password
@ -9,10 +11,14 @@ from django.contrib.auth.models import (
Permission,
PermissionsMixin,
)
from django.core import mail
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Prefetch, Q
from django.utils import timezone
from jsonfield import JSONField
from ..core.config import config
from ..core.models import Projector
from ..utils.collection import CollectionElement
from ..utils.models import RESTModelMixin
@ -136,6 +142,12 @@ class User(RESTModelMixin, PermissionsMixin, AbstractBaseUser):
max_length=255,
blank=True)
email = models.EmailField(blank=True)
last_email_send = models.DateTimeField(
blank=True,
null=True)
# TODO: Try to remove the default argument in the following fields.
structure_level = models.CharField(
@ -228,6 +240,53 @@ class User(RESTModelMixin, PermissionsMixin, AbstractBaseUser):
"""
raise RuntimeError('Do not use user.has_perm() but use openslides.utils.auth.has_perm')
def send_invitation_email(self, connection, skip_autoupdate=False):
"""
Sends an invitation email to the users. Returns True on success, False on failiure.
May raise an ValidationError, if something went wrong.
"""
if not self.email:
return False
# Custom dict class that for formatstrings with entries like {not_existent}
# no error is raised and this is replaced with ''.
class format_dict(dict):
def __missing__(self, key):
return ''
message_format = format_dict({
'name': str(self),
'event_name': config['general_event_name'],
'url': config['users_pdf_url'],
'username': self.username,
'password': self.default_password})
message = config['users_email_body'].format(**message_format)
subject_format = format_dict({'event_name': config['general_event_name']})
subject = config['users_email_subject'].format(**subject_format)
# Create an email and send it.
email = mail.EmailMessage(subject, message, config['users_email_sender'], [self.email])
try:
count = connection.send_messages([email])
except smtplib.SMTPDataError as e:
error = e.smtp_code
helptext = ''
if error == 554:
helptext = ' Is the email sender correct?'
connection.close()
raise ValidationError({'detail': 'Error {}. Cannot send email.{}'.format(error, helptext)})
except smtplib.SMTPRecipientsRefused:
pass # Run into returning false later
else:
if count == 1:
self.email_send = True
self.last_email_send = timezone.now()
self.save(skip_autoupdate=skip_autoupdate)
return True
return False
class GroupManager(_GroupManager):
"""

View File

@ -29,6 +29,8 @@ USERCANSEESERIALIZER_FIELDS = (
USERCANSEEEXTRASERIALIZER_FIELDS = USERCANSEESERIALIZER_FIELDS + (
'email',
'last_email_send',
'comment',
'is_active',
)
@ -51,6 +53,7 @@ class UserFullSerializer(ModelSerializer):
class Meta:
model = User
fields = USERCANSEEEXTRASERIALIZER_FIELDS + ('default_password',)
read_only_fields = ('last_email_send',)
def validate(self, data):
"""

View File

@ -12,7 +12,7 @@ angular.module('OpenSlidesApp.users.csv', [])
function ($filter, Group, gettextCatalog, CsvDownload) {
var makeHeaderline = function () {
var headerline = ['Title', 'Given name', 'Surname', 'Structure level', 'Participant number', 'Groups',
'Comment', 'Is active', 'Is present', 'Is a committee', 'Initial password'];
'Comment', 'Is active', 'Is present', 'Is a committee', 'Initial password', 'Email'];
return _.map(headerline, function (entry) {
return gettextCatalog.getString(entry);
});
@ -38,6 +38,7 @@ angular.module('OpenSlidesApp.users.csv', [])
row.push(user.is_present ? '1' : '0');
row.push(user.is_committee ? '1' : '0');
row.push('"' + user.default_password + '"');
row.push('"' + user.email + '"');
csvRows.push(row);
});
CsvDownload(csvRows, 'users-export.csv');
@ -58,10 +59,10 @@ angular.module('OpenSlidesApp.users.csv', [])
var csvRows = [makeHeaderline(),
// example entries
['Dr.', 'Max', 'Mustermann', 'Berlin','1234567890', csvGroups, 'xyz', '1', '1', '', ''],
['', 'John', 'Doe', 'Washington','75/99/8-2', csvGroup, 'abc', '1', '1', '', ''],
['', 'Fred', 'Bloggs', 'London', '', '', '', '', '', '', ''],
['', '', 'Executive Board', '', '', '', '', '', '', '1', ''],
['Dr.', 'Max', 'Mustermann', 'Berlin','1234567890', csvGroups, 'xyz', '1', '1', '', 'initialPassword', ''],
['', 'John', 'Doe', 'Washington','75/99/8-2', csvGroup, 'abc', '1', '1', '', '', 'john.doe@email.com'],
['', 'Fred', 'Bloggs', 'London', '', '', '', '', '', '', '', ''],
['', '', 'Executive Board', '', '', '', '', '', '', '1', '', ''],
];
CsvDownload(csvRows, 'users-example.csv');

View File

@ -306,6 +306,13 @@ angular.module('OpenSlidesApp.users.site', [
}
]
},
{
key: 'email',
type: 'input',
templateOptions: {
label: gettextCatalog.getString('Email')
},
},
{
className: "row",
fieldGroup: [
@ -457,6 +464,13 @@ angular.module('OpenSlidesApp.users.site', [
required: true
},
},
{
key: 'email',
type: 'input',
templateOptions: {
label: gettextCatalog.getString('Email')
},
},
{
key: 'about_me',
type: 'editor',
@ -621,6 +635,8 @@ angular.module('OpenSlidesApp.users.site', [
display_name: gettext('Structure level')},
{name: 'comment',
display_name: gettext('Comment')},
{name: 'last_email_send',
display_name: gettext('Last email send')},
];
// pagination
@ -735,6 +751,33 @@ angular.module('OpenSlidesApp.users.site', [
User.save(user);
});
};
// Send invitation emails
$scope.sendInvitationEmails = function () {
var user_ids = _
.chain($scope.usersFiltered)
.filter(function (user) {
return user.selected;
})
.map(function (user) {
return user.id;
})
.value();
$http.post('/rest/users/user/mass_invite_email/', {
user_ids: user_ids,
}).then(function (success) {
$scope.alert = {
msg: gettextCatalog.getString('Send %num% emails sucessfully.').replace('%num%', success.data.count),
type: 'success',
show: true,
};
$scope.isSelectMode = false;
$scope.uncheckAll();
}, function (error) {
$scope.alert = ErrorMessage.forAlert(error);
$scope.isSelectMode = false;
$scope.uncheckAll();
});
};
// Export as PDF
$scope.pdfExportUserList = function () {
@ -1077,7 +1120,7 @@ angular.module('OpenSlidesApp.users.site', [
};
var FIELDS = ['title', 'first_name', 'last_name', 'structure_level', 'number',
'groups', 'comment', 'is_active', 'is_present', 'is_committee', 'default_password'];
'groups', 'comment', 'is_active', 'is_present', 'is_committee', 'default_password', 'email'];
$scope.users = [];
$scope.onCsvChange = function (csv) {
$scope.csvImporting = false;
@ -1721,6 +1764,14 @@ angular.module('OpenSlidesApp.users.site', [
gettext('WEP');
gettext('WPA/WPA2');
gettext('No encryption');
gettext('Email');
gettext('Email sender');
gettext('Email subject');
gettext('Your login for {event_name}');
gettext('You can use {event_name} as a placeholder.');
gettext('Email body');
gettext('Dear {name},\n\nthis is your OpenSlides login for the event "{event_name}":\n {url}\n username: {username}\n password: {password}\n\nThis email was generated automatically.');
gettext('Use these placeholders: {name}, {event_name}, {url}, {username}, {password}. The url referrs to the system url.');
}
]);

View File

@ -32,6 +32,10 @@
</div>
<label translate>Participant number</label>
{{ user.number }}
<label translate>Email</label>
{{ user.email }}
<label ng-if="user.last_email_send" translate>Last email send</label>
{{ user.last_email_send | date: 'yyyy-MM-dd HH:mm:ss' }}
<label translate>About me</label>
<div ng-bind-html="user.about_me | trusted"></div>
</fieldset>

View File

@ -58,6 +58,7 @@
<translate>Is present</translate>,
<translate>Is committee</translate>,
<translate>Initial password</translate>
<translate>Email</translate>
</code>
<li translate>At least given name or surname have to be filled in. All
other fields are optional and may be empty.
@ -83,7 +84,8 @@
<th translate>Is active
<th translate>Is present
<th translate>Is committee
<th translate>Initial password</th>
<th translate>Initial password
<th translate>Email
<th ng-if="duplicates > 0">
<i class="fa fa-exclamation-triangle text-danger"></i>
<strong class="text-danger" ng-if="duplicates == 1">1 <translate>duplicate</translate></strong>
@ -158,6 +160,8 @@
ng-click="user.is_committee = !user.is_committee"></i>
<td>
{{ user.default_password }}
<td>
{{ user.email }}
<td ng-if="duplicates > 0">
<div ng-if="user.duplicate" uib-tooltip="{{ user.duplicate_info }}" uib-dropdown>
<button id="UserAction{{ $index }}" type="button" class="btn btn-default btn-sm"

View File

@ -25,6 +25,9 @@
</div>
<div class="details">
<div uib-alert ng-show="alert.show" ng-class="'alert-' + (alert.type || 'warning')" close="alert={}">
{{ alert.msg }}
</div>
<div class="row">
<div class="col-sm-6">
<!-- select mode -->
@ -80,6 +83,7 @@
<option value="is_active" translate>Set/Unset 'is active'</option>
<option value="is_present" translate>Set/Unset 'is present'</option>
<option value="is_committee" translate>Set/Unset 'is a committee'</option>
<option value="send_invite_email" translate>Send invitation emails</option>
</select>
<!-- delete button -->
<a ng-show="selectedAction == 'delete'"
@ -129,6 +133,11 @@
<span ng-if="selectedAction == 'is_present'" translate>Is not present</span>
<span ng-if="selectedAction == 'is_committee'" translate>Is not a committee</span>
</a>
<!-- send_invite_email -->
<a ng-show="selectedAction == 'send_invite_email'" class="btn btn-default btn-sm"
ng-click="sendInvitationEmails()">
<translate>Send invitation emails</translate>
</a>
</div>
</div>
@ -172,7 +181,7 @@
<div class="col-xs-11 main-header">
<span class="form-inline text-right pull-right">
<!-- reset Filters -->
<span class="sort-spacer pointer" ng-click="filter.reset()"
<span class="sort-spacer pointer" ng-click="filter.reset(isSelectMode)"
ng-if="filter.areFiltersSet()" ng-disabled="isSelectMode"
ng-class="{'disabled': isSelectMode}">
<i class="fa fa-times-circle"></i>
@ -414,6 +423,13 @@
{{ user.comment | limitTo:25}}{{ user.comment.length > 25 ? '...' : '' }}
</div>
</div>
<div os-perms="users.can_manage" ng-show="user.last_email_send">
<div uib-tooltip="{{ 'Last email send to the user' | translate }}" tooltip-placement="top-left">
<i class="fa fa-envelope"></i>
{{ user.last_email_send | date: 'yyyy-MM-dd HH:mm:ss' }}
</div>
</div>
</small>
</div>
<div style="width: 40%;" class="pull-right" os-perms="users.can_see_extra_data">

View File

@ -1,10 +1,13 @@
import smtplib
from typing import List # noqa
from django.conf import settings
from django.contrib.auth import login as auth_login
from django.contrib.auth import logout as auth_logout
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.password_validation import validate_password
from django.core import mail
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction
from django.utils.encoding import force_text
@ -59,7 +62,7 @@ class UserViewSet(ModelViewSet):
result = has_perm(self.request.user, 'users.can_see_name')
elif self.action in ('update', 'partial_update'):
result = self.request.user.is_authenticated()
elif self.action in ('create', 'destroy', 'reset_password', 'mass_import'):
elif self.action in ('create', 'destroy', 'reset_password', 'mass_import', 'mass_invite_email'):
result = (has_perm(self.request.user, 'users.can_see_name') and
has_perm(self.request.user, 'users.can_see_extra_data') and
has_perm(self.request.user, 'users.can_manage'))
@ -165,6 +168,44 @@ class UserViewSet(ModelViewSet):
'detail': _('{number} users successfully imported.').format(number=len(created_users)),
'importedTrackIds': imported_track_ids})
@list_route(methods=['post'])
def mass_invite_email(self, request):
"""
Endpoint to send invitation emails to all given users (by id). Returns the
number of emails send.
"""
user_ids = request.data.get('user_ids')
if not isinstance(user_ids, list):
raise ValidationError({'detail': 'User_ids has to be a list.'})
for user_id in user_ids:
if not isinstance(user_id, int):
raise ValidationError({'detail': 'User_id has to be an int.'})
users = User.objects.filter(pk__in=user_ids)
# Sending Emails. Keep track, which users gets an email.
# First, try to open the connection to the smtp server.
connection = mail.get_connection(fail_silently=False)
try:
connection.open()
except ConnectionRefusedError:
raise ValidationError({'detail': 'Cannot connect to SMTP server on {}:{}'.format(
settings.EMAIL_HOST,
settings.EMAIL_PORT)})
except smtplib.SMTPException as e:
raise ValidationError({'detail': '{}: {}'.format(e.errno, e.strerror)})
success_users = []
try:
for user in users:
if user.send_invitation_email(connection, skip_autoupdate=True):
success_users.append(user)
except DjangoValidationError as e:
raise ValidationError(e.message_dict)
connection.close()
inform_changed_data(success_users)
return Response({'count': len(success_users)})
class GroupViewSetMetadata(SimpleMetadata):
"""

View File

@ -43,6 +43,13 @@ SECRET_KEY = %(secret_key)r
DEBUG = %(debug)s
# Email settings
# For SSL/TLS specific settings see https://docs.djangoproject.com/en/1.11/topics/email/#smtp-backend
EMAIL_HOST = 'localhost'
EMAIL_PORT = 587
EMAIL_HOST_USER = ''
EMAIL_HOST_PASSWORD = ''
# Database
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases

View File

@ -1,3 +1,4 @@
from django.core import mail
from django.core.urlresolvers import reverse
from django_redis import get_redis_connection
from rest_framework import status
@ -336,6 +337,30 @@ class UserMassImport(TestCase):
self.assertEqual(User.objects.count(), 3)
class UserSendIntivationEmail(TestCase):
"""
Tests sending an email to the user.
"""
email = "admin@test-domain.com"
def setUp(self):
self.client = APIClient()
self.client.login(username='admin', password='admin')
self.admin = User.objects.get()
self.admin.email = self.email
self.admin.save()
def test_email_sending(self):
response = self.client.post(
reverse('user-mass-invite-email'),
{'user_ids': [self.admin.pk]},
format='json')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['count'], 1)
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].to[0], self.email)
class GroupMetadata(TestCase):
def test_options_request_as_anonymous_user_activated(self):
config['general_system_enable_anonymous'] = True

View File

@ -10,6 +10,7 @@ from openslides.global_settings import * # noqa
OPENSLIDES_USER_DATA_PATH = os.path.realpath(os.path.dirname(os.path.abspath(__file__)))
EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend'
# OpenSlides plugins