diff --git a/SciPost_v1/urls.py b/SciPost_v1/urls.py index 1e99be8d609e9b9d7f2a7c9a82a403cd1f7497d7..2db7b8fce81c5183156da1e49600730beea7466b 100644 --- a/SciPost_v1/urls.py +++ b/SciPost_v1/urls.py @@ -55,6 +55,7 @@ urlpatterns = [ url(r'^submission/', include('submissions.urls', namespace="_submissions")), url(r'^theses/', include('theses.urls', namespace="theses")), url(r'^thesis/', include('theses.urls', namespace="_theses")), + url(r'^mails/', include('mails.urls', namespace="mails")), url(r'^meetings/', include('virtualmeetings.urls', namespace="virtualmeetings")), url(r'^news/', include('news.urls', namespace="news")), url(r'^notifications/', include('notifications.urls', namespace="notifications")), diff --git a/mails/backends/filebased.py b/mails/backends/filebased.py index 3e1fad5a718eda2b05a22d9487656bcdcea4b181..30127a0cc677b76db8a54cedfeedb5d35b2d8ea0 100644 --- a/mails/backends/filebased.py +++ b/mails/backends/filebased.py @@ -51,8 +51,7 @@ class ModelEmailBackend(FileBackend): content_object = None mail_code = '' - if 'delayed_processing' in email_message.extra_headers \ - and email_message.extra_headers['delayed_processing']: + if email_message.extra_headers.get('delayed_processing', False): status = 'not_rendered' content_object = email_message.extra_headers.get('content_object', None) mail_code = email_message.extra_headers.get('mail_code', '') diff --git a/mails/core.py b/mails/core.py index f6e1b0ebea69b2010ec824eb633092084cf86d43..d901dad943a65688c9dbc0adef34565e315ea902 100644 --- a/mails/core.py +++ b/mails/core.py @@ -1,6 +1,18 @@ __copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" __license__ = "AGPL v3" +from html2text import HTML2Text +import json +import re +import inspect + +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from django.db import models +from django.template.loader import get_template + +from .exceptions import ConfigurationError + class MailEngine: """ @@ -8,8 +20,14 @@ class MailEngine: the MailLog table. """ - def __init__(self, mail_code, subject='', recipient_list=None, bcc=None, from_email='', - from_name=None, **kwargs): + _required_parameters = ['recipient_list', 'subject', 'from_email'] + _possible_parameters = ['recipient_list', 'subject', 'from_email', 'from_name', 'bcc'] + _email_fields = ['recipient_list', 'from_email', 'bcc'] + _processed_template = False + _mail_sent = False + + def __init__(self, mail_code, subject='', recipient_list=[], bcc=[], from_email='', + from_name='', context_object_name='', **kwargs): """ Start engine with specific mail_code. Any other keyword argument that is passed will be used as a variable in the mail template. @@ -27,25 +45,373 @@ class MailEngine: """ self.mail_code = mail_code self.extra_config = { - 'bcc': [], + 'bcc': bcc, 'subject': subject, 'from_name': from_name, - 'from_email': '', - 'recipient_list': [], + 'from_email': from_email, + 'recipient_list': recipient_list, + 'context_object_name': context_object_name, } - if from_email: - if not isinstance(from_email, str): - raise TypeError('"from_email" argument must be a string') - self.extra_config['from_email'] = from_email - if recipient_list: - if isinstance(recipient_list, str): - raise TypeError('"recipient_list" argument must be a list or tuple') - self.extra_config['recipient_list'] = list(recipient_list) - if bcc: - if isinstance(bcc, str): - raise TypeError('"bcc" argument must be a list or tuple') - self.extra_config['bcc'] = list(bcc) + + # # Quick check given parameters + # if from_email: + # if not isinstance(from_email, str): + # raise TypeError('"from_email" argument must be a string') + # if recipient_list and not isinstance(recipient_list, list): + # raise TypeError('"recipient_list" argument must be a list') + # if bcc and not isinstance(bcc, list): + # raise TypeError('"bcc" argument must be a list') self.template_variables = kwargs - def start(self): - return True + def __repr__(self): + return '<%(cls)s code="%(code)s", validated=%(validated)s sent=%(sent)s>' % { + 'cls': self.__class__.__name__, + 'code': self.mail_code, + 'validated': hasattr(self, 'mail_data'), + 'sent': self._mail_sent, + } + + def validate(self, render_template=False): + """Check if MailEngine is valid and ready for sending.""" + self._read_configuration_file() + self._detect_and_save_object() + self._check_template_exists() + self._validate_configuration() + if render_template: + self.render_template() + + def render_template(self, html_message=None): + """ + Render the template associated with the mail_code. If html_message is given, + use this as a template instead. + """ + if html_message: + self.mail_data['html_message'] = html_message + else: + mail_template = get_template('email/%s.html' % self.mail_code) + self.mail_data['html_message'] = mail_template.render(self.template_variables) # Damn slow. + + # Transform to non-HTML version. + handler = HTML2Text() + self.mail_data['message'] = handler.handle(self.mail_data['html_message']) + self._processed_template = True + + def send_mail(self): + """Send the mail.""" + if self._mail_sent: + # Prevent double sending when using a Django form. + return + elif not hasattr(self, 'mail_data'): + raise ValueError( + "The mail: %s could not be sent because the data didn't validate." % self.mail_code) + + email = EmailMultiAlternatives( + self.mail_data['subject'], + self.mail_data['message'], + '%s <%s>' % ( + self.mail_data.get('from_name', 'SciPost'), + self.mail_data.get('from_email', 'noreply@scipost.org')), + self.mail_data['recipient_list'], + bcc=self.mail_data['bcc'], + reply_to=[ + self.mail_data.get('from_email', 'noreply@scipost.org') + ], + headers={ + 'delayed_processing': not self._processed_template, + 'content_object': self.template_variables['object'], + 'mail_code': self.mail_code, + }) + + # Send html version if available + if 'html_message' in self.mail_data: + email.attach_alternative(self.mail_data['html_message'], 'text/html') + + email.send(fail_silently=False) + self._mail_sent = True + + if self.template_variables['object'] and hasattr(self.template_variables['object'], 'mail_sent'): + self.instance.mail_sent() + + def _detect_and_save_object(self): + """ + Detect if less than or equal to one object exists and save it, else raise exception. + Stick to Django's convention of saving it as a central `object` variable. + """ + object = None + context_object_name = None + + if 'context_object_name' in self.mail_data: + context_object_name = self.mail_data['context_object_name'] + + if 'object' in self.template_variables: + object = self.template_variables['object'] + elif 'instance' in self.template_variables: + object = self.template_variables['instance'] + elif context_object_name and context_object_name in self.template_variables: + object = self.template_variables[context_object_name] + else: + for key, var in self.template_variables.items(): + if isinstance(var, models.Model): + if object: + raise ValueError('Multiple db instances are given. Please specify which object to use.') + else: + object = var + self.template_variables['object'] = object + + if not context_object_name and isinstance(object, models.Model): + context_object_name = self.template_variables['object']._meta.model_name + + if context_object_name and object: + self.template_variables[context_object_name] = object + + + def _read_configuration_file(self): + """Retrieve default configuration for specific mail_code.""" + json_location = '%s/templates/email/%s.json' % (settings.BASE_DIR, self.mail_code) + + try: + self.mail_data = json.loads(open(json_location).read()) + except OSError: + raise ImportError('No configuration file found. Mail code: %s' % self.mail_code) + + # Check if configuration file is valid. + if 'subject' not in self.mail_data: + raise ConfigurationError('key "subject" is missing.') + if 'recipient_list' not in self.mail_data: + raise ConfigurationError('key "recipient_list" is missing.') + + # Overwrite mail data if parameters are given. + for key, val in self.extra_config.items(): + if val or key not in self.mail_data: + self.mail_data[key] = val + + def _check_template_exists(self): + """Save template or raise TemplateDoesNotExist.""" + self._template = get_template('email/%s.html' % self.mail_code) + + def _validate_configuration(self): + """Check if all required data is given via either configuration or extra parameters.""" + + # Check data is complete + if not all(key in self.mail_data for key in self._required_parameters): + txt = 'Not all required parameters are given in the configuration file or on instantiation.' + txt += ' Check required parameters: {}'.format(self._required_parameters) + raise ConfigurationError(txt) + + # Check all configuration value types + for email_key in ['subject', 'from_email', 'from_name']: + if email_key in self.mail_data and self.mail_data[email_key]: + if not isinstance(self.mail_data[email_key], str): + raise ConfigurationError('"%(key)s" argument must be a string' % { + 'key': email_key, + }) + for email_key in ['recipient_list', 'bcc']: + if email_key in self.mail_data and self.mail_data[email_key]: + if not isinstance(self.mail_data[email_key], list): + raise ConfigurationError('"%(key)s" argument must be a list' % { + 'key': email_key, + }) + + # Validate all email addresses + for email_key in self._email_fields: + if email_key in self.mail_data: + if isinstance(self.mail_data[email_key], list): + for i, email in enumerate(self.mail_data[email_key]): + self.mail_data[email_key][i] = self._validate_email_addresses(email) + else: + self.mail_data[email_key] = self._validate_email_addresses(self.mail_data[email_key]) + + def _validate_email_addresses(self, entry): + """Return email address given raw email or database relation given in `entry`.""" + if re.match("[^@]+@[^@]+\.[^@]+", entry): + # Email string + return entry + elif self.template_variables['object']: + mail_to = self.template_variables['object'] + for attr in entry.split('.'): + try: + mail_to = getattr(mail_to, attr) + if inspect.ismethod(mail_to): + mail_to = mail_to() + except AttributeError: + # Invalid property/mail + raise KeyError('The property (%s) does not exist.' % entry) + return mail_to + raise KeyError('Neither an email adress nor db instance is given.') + + # def pre_validation(self, *args, **kwargs): + # """Validate the incoming data to initiate a specific mail.""" + # self.mail_code = kwargs.pop('mail_code') + # self.instance = kwargs.pop('instance', None) + # kwargs['object'] = self.instance # Similar template nomenclature as Django. + # self.mail_data = { + # 'subject': kwargs.pop('subject', ''), + # 'to_address': kwargs.pop('to', ''), + # 'bcc_to': kwargs.pop('bcc', ''), + # 'from_address_name': kwargs.pop('from_name', 'SciPost'), + # 'from_address': kwargs.pop('from', 'no-reply@scipost.org'), + # } + # + # # Gather meta data + # json_location = '%s/templates/email/%s.json' % (settings.BASE_DIR, self.mail_code) + # + # try: + # self.mail_data.update(json.loads(open(json_location).read())) + # except OSError: + # if not self.mail_data['subject']: + # raise NotImplementedError(('You did not create a valid .html and .json file ' + # 'for mail_code: %s' % self.mail_code)) + # + # # Save central object/instance if not already + # self.instance = self.get_object(**kwargs) + # + # # Digest the templates + # if not self.delayed_processing: + # mail_template = loader.get_template('email/%s.html' % self.mail_code) + # if self.instance and self.mail_data.get('context_object'): + # kwargs[self.mail_data['context_object']] = self.instance + # self.mail_template = mail_template.render(kwargs) # Damn slow. + # + # # Gather Recipients data + # try: + # self.original_recipient = self._validate_single_entry(self.mail_data.get('to_address'))[0] + # except IndexError: + # self.original_recipient = '' + # + # self.subject = self.mail_data['subject'] + # + # def get_object(self, **kwargs): + # if self.instance: + # return self.instance + # + # if self.mail_data.get('context_object'): + # return kwargs.get(self.mail_data['context_object'], None) + # + # def _validate_single_entry(self, entry): + # """ + # entry -- raw email string or path or properties leading to email mail field + # + # Returns a list of email addresses found. + # """ + # if entry and self.instance: + # if re.match("[^@]+@[^@]+\.[^@]+", entry): + # # Email string + # return [entry] + # else: + # mail_to = self.instance + # for attr in entry.split('.'): + # try: + # mail_to = getattr(mail_to, attr) + # if inspect.ismethod(mail_to): + # mail_to = mail_to() + # except AttributeError: + # # Invalid property/mail + # return [] + # + # if not isinstance(mail_to, list): + # return [mail_to] + # else: + # return mail_to + # elif re.match("[^@]+@[^@]+\.[^@]+", entry): + # return [entry] + # else: + # return [] + # # + # def validate_bcc_list(self): + # """ + # bcc_to in the .json file may contain multiple raw email addreses or property paths to + # an email field. The different entries need to be comma separated. + # """ + # # Get recipients list. Try to send through BCC to prevent privacy issues! + # self.bcc_list = [] + # if self.mail_data.get('bcc_to'): + # for bcc_entry in self.mail_data['bcc_to'].split(','): + # self.bcc_list += self._validate_single_entry(bcc_entry) + # + # def validate_recipients(self): + # # Check the send list + # if isinstance(self.original_recipient, list): + # recipients = self.original_recipient + # elif not isinstance(self.original_recipient, str): + # try: + # recipients = list(self.original_recipient) + # except TypeError: + # recipients = [self.original_recipient] + # else: + # recipients = [self.original_recipient] + # recipients = list(recipients) + # + # # Check if email needs to be taken from an instance + # _recipients = [] + # for recipient in recipients: + # if isinstance(recipient, Contributor): + # _recipients.append(recipient.user.email) + # elif isinstance(recipient, get_user_model()): + # _recipients.append(recipient.email) + # elif isinstance(recipient, str): + # _recipients.append(recipient) + # self.recipients = _recipients + # + # def validate_message(self): + # if not self.html_message: + # self.html_message = self.mail_template + # handler = HTML2Text() + # self.message = handler.handle(self.html_message) + # + # def validate(self): + # """Execute different validation methods. + # + # Only to be used when the default data is used, eg. not in the EmailTemplateForm. + # """ + # self.validate_message() + # self.validate_bcc_list() + # self.validate_recipients() + # self.save_mail_data() + # + # def save_mail_data(self): + # """Save mail validated mail data; update default values of mail data.""" + # self.mail_data.update({ + # 'subject': self.subject, + # 'message': self.message, + # 'html_message': self.html_message, + # 'recipients': self.recipients, + # 'bcc_list': self.bcc_list, + # }) + # + # def set_alternative_sender(self, from_name, from_address): + # """TODO: REMOVE; DEPRECATED + # + # Set an alternative from address/name from the default values received from the json + # config file. The arguments only take raw string data, no methods/properties! + # """ + # self.mail_data['from_address_name'] = from_name + # self.mail_data['from_address'] = from_address + # + # def send(self): + # """Send the mail assuming `mail_data` is validated and complete.""" + # if self.mail_sent: + # # Prevent double sending when using a Django form. + # return + # + # email = EmailMultiAlternatives( + # self.mail_data['subject'], + # self.mail_data['message'], + # '%s <%s>' % (self.mail_data['from_address_name'], self.mail_data['from_address']), + # self.mail_data['recipients'], + # bcc=self.mail_data['bcc_list'], + # reply_to=[self.mail_data['from_address']], + # headers={ + # 'delayed_processing': self.delayed_processing, + # 'content_object': self.get_object(), + # 'mail_code': self.mail_code, + # }) + # + # # Send html version if available + # if 'html_message' in self.mail_data: + # email.attach_alternative(self.mail_data['html_message'], 'text/html') + # + # email.send(fail_silently=False) + # self.mail_sent = True + # + # if self.instance and hasattr(self.instance, 'mail_sent'): + # self.instance.mail_sent() diff --git a/mails/exceptions.py b/mails/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..8a765a71ac5ba34a6f3aa127adbbc1527f4c9a89 --- /dev/null +++ b/mails/exceptions.py @@ -0,0 +1,10 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +class ConfigurationError(Exception): + def __init__(self, name): + self.name = name + + def __str__(self): + return 'Configuration error: {}'.format(self.name) diff --git a/mails/forms.py b/mails/forms.py index 472ae255b8594ec19449317df834a35ce395e4e8..5ff996511459f0e0e64dcbfe5894fceab5931c07 100644 --- a/mails/forms.py +++ b/mails/forms.py @@ -4,80 +4,150 @@ __license__ = "AGPL v3" from django import forms -from .mixins import MailUtilsMixin +from .core import MailEngine from .widgets import SummernoteEditor -class EmailTemplateForm(forms.Form, MailUtilsMixin): - subject = forms.CharField(max_length=250, label="Subject*") +class EmailForm(forms.Form): + """ + This form is prefilled with data from a mail_code and is used by any user to send out + the mail after editing. + """ + + subject = forms.CharField(max_length=255, label="Subject*") text = forms.CharField(widget=SummernoteEditor, label="Text*") - extra_recipient = forms.EmailField(label="Optional: bcc this email to", required=False) + mail_field = forms.EmailField(label="Optional: bcc this email to", required=False) prefix = 'mail_form' + extra_config = {} def __init__(self, *args, **kwargs): - self.pre_validation(*args, **kwargs) + self.mail_code = kwargs.pop('mail_code') + + # Check if all exta configurations are valid. + self.extra_config.update(kwargs.pop('mail_config', {})) + + if not all(key in MailEngine._possible_parameters for key, val in self.extra_config.items()): + raise KeyError('Not all `extra_config` parameters are accepted.') # This form shouldn't be is_bound==True is there is any non-relavant POST data given. - data = args[0] if args else {} - if not data: + if len(args) > 0 and args[0]: + data = args[0] + elif 'data' in kwargs: + data = kwargs.pop('data') + else: data = {} if '%s-subject' % self.prefix in data.keys(): data = { '%s-subject' % self.prefix: data.get('%s-subject' % self.prefix), '%s-text' % self.prefix: data.get('%s-text' % self.prefix), - '%s-extra_recipient' % self.prefix: data.get('%s-extra_recipient' % self.prefix), - } - elif kwargs.get('data', False): - data = { - '%s-subject' % self.prefix: kwargs['data'].get('%s-subject' % self.prefix), - '%s-text' % self.prefix: kwargs['data'].get('%s-text' % self.prefix), - '%s-extra_recipient' % self.prefix: kwargs['data'].get('%s-extra_recipient' % self.prefix), + '%s-mail_field' % self.prefix: data.get('%s-mail_field' % self.prefix), } else: - data = None + # Reset to prevent having a false-bound form. + data = {} super().__init__(data or None) - if not self.original_recipient: - self.fields['extra_recipient'].label = "Send this email to" - self.fields['extra_recipient'].required = True - # Set the data as initials - self.fields['text'].initial = self.mail_template - self.fields['subject'].initial = self.mail_data['subject'] - - def save_data(self): - # Get text and html - self.html_message = self.cleaned_data['text'] - self.subject = self.cleaned_data['subject'] - self.validate_message() - self.validate_bcc_list() - - # Get recipients list. Try to send through BCC to prevent privacy issues! - if self.cleaned_data.get('extra_recipient') and self.original_recipient: - self.bcc_list.append(self.cleaned_data.get('extra_recipient')) - elif self.cleaned_data.get('extra_recipient') and not self.original_recipient: - self.original_recipient = [self.cleaned_data.get('extra_recipient')] - elif not self.original_recipient: - self.add_error('extra_recipient', 'Please fill the bcc field to send the mail.') - - self.validate_recipients() - self.save_mail_data() - - def clean(self): - data = super().clean() - self.save_data() - return data + self.engine = MailEngine(self.mail_code, **self.extra_config, **kwargs) + self.engine.validate(render_template=True) + self.fields['text'].initial = self.engine.mail_data['html_message'] + self.fields['subject'].initial = self.engine.mail_data['subject'] + + # if not self.original_recipient: + # self.fields['mail_field'].label = "Send this email to" + # self.fields['mail_field'].required = True + + def is_valid(self): + """Fallback used in CBVs.""" + if super().is_valid(): + try: + self.engine.validate(render_template=False) + return True + except (ImportError, KeyError): + return False + return False def save(self): - """Because Django uses .save() by default...""" - self.send() - return self.instance - - - -class HiddenDataForm(forms.Form): - def __init__(self, form, *args, **kwargs): - super().__init__(form.data, *args, **kwargs) - for name, field in form.fields.items(): - self.fields[name] = field - self.fields[name].widget = forms.HiddenInput() + self.engine.render_template(self.cleaned_data['text']) + self.engine.mail_data['subject'] = self.cleaned_data['subject'] + if self.cleaned_data['mail_field']: + self.engine.mail_data['bcc'].append(self.cleaned_data['mail_field']) + self.engine.send_mail() + return self.engine.template_variables['object'] + + +class EmailTemplateForm(forms.Form): + """Deprecated.""" + pass +# subject = forms.CharField(max_length=250, label="Subject*") +# text = forms.CharField(widget=SummernoteEditor, label="Text*") +# extra_recipient = forms.EmailField(label="Optional: bcc this email to", required=False) +# prefix = 'mail_form' +# +# def __init__(self, *args, **kwargs): +# self.pre_validation(*args, **kwargs) +# +# # This form shouldn't be is_bound==True is there is any non-relavant POST data given. +# data = args[0] if args else {} +# if not data: +# data = {} +# if '%s-subject' % self.prefix in data.keys(): +# data = { +# '%s-subject' % self.prefix: data.get('%s-subject' % self.prefix), +# '%s-text' % self.prefix: data.get('%s-text' % self.prefix), +# '%s-extra_recipient' % self.prefix: data.get('%s-extra_recipient' % self.prefix), +# } +# elif kwargs.get('data', False): +# data = { +# '%s-subject' % self.prefix: kwargs['data'].get('%s-subject' % self.prefix), +# '%s-text' % self.prefix: kwargs['data'].get('%s-text' % self.prefix), +# '%s-extra_recipient' % self.prefix: kwargs['data'].get('%s-extra_recipient' % self.prefix), +# } +# else: +# data = None +# super().__init__(data or None) +# +# if not self.original_recipient: +# self.fields['extra_recipient'].label = "Send this email to" +# self.fields['extra_recipient'].required = True +# +# # Set the data as initials +# self.fields['text'].initial = self.mail_template +# self.fields['subject'].initial = self.mail_data['subject'] +# +# def save_data(self): +# # Get text and html +# self.html_message = self.cleaned_data['text'] +# self.subject = self.cleaned_data['subject'] +# self.validate_message() +# self.validate_bcc_list() +# +# # Get recipients list. Try to send through BCC to prevent privacy issues! +# if self.cleaned_data.get('extra_recipient') and self.original_recipient: +# self.bcc_list.append(self.cleaned_data.get('extra_recipient')) +# elif self.cleaned_data.get('extra_recipient') and not self.original_recipient: +# self.original_recipient = [self.cleaned_data.get('extra_recipient')] +# elif not self.original_recipient: +# self.add_error('extra_recipient', 'Please fill the bcc field to send the mail.') +# +# self.validate_recipients() +# self.save_mail_data() +# +# def clean(self): +# data = super().clean() +# self.save_data() +# return data +# +# def save(self): +# """Because Django uses .save() by default...""" +# self.send() +# return self.instance +# +# +# +# class HiddenDataForm(forms.Form): +# def __init__(self, form, *args, **kwargs): +# super().__init__(form.data, *args, **kwargs) +# for name, field in form.fields.items(): +# self.fields[name] = field +# self.fields[name].widget = forms.HiddenInput() diff --git a/mails/tests/test_core.py b/mails/tests/test_mail_code_1.py similarity index 59% rename from mails/tests/test_core.py rename to mails/tests/test_mail_code_1.py index cb8d9db80aff0d59e52aaae1c8de3b4ce3768348..3beaf7f8e451d8d4f1168b6e981a6a6cfb70e22a 100644 --- a/mails/tests/test_core.py +++ b/mails/tests/test_mail_code_1.py @@ -1,3 +1,9 @@ +# import json +# from unittest.mock import patch, mock_open +# import tempfile + +# from django.conf import settings +from django.template.exceptions import TemplateDoesNotExist from django.test import TestCase from mails.core import MailEngine @@ -7,18 +13,16 @@ class MailLogModelTests(TestCase): """ Test the MailEngine object. """ - # def setUp(self): - # pass - def test_valid_initialisation(self): - """Test if the initialisation of the engine works properly.""" + def test_valid_instantiation(self): + """Test if init method of the engine works properly.""" # Test no mail_code given fails. with self.assertRaises(TypeError): MailEngine() # Test only mail_code given works. try: - MailEngine('test_mail_code_1') + MailEngine('tests/test_mail_code_1') except: # For whatever reason possible... self.fail('MailEngine() raised unexpectedly!') @@ -26,7 +30,7 @@ class MailLogModelTests(TestCase): # Test all extra arguments are accepted. try: MailEngine( - 'test_mail_code_1', + 'tests/test_mail_code_1', subject='Test subject A', recipient_list=['test_A@example.org', 'test_B@example.org'], bcc=['test_C@example.com', 'test_D@example.com'], @@ -37,19 +41,29 @@ class MailLogModelTests(TestCase): # Test if only proper arguments are accepted. with self.assertRaises(TypeError): - MailEngine('test_mail_code_1', recipient_list='test_A@example.org') + MailEngine('tests/test_mail_code_1', recipient_list='test_A@example.org') with self.assertRaises(TypeError): - MailEngine('test_mail_code_1', bcc='test_A@example.org') + MailEngine('tests/test_mail_code_1', bcc='test_A@example.org') with self.assertRaises(TypeError): - MailEngine('test_mail_code_1', from_email=['test_A@example.org']) + MailEngine('tests/test_mail_code_1', from_email=['test_A@example.org']) # See if any other keyword argument is accepted and saved as template variable. try: engine = MailEngine( - 'test_mail_code_1', + 'tests/test_mail_code_1', fake='Test subject A', extra=['test_A@example.org']) except KeyError: self.fail('MailEngine() does not accept extra keyword arguments!') + self.assertIs(engine.template_variables['fake'], 'Test subject A') self.assertListEqual(engine.template_variables['extra'], ['test_A@example.org']) + + def test_invalid_mail_code(self): + """Test if invalid configuration files are handled properly.""" + with self.assertRaises(ImportError): + MailEngine('tests/fake_mail_code_1') + with self.assertRaises(KeyError): + MailEngine('tests/test_mail_code_fault_1') + with self.assertRaises(TemplateDoesNotExist): + MailEngine('tests/test_mail_code_no_template_1') diff --git a/mails/urls.py b/mails/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..4e9078b13dde633367f57076b7f6e7b10d357f8d --- /dev/null +++ b/mails/urls.py @@ -0,0 +1,11 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.conf.urls import url + +from . import views + +urlpatterns = [ + url(r'^test/(?P<pk>\d+)/$', views.TestView.as_view(), name='test'), +] diff --git a/mails/utils.py b/mails/utils.py index a1c49158e249ddb54c527e654e187c312b56d3a3..6fa1ad19a89ba9a9fb0946152455960467b0b700 100644 --- a/mails/utils.py +++ b/mails/utils.py @@ -2,18 +2,16 @@ __copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" __license__ = "AGPL v3" -from .mixins import MailUtilsMixin +from .core import MailEngine -class DirectMailUtil(MailUtilsMixin): - """ - Same templates and json files as the form EmailTemplateForm, but this will directly send - the mails out, without intercepting and showing the mail editor to the user. - """ +class DirectMailUtil: + """Send a templated email directly; easiest possible way.""" - def __init__(self, mail_code, *args, **kwargs): - kwargs['mail_code'] = mail_code - kwargs['instance'] = kwargs.pop('instance', None) - self.delayed_processing = kwargs.pop('delayed_processing', False) - super().__init__(*args, **kwargs) - self.validate() + def __init__(self, mail_code, delayed_processing=True, **kwargs): + # Set the data as initials + self.engine = MailEngine(mail_code, **kwargs) + self.engine.validate(render_template=not delayed_processing) + + def send_mail(self): + return self.engine.send_mail() diff --git a/mails/views.py b/mails/views.py index 9aa031d86ea309074213fc11f82097f2d64aefc0..a004f6045c93df5895e3862622c262df5fd305ea 100644 --- a/mails/views.py +++ b/mails/views.py @@ -2,131 +2,202 @@ __copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" __license__ = "AGPL v3" -from django.contrib import messages +# from django.contrib import messages +# from django.db import models +# from django.http import HttpRequest from django.shortcuts import render from django.views.generic.edit import UpdateView -from .forms import EmailTemplateForm, HiddenDataForm +from submissions.models import Submission +from .forms import EmailForm -class MailEditingSubView(object): - alternative_from_address = None # Tuple: ('from_name', 'from_address') +class MailView(UpdateView): + """Send a templated email after being edited by user.""" - def __init__(self, request, mail_code, **kwargs): - self.request = request - self.context = kwargs.get('context', {}) - self.template_name = kwargs.get('template', 'mails/mail_form.html') - self.header_template = kwargs.get('header_template', '') - self.mail_form = EmailTemplateForm(request.POST or None, mail_code=mail_code, **kwargs) + template_name = 'mails/mail_form.html' + form_class = EmailForm + mail_code = None + mail_config = {} - @property - def recipients_string(self): - return ', '.join(getattr(self.mail_form, 'mail_data', {}).get('recipients', [''])) + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['mail_code'] = self.mail_code + kwargs['mail_config'] = self.mail_config + return kwargs - def add_form(self, form): - self.context['transfer_data_form'] = HiddenDataForm(form) + # def form_invalid(self, form): + # """If the form is valid, save the associated model.""" + # raise + # self.object = form.save() + # return super().form_valid(form) - def set_alternative_sender(self, from_name, from_address): - self.alternative_from_address = (from_name, from_address) - def is_valid(self): - return self.mail_form.is_valid() - def send(self): - if self.alternative_from_address: - self.mail_form.set_alternative_sender( - self.alternative_from_address[0], self.alternative_from_address[1]) - return self.mail_form.send() +class TestView(MailView): + """To be removed; exists for testing purposes only.""" + mail_code = 'tests/test_mail_code_1' + model = Submission + success_url = '/' - def return_render(self): - self.context['form'] = self.mail_form - self.context['header_template'] = self.header_template - if hasattr(self.mail_form, 'instance') and self.mail_form.instance: - self.context['object'] = self.mail_form.instance - else: - self.context['object'] = None - return render(self.request, self.template_name, self.context) - -class MailEditorMixin: +class MailEditorSubview: """ - Use MailEditorMixin in edit CBVs to automatically implement the mail editor as - a post-form_valid hook. + This subview works as an interrupter for function based views. - The view must specify the `mail_code` variable. + If a FBV is completed, the MailEditingSubview will interrupt the request and + provide a form that give the user the possibility to edit a template based email before + sending it. """ - object = None - mail_form = None - has_permission_to_send_mail = True - alternative_from_address = None # Tuple: ('from_name', 'from_address') - def __init__(self, *args, **kwargs): - if not self.mail_code: - raise AttributeError(self.__class__.__name__ + ' object has no attribute `mail_code`') - super().__init__(*args, **kwargs) + template_name = 'mails/mail_form.html' - def get_template_names(self): - """ - The mail editor form has its own template. - """ - if self.mail_form and not self.mail_form.is_valid(): - return ['mails/mail_form.html'] - return super().get_template_names() + def __init__(self, request, mail_code, context=None, header_template=None, **kwargs): + self.mail_code = mail_code + self.context = context or {} + self.request = request + self.header_template = header_template + self.mail_form = EmailForm(request.POST or None, mail_code=mail_code, **kwargs) + self._is_valid = False - def post(self, request, *args, **kwargs): + def interrupt(self): """ - Handle POST requests, but interpect the data if the mail form data isn't valid. - """ - if not self.has_permission_to_send_mail: - # Don't use the mail form; don't send out the mail. - return super().post(request, *args, **kwargs) - self.object = self.get_object() - form = self.get_form() - if form.is_valid(): - self.mail_form = EmailTemplateForm(request.POST or None, mail_code=self.mail_code, - instance=self.object) - if self.mail_form.is_valid(): - return self.form_valid(form) - - return self.render_to_response( - self.get_context_data(form=self.mail_form, - transfer_data_form=HiddenDataForm(form))) - else: - return self.form_invalid(form) + Interrupt request by rendering the templated email form. - def form_valid(self, form): - """ - If both the regular form and mailing form are valid, save the form and run the mail form. + The `request` should be an HttpRequest instance that should be captured + and be included into the response of the interrupted response. Currently only + POST requests are supported. """ - # Don't use the mail form; don't send out the mail. - if not self.has_permission_to_send_mail: - return super().form_valid(form) - - if self.alternative_from_address: - # Set different from address if given. - self.mail_form.set_alternative_sender( - self.alternative_from_address[0], self.alternative_from_address[1]) - - response = super().form_valid(form) - try: - self.mail_form.send() - except AttributeError: - # self.mail_form is None - raise AttributeError('Did you check the order in which MailEditorMixin is used?') - messages.success(self.request, 'Mail sent') - return response - + self.context['form'] = self.mail_form + self.context['header_template'] = self.header_template + if 'object' in self.mail_form.engine.template_variables: + self.context['object'] = self.mail_form.engine.template_variables['object'] + else: + self.context['object'] = None + return render(self.request, self.template_name, self.context) -class MailView(UpdateView): - template_name = 'mails/mail_form.html' - form_class = EmailTemplateForm + def is_valid(self): + """See if data is returned and valid.""" + self._is_valid = self.mail_form.is_valid() + return self._is_valid + + def send_mail(self): + """Send email as returned by user.""" + if not self._is_valid: + raise ValueError( + "The mail: %s could not be sent because the data didn't validate." % self.mail_code) + return self.mail_form.save() + + +class MailEditingSubView: + """Deprecated.""" + pass +# alternative_from_address = None # Tuple: ('from_name', 'from_address') +# +# def __init__(self, request, mail_code, **kwargs): +# self.request = request +# self.context = kwargs.get('context', {}) +# # self.template_name = kwargs.get('template', 'mails/mail_form.html') +# self.header_template = kwargs.get('header_template', '') +# self.mail_form = EmailForm(request.POST or None, mail_code=mail_code, **kwargs) +# +# @property +# def recipients_string(self): +# return ', '.join(getattr(self.mail_form, 'mail_data', {}).get('recipients', [''])) +# +# def add_form(self, form): +# """DEPRECATED""" +# self.context['transfer_data_form'] = HiddenDataForm(form) +# +# def set_alternative_sender(self, from_name, from_address): +# """DEPRECATED""" +# self.alternative_from_address = (from_name, from_address) +# +# def is_valid(self): +# return self.mail_form.is_valid() +# +# def send(self): +# if self.alternative_from_address: +# self.mail_form.set_alternative_sender( +# self.alternative_from_address[0], self.alternative_from_address[1]) +# return self.mail_form.send() +# +# def return_render(self): +# self.context['form'] = self.mail_form +# self.context['header_template'] = self.header_template +# if hasattr(self.mail_form, 'instance') and self.mail_form.instance: +# self.context['object'] = self.mail_form.instance +# else: +# self.context['object'] = None +# return render(self.request, self.template_name, self.context) - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs['mail_code'] = self.mail_code - return kwargs - def form_valid(self, form): - response = super().form_valid(form) - form.send() - return response +class MailEditorMixin: + """Deprecated.""" + pass + # """ + # Use MailEditorMixin in edit CBVs to automatically implement the mail editor as + # a post-form_valid hook. + # + # The view must specify the `mail_code` variable. + # """ + # object = None + # mail_form = None + # has_permission_to_send_mail = True + # alternative_from_address = None # Tuple: ('from_name', 'from_address') + # + # def __init__(self, *args, **kwargs): + # if not self.mail_code: + # raise AttributeError(self.__class__.__name__ + ' object has no attribute `mail_code`') + # super().__init__(*args, **kwargs) + # + # def get_template_names(self): + # """ + # The mail editor form has its own template. + # """ + # if self.mail_form and not self.mail_form.is_valid(): + # return ['mails/mail_form.html'] + # return super().get_template_names() + # + # def post(self, request, *args, **kwargs): + # """ + # Handle POST requests, but interpect the data if the mail form data isn't valid. + # """ + # if not self.has_permission_to_send_mail: + # # Don't use the mail form; don't send out the mail. + # return super().post(request, *args, **kwargs) + # self.object = self.get_object() + # form = self.get_form() + # if form.is_valid(): + # self.mail_form = EmailForm(request.POST or None, mail_code=self.mail_code, + # instance=self.object) + # if self.mail_form.is_valid(): + # return self.form_valid(form) + # + # return self.render_to_response( + # self.get_context_data(form=self.mail_form, + # transfer_data_form=HiddenDataForm(form))) + # else: + # return self.form_invalid(form) + # + # def form_valid(self, form): + # """ + # If both the regular form and mailing form are valid, save the form and run the mail form. + # """ + # # Don't use the mail form; don't send out the mail. + # if not self.has_permission_to_send_mail: + # return super().form_valid(form) + # + # if self.alternative_from_address: + # # Set different from address if given. + # self.mail_form.set_alternative_sender( + # self.alternative_from_address[0], self.alternative_from_address[1]) + # + # response = super().form_valid(form) + # try: + # self.mail_form.send() + # except AttributeError: + # # self.mail_form is None + # raise AttributeError('Did you check the order in which MailEditorMixin is used?') + # messages.success(self.request, 'Mail sent') + # return response diff --git a/submissions/views.py b/submissions/views.py index d9d15c3301356488b2558e8df790e68da65c98da..c232d43e2dadd4342c059a0f374a55c55cd49f16 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -54,7 +54,7 @@ from invitations.constants import STATUS_SENT from invitations.models import RegistrationInvitation from journals.models import Journal from mails.utils import DirectMailUtil -from mails.views import MailEditingSubView +from mails.views import MailEditingSubView, MailEditorSubview from ontology.models import Topic from ontology.forms import SelectTopicForm from production.forms import ProofsDecisionForm @@ -809,10 +809,10 @@ def assignment_failed(request, identifier_w_vn_nr): submission = get_object_or_404(Submission.objects.pool(request.user).unassigned(), preprint__identifier_w_vn_nr=identifier_w_vn_nr) - mail_request = MailEditingSubView( + mail_editor_view = MailEditorSubview( request, mail_code='submissions_assignment_failed', instance=submission, header_template='partials/submissions/admin/editorial_assignment_failed.html') - if mail_request.is_valid(): + if mail_editor_view.is_valid(): # Deprecate old Editorial Assignments EditorialAssignment.objects.filter(submission=submission).invited().update( status=STATUS_DEPRECATED) @@ -826,10 +826,9 @@ def assignment_failed(request, identifier_w_vn_nr): request, 'Submission {arxiv} has failed pre-screening and been rejected.'.format( arxiv=submission.preprint.identifier_w_vn_nr)) messages.success(request, 'Authors have been informed by email.') - mail_request.send() + mail_editor_view.send_mail() return redirect(reverse('submissions:pool')) - else: - return mail_request.return_render() + return mail_editor_view.interrupt() @login_required diff --git a/templates/email/submissions_assignment_failed.json b/templates/email/submissions_assignment_failed.json index 9b1051a0bc9cdfdbf1e524a93326f6e2f45b55fd..8f6266bfddcf7d2d220c4464182689269f28fb12 100644 --- a/templates/email/submissions_assignment_failed.json +++ b/templates/email/submissions_assignment_failed.json @@ -1,8 +1,11 @@ { "subject": "SciPost: pre-screening not passed", - "to_address": "submitted_by.user.email", - "bcc_to": "submissions@scipost.org", - "from_address_name": "SciPost Editorial Admin", - "from_address": "submissions@scipost.org", - "context_object": "submission" + "recipient_list": [ + "submitted_by.user.email" + ], + "bcc": [ + "submissions@scipost.org" + ], + "from_name": "SciPost Editorial Admin", + "from_email": "submissions@scipost.org" } diff --git a/templates/email/tests/test_mail_code_1.html b/templates/email/tests/test_mail_code_1.html new file mode 100644 index 0000000000000000000000000000000000000000..045ef365826e3319f8999c98141e722c2b8ab8e3 --- /dev/null +++ b/templates/email/tests/test_mail_code_1.html @@ -0,0 +1,10 @@ +<h3>Super test title</h3> +<h4>Object: {{ object }}</h4> + +<p> + Dear reader, + <br> + <strong>Thanks for reading this test mail.</strong> + <br> + <em>¡Salud!</em> +</p> diff --git a/templates/email/tests/test_mail_code_1.json b/templates/email/tests/test_mail_code_1.json new file mode 100644 index 0000000000000000000000000000000000000000..43cae8f8bdafe878a5aed5b91b5b247732e05871 --- /dev/null +++ b/templates/email/tests/test_mail_code_1.json @@ -0,0 +1,5 @@ +{ + "subject": "SciPost Test", + "recipient_list": ["test@scipost.org"], + "from_email": "admin@scipost.org" +} diff --git a/templates/email/tests/test_mail_code_fault_1.json b/templates/email/tests/test_mail_code_fault_1.json new file mode 100644 index 0000000000000000000000000000000000000000..48ed00ad03592db18fc74d2b1dc7fbec47d8850b --- /dev/null +++ b/templates/email/tests/test_mail_code_fault_1.json @@ -0,0 +1,4 @@ +{ + "subject": "SciPost Test", + "from_email": "admin@scipost.org" +} diff --git a/templates/email/tests/test_mail_code_no_template_1.json b/templates/email/tests/test_mail_code_no_template_1.json new file mode 100644 index 0000000000000000000000000000000000000000..43cae8f8bdafe878a5aed5b91b5b247732e05871 --- /dev/null +++ b/templates/email/tests/test_mail_code_no_template_1.json @@ -0,0 +1,5 @@ +{ + "subject": "SciPost Test", + "recipient_list": ["test@scipost.org"], + "from_email": "admin@scipost.org" +}