diff --git a/openslides/motions/static/js/motions/site.js b/openslides/motions/static/js/motions/site.js
index a802d16f0..0cc4e0fcf 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 @@
Participant number
{{ user.number }}
+ Email
+ {{ user.email }}
+ Last email send
+ {{ user.last_email_send | date: 'yyyy-MM-dd HH:mm:ss' }}
About me
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 }}
+
+ {{ alert.msg }}
+
@@ -172,7 +181,7 @@
-
@@ -414,6 +423,13 @@
{{ user.comment | limitTo:25}}{{ user.comment.length > 25 ? '...' : '' }}
+
+
+
+
+ {{ user.last_email_send | date: 'yyyy-MM-dd HH:mm:ss' }}
+
+
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