diff --git a/CHANGELOG b/CHANGELOG index e72beb753..19a03d83f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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 diff --git a/openslides/agenda/static/templates/agenda/item-list.html b/openslides/agenda/static/templates/agenda/item-list.html index a1a881326..02688c2a2 100644 --- a/openslides/agenda/static/templates/agenda/item-list.html +++ b/openslides/agenda/static/templates/agenda/item-list.html @@ -159,7 +159,7 @@
- diff --git a/openslides/assignments/static/templates/assignments/assignment-list.html b/openslides/assignments/static/templates/assignments/assignment-list.html index e396c2a23..b9a69cab7 100644 --- a/openslides/assignments/static/templates/assignments/assignment-list.html +++ b/openslides/assignments/static/templates/assignments/assignment-list.html @@ -93,7 +93,7 @@
- diff --git a/openslides/core/static/js/core/base.js b/openslides/core/static/js/core/base.js index 5ae46342e..8c4651243 100644 --- a/openslides/core/static/js/core/base.js +++ b/openslides/core/static/js/core/base.js @@ -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] + ' '; diff --git a/openslides/core/static/js/core/site.js b/openslides/core/static/js/core/site.js index 7b0f19a54..ef93192a2 100644 --- a/openslides/core/static/js/core/site.js +++ b/openslides/core/static/js/core/site.js @@ -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] = []; }); diff --git a/openslides/global_settings.py b/openslides/global_settings.py index 2487d7d68..efbc8f9a7 100644 --- a/openslides/global_settings.py +++ b/openslides/global_settings.py @@ -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/ diff --git a/openslides/mediafiles/static/templates/mediafiles/mediafile-list.html b/openslides/mediafiles/static/templates/mediafiles/mediafile-list.html index f79f2fadb..256d18d10 100644 --- a/openslides/mediafiles/static/templates/mediafiles/mediafile-list.html +++ b/openslides/mediafiles/static/templates/mediafiles/mediafile-list.html @@ -160,7 +160,7 @@
- diff --git a/openslides/motions/static/js/motions/site.js b/openslides/motions/static/js/motions/site.js index 3a6d4e985..a0ae293db 100644 --- a/openslides/motions/static/js/motions/site.js +++ b/openslides/motions/static/js/motions/site.js @@ -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 diff --git a/openslides/motions/static/templates/motions/motion-list.html b/openslides/motions/static/templates/motions/motion-list.html index e7ef28a84..f3c7bdc46 100644 --- a/openslides/motions/static/templates/motions/motion-list.html +++ b/openslides/motions/static/templates/motions/motion-list.html @@ -396,7 +396,7 @@
- diff --git a/openslides/users/config_variables.py b/openslides/users/config_variables.py index 09322fb56..eaf70a21b 100644 --- a/openslides/users/config_variables.py +++ b/openslides/users/config_variables.py @@ -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') diff --git a/openslides/users/migrations/0006_user_email.py b/openslides/users/migrations/0006_user_email.py new file mode 100644 index 000000000..4c5f800f5 --- /dev/null +++ b/openslides/users/migrations/0006_user_email.py @@ -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), + ), + ] diff --git a/openslides/users/models.py b/openslides/users/models.py index d722544d6..0fdba625b 100644 --- a/openslides/users/models.py +++ b/openslides/users/models.py @@ -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): """ diff --git a/openslides/users/serializers.py b/openslides/users/serializers.py index 7ec58f599..708e0dbd2 100644 --- a/openslides/users/serializers.py +++ b/openslides/users/serializers.py @@ -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): """ diff --git a/openslides/users/static/js/users/csv.js b/openslides/users/static/js/users/csv.js index 4558eaa91..74ce706ca 100644 --- a/openslides/users/static/js/users/csv.js +++ b/openslides/users/static/js/users/csv.js @@ -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'); diff --git a/openslides/users/static/js/users/site.js b/openslides/users/static/js/users/site.js index 9ceb5bcab..34be843af 100644 --- a/openslides/users/static/js/users/site.js +++ b/openslides/users/static/js/users/site.js @@ -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.'); } ]); diff --git a/openslides/users/static/templates/users/user-detail.html b/openslides/users/static/templates/users/user-detail.html index d9d617fe4..a60450e84 100644 --- a/openslides/users/static/templates/users/user-detail.html +++ b/openslides/users/static/templates/users/user-detail.html @@ -32,6 +32,10 @@
{{ user.number }} + + {{ user.email }} + + {{ user.last_email_send | date: 'yyyy-MM-dd HH:mm:ss' }}
diff --git a/openslides/users/static/templates/users/user-import.html b/openslides/users/static/templates/users/user-import.html index 4b7926a2f..4c4d81343 100644 --- a/openslides/users/static/templates/users/user-import.html +++ b/openslides/users/static/templates/users/user-import.html @@ -58,6 +58,7 @@ Is present, Is committee, Initial password + Email
  • At least given name or surname have to be filled in. All other fields are optional and may be empty. @@ -83,7 +84,8 @@ Is active Is present Is committee - Initial password + Initial password + Email 1 duplicate @@ -158,6 +160,8 @@ ng-click="user.is_committee = !user.is_committee"> {{ user.default_password }} + + {{ user.email }}
    diff --git a/openslides/users/views.py b/openslides/users/views.py index 926de14d1..6f9188508 100644 --- a/openslides/users/views.py +++ b/openslides/users/views.py @@ -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): """ diff --git a/openslides/utils/settings.py.tpl b/openslides/utils/settings.py.tpl index 41d5b548d..e30afbd78 100644 --- a/openslides/utils/settings.py.tpl +++ b/openslides/utils/settings.py.tpl @@ -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 diff --git a/tests/integration/users/test_viewset.py b/tests/integration/users/test_viewset.py index caaa76fdf..9f932457e 100644 --- a/tests/integration/users/test_viewset.py +++ b/tests/integration/users/test_viewset.py @@ -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 diff --git a/tests/settings.py b/tests/settings.py index 2b5f2c588..92bfecd69 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -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