diff --git a/mayan/apps/common/forms.py b/mayan/apps/common/forms.py index bf472f0e25..d90e5184cb 100644 --- a/mayan/apps/common/forms.py +++ b/mayan/apps/common/forms.py @@ -6,6 +6,7 @@ from django import forms from django.conf import settings from django.contrib.auth import get_user_model from django.db import models +from django.utils.module_loading import import_string from django.utils.translation import ugettext_lazy as _ from .classes import Filter, Package @@ -83,6 +84,35 @@ class DetailForm(forms.ModelForm): ) +class DynamicFormMixin(object): + def __init__(self, *args, **kwargs): + self.schema = kwargs.pop('schema') + super(DynamicFormMixin, self).__init__(*args, **kwargs) + for field in self.schema['fields']: + field_class = import_string(field['class']) + kwargs = { + 'label': field['label'], + 'required': field.get('required', True), + 'initial': field.get('default', None), + 'help_text': field.get('help_text'), + } + kwargs.update(field.get('kwargs', {})) + self.fields[field['name']] = field_class(**kwargs) + + for field, widget in self.schema.get('widgets', {}).items(): + self.fields[field].widget = import_string( + widget['class'] + )(**widget.get('kwargs', {})) + + +class DynamicForm(DynamicFormMixin, forms.Form): + pass + + +class DynamicModelForm(DynamicFormMixin, forms.ModelForm): + pass + + class FileDisplayForm(forms.Form): text = forms.CharField( label='', diff --git a/mayan/apps/common/generics.py b/mayan/apps/common/generics.py index e3d6b3fe93..064b5f7622 100644 --- a/mayan/apps/common/generics.py +++ b/mayan/apps/common/generics.py @@ -19,10 +19,10 @@ from django_downloadview import ( ) from pure_pagination.mixins import PaginationMixin -from .forms import ChoiceForm +from .forms import ChoiceForm, DynamicForm from .mixins import ( - DeleteExtraDataMixin, ExtraContextMixin, FormExtraKwargsMixin, - MultipleObjectMixin, ObjectActionMixin, + DeleteExtraDataMixin, DynamicFormViewMixin, ExtraContextMixin, + FormExtraKwargsMixin, MultipleObjectMixin, ObjectActionMixin, ObjectListPermissionFilterMixin, ObjectNameMixin, ObjectPermissionCheckMixin, RedirectionMixin, ViewPermissionCheckMixin @@ -186,10 +186,14 @@ class ConfirmView(ObjectListPermissionFilterMixin, ObjectPermissionCheckMixin, V return HttpResponseRedirect(self.get_success_url()) -class FormView(FormExtraKwargsMixin, ViewPermissionCheckMixin, ExtraContextMixin, RedirectionMixin, DjangoFormView): +class FormView(ViewPermissionCheckMixin, ExtraContextMixin, RedirectionMixin, FormExtraKwargsMixin, DjangoFormView): template_name = 'appearance/generic_form.html' +class DynamicFormView(DynamicFormViewMixin, FormView): + pass + + class MultiFormView(DjangoFormView): prefix = None prefixes = {} @@ -302,7 +306,7 @@ class SimpleView(ViewPermissionCheckMixin, ExtraContextMixin, TemplateView): pass -class SingleObjectCreateView(ObjectNameMixin, ViewPermissionCheckMixin, ExtraContextMixin, RedirectionMixin, CreateView): +class SingleObjectCreateView(ObjectNameMixin, ViewPermissionCheckMixin, ExtraContextMixin, RedirectionMixin, FormExtraKwargsMixin, CreateView): template_name = 'appearance/generic_form.html' def form_valid(self, form): @@ -344,6 +348,10 @@ class SingleObjectCreateView(ObjectNameMixin, ViewPermissionCheckMixin, ExtraCon return HttpResponseRedirect(self.get_success_url()) +class SingleObjectDynamicFormCreateView(DynamicFormViewMixin, SingleObjectCreateView): + pass + + class SingleObjectDeleteView(ObjectNameMixin, DeleteExtraDataMixin, ViewPermissionCheckMixin, ObjectPermissionCheckMixin, ExtraContextMixin, RedirectionMixin, DeleteView): template_name = 'appearance/generic_confirm.html' @@ -394,7 +402,7 @@ class SingleObjectDownloadView(ViewPermissionCheckMixin, ObjectPermissionCheckMi VirtualFile = VirtualFile -class SingleObjectEditView(ObjectNameMixin, ViewPermissionCheckMixin, ObjectPermissionCheckMixin, ExtraContextMixin, RedirectionMixin, UpdateView): +class SingleObjectEditView(ObjectNameMixin, ViewPermissionCheckMixin, ObjectPermissionCheckMixin, ExtraContextMixin, FormExtraKwargsMixin, RedirectionMixin, UpdateView): template_name = 'appearance/generic_form.html' def form_valid(self, form): @@ -446,6 +454,10 @@ class SingleObjectEditView(ObjectNameMixin, ViewPermissionCheckMixin, ObjectPerm return obj +class SingleObjectDynamicFormEditView(DynamicFormViewMixin, SingleObjectEditView): + pass + + class SingleObjectListView(PaginationMixin, ViewPermissionCheckMixin, ObjectListPermissionFilterMixin, ExtraContextMixin, RedirectionMixin, ListView): template_name = 'appearance/generic_list.html' diff --git a/mayan/apps/common/mixins.py b/mayan/apps/common/mixins.py index 0a04321e20..712b22c343 100644 --- a/mayan/apps/common/mixins.py +++ b/mayan/apps/common/mixins.py @@ -12,10 +12,11 @@ from permissions import Permission from acls.models import AccessControlList +from .forms import DynamicForm __all__ = ( - 'DeleteExtraDataMixin', 'ExtraContextMixin', 'FormExtraKwargsMixin', - 'MultipleObjectMixin', 'ObjectActionMixin', + 'DeleteExtraDataMixin', 'DynamicFormViewMixin', 'ExtraContextMixin', + 'FormExtraKwargsMixin', 'MultipleObjectMixin', 'ObjectActionMixin', 'ObjectListPermissionFilterMixin', 'ObjectNameMixin', 'ObjectPermissionCheckMixin', 'RedirectionMixin', 'ViewPermissionCheckMixin' @@ -34,6 +35,15 @@ class DeleteExtraDataMixin(object): return HttpResponseRedirect(success_url) +class DynamicFormViewMixin(object): + form_class = DynamicForm + + def get_form_kwargs(self): + data = super(DynamicFormViewMixin, self).get_form_kwargs() + data.update({'schema': self.get_form_schema()}) + return data + + class ExtraContextMixin(object): """ Mixin that allows views to pass extra context to the template diff --git a/mayan/apps/mailer/__init__.py b/mayan/apps/mailer/__init__.py index c04af4226a..a4a9147d3b 100644 --- a/mayan/apps/mailer/__init__.py +++ b/mayan/apps/mailer/__init__.py @@ -1,3 +1,5 @@ from __future__ import unicode_literals +from .classes import * # NOQA + default_app_config = 'mailer.apps.MailerApp' diff --git a/mayan/apps/mailer/admin.py b/mayan/apps/mailer/admin.py index c4eedd1055..7f51a1762f 100644 --- a/mayan/apps/mailer/admin.py +++ b/mayan/apps/mailer/admin.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals from django.contrib import admin -from .models import LogEntry +from .models import LogEntry, UserMailer @admin.register(LogEntry) @@ -10,3 +10,10 @@ class LogEntryAdmin(admin.ModelAdmin): date_hierarchy = 'datetime' list_display = ('datetime', 'message') readonly_fields = ('datetime', 'message') + + +@admin.register(UserMailer) +class UserMailerAdmin(admin.ModelAdmin): + list_display = ( + 'label', 'default', 'enabled', 'backend_path', 'backend_data' + ) diff --git a/mayan/apps/mailer/apps.py b/mayan/apps/mailer/apps.py index 700ee9fb33..65c2abeb99 100644 --- a/mayan/apps/mailer/apps.py +++ b/mayan/apps/mailer/apps.py @@ -6,17 +6,26 @@ from django.apps import apps from django.utils.translation import ugettext_lazy as _ from acls import ModelPermission -from common import MayanAppConfig, menu_object, menu_multi_item, menu_tools +from common import ( + MayanAppConfig, menu_object, menu_multi_item, menu_secondary, menu_setup, + menu_tools +) +from common.widgets import two_state_template from mayan.celery import app from navigation import SourceColumn +from .classes import MailerBackend from .links import ( - link_document_mailing_error_log, link_send_document_link, - link_send_document, link_send_multiple_document, - link_send_multiple_document_link + link_send_document_link, link_send_document, link_send_multiple_document, + link_send_multiple_document_link, link_system_mailer_error_log, + link_user_mailer_create, link_user_mailer_delete, link_user_mailer_edit, + link_user_mailer_list, link_user_mailer_log_list, link_user_mailer_setup, + link_user_mailer_test ) from .permissions import ( - permission_mailing_link, permission_mailing_send_document + permission_mailing_link, permission_mailing_send_document, + permission_user_mailer_delete, permission_user_mailer_edit, + permission_user_mailer_use, permission_user_mailer_view, ) from .queues import * # NOQA @@ -34,14 +43,34 @@ class MailerApp(MayanAppConfig): ) LogEntry = self.get_model('LogEntry') + UserMailer = self.get_model('UserMailer') + + MailerBackend.initialize() SourceColumn( source=LogEntry, label=_('Date and time'), attribute='datetime' ) - SourceColumn( source=LogEntry, label=_('Message'), attribute='message' ) + SourceColumn( + source=UserMailer, label=_('Label'), attribute='label' + ) + SourceColumn( + source=UserMailer, label=_('Default?'), + func=lambda context: two_state_template( + context['object'].default + ) + ) + SourceColumn( + source=UserMailer, label=_('Enabled?'), + func=lambda context: two_state_template( + context['object'].enabled + ) + ) + SourceColumn( + source=UserMailer, label=_('Label'), attribute='backend_label' + ) ModelPermission.register( model=Document, permissions=( @@ -49,6 +78,13 @@ class MailerApp(MayanAppConfig): ) ) + ModelPermission.register( + model=UserMailer, permissions=( + permission_user_mailer_delete, permission_user_mailer_edit, + permission_user_mailer_view, permission_user_mailer_use + ) + ) + app.conf.CELERY_QUEUES.append( Queue('mailing', Exchange('mailing'), routing_key='mailing'), ) @@ -73,4 +109,22 @@ class MailerApp(MayanAppConfig): ), sources=(Document,) ) - menu_tools.bind_links(links=(link_document_mailing_error_log,)) + menu_object.bind_links( + links=( + link_user_mailer_edit, link_user_mailer_log_list, + link_user_mailer_test, link_user_mailer_delete, + ), sources=(UserMailer,) + ) + + menu_secondary.bind_links( + links=( + link_user_mailer_list, link_user_mailer_create, + ), sources=( + UserMailer, 'mailer:user_mailer_list', + 'mailer:user_mailer_create' + ) + ) + + menu_tools.bind_links(links=(link_system_mailer_error_log,)) + + menu_setup.bind_links(links=(link_user_mailer_setup,)) diff --git a/mayan/apps/mailer/classes.py b/mayan/apps/mailer/classes.py new file mode 100644 index 0000000000..8726af3a9a --- /dev/null +++ b/mayan/apps/mailer/classes.py @@ -0,0 +1,71 @@ +from __future__ import unicode_literals + +from importlib import import_module +import logging + +from django.apps import apps +from django.utils import six +from django.utils.encoding import force_text + +logger = logging.getLogger(__name__) + + +__ALL__ = ('MailerBackend',) + + +class MailerBackendMetaclass(type): + _registry = {} + + def __new__(mcs, name, bases, attrs): + new_class = super(MailerBackendMetaclass, mcs).__new__( + mcs, name, bases, attrs + ) + if not new_class.__module__ == 'mailer.classes': + mcs._registry[ + '{}.{}'.format(new_class.__module__, name) + ] = new_class + + return new_class + + +class MailerBackendBase(object): + """ + Base class for the mailing backends. This class is mainly a wrapper + for other Django backends that adds a few metadata to specify the + fields it needs to be instanciated at runtime. + + The fields attribute is a list of dictionaries with the format: + { + 'name': '' # Field internal name + 'label': '' # Label to show to users + 'class': '' # Field class to use. Field classes are Python dot + paths to Django's form fields. + 'initial': '' # Field initial value + 'default': '' # Default value. + } + + """ + class_path = '' # Dot path to the actual class that will handle the mail + fields = () + + +class MailerBackend(six.with_metaclass(MailerBackendMetaclass, MailerBackendBase)): + @classmethod + def get(cls, name): + return cls._registry[name] + + @classmethod + def get_all(cls): + return cls._registry + + @staticmethod + def initialize(): + for app in apps.get_app_configs(): + try: + import_module('{}.mailers'.format(app.name)) + except ImportError as exception: + if force_text(exception) != 'No module named mailers': + logger.error( + 'Error importing %s mailers.py file; %s', app.name, + exception + ) diff --git a/mayan/apps/mailer/forms.py b/mayan/apps/mailer/forms.py index 169ff90971..755c7851cb 100644 --- a/mayan/apps/mailer/forms.py +++ b/mayan/apps/mailer/forms.py @@ -1,9 +1,17 @@ from __future__ import unicode_literals +import json + from django import forms from django.conf import settings from django.utils.translation import ugettext_lazy as _ +from acls.models import AccessControlList +from common.forms import DynamicModelForm + +from .classes import MailerBackend +from .models import UserMailer +from .permissions import permission_user_mailer_use from .settings import ( setting_document_body_template, setting_document_subject_template, setting_link_body_template, setting_link_subject_template @@ -13,6 +21,7 @@ from .settings import ( class DocumentMailForm(forms.Form): def __init__(self, *args, **kwargs): as_attachment = kwargs.pop('as_attachment', False) + user = kwargs.pop('user', None) super(DocumentMailForm, self).__init__(*args, **kwargs) if as_attachment: self.fields[ @@ -33,8 +42,70 @@ class DocumentMailForm(forms.Form): 'project_title': settings.PROJECT_TITLE, 'project_website': settings.PROJECT_WEBSITE } + + queryset = AccessControlList.objects.filter_by_access( + permission=permission_user_mailer_use, user=user, + queryset=UserMailer.objects.filter(enabled=True) + ) + + self.fields['user_mailer'].queryset = queryset + try: + self.fields['user_mailer'].initial = queryset.get(default=True) + except UserMailer.DoesNotExist: + pass + email = forms.EmailField(label=_('Email address')) subject = forms.CharField(label=_('Subject'), required=False) body = forms.CharField( label=_('Body'), widget=forms.widgets.Textarea(), required=False ) + user_mailer = forms.ModelChoiceField( + label=_('Mailing profile'), queryset=UserMailer.objects.none() + ) + + +class UserMailerBackendSelectionForm(forms.Form): + backend = forms.ChoiceField(choices=(), label=_('Backend')) + + def __init__(self, *args, **kwargs): + super(UserMailerBackendSelectionForm, self).__init__(*args, **kwargs) + + self.fields['backend'].choices = [ + ( + key, backend.label + ) for key, backend in MailerBackend.get_all().items() + ] + + +class UserMailerDynamicForm(DynamicModelForm): + class Meta: + fields = ('label', 'default', 'enabled', 'backend_data') + model = UserMailer + widgets = {'backend_data': forms.widgets.HiddenInput} + + def __init__(self, *args, **kwargs): + result = super(UserMailerDynamicForm, self).__init__(*args, **kwargs) + if self.instance.backend_data: + for key, value in json.loads(self.instance.backend_data).items(): + self.fields[key].initial = value + + return result + + def clean(self): + data = super(UserMailerDynamicForm, self).clean() + + # Consolidate the dynamic fields into a single JSON field called + # 'backend_data'. + backend_data = {} + + for field in self.schema['fields']: + backend_data[field['name']] = data.pop( + field['name'], field.get('default', None) + ) + + data['backend_data'] = json.dumps(backend_data) + return data + + +class UserMailerTestForm(forms.Form): + email = forms.EmailField(label=_('Email address')) diff --git a/mayan/apps/mailer/links.py b/mayan/apps/mailer/links.py index 583bd9e544..fd35c26750 100644 --- a/mayan/apps/mailer/links.py +++ b/mayan/apps/mailer/links.py @@ -6,7 +6,9 @@ from navigation import Link from .permissions import ( permission_mailing_link, permission_mailing_send_document, - permission_view_error_log + permission_user_mailer_create, permission_user_mailer_delete, + permission_user_mailer_edit, permission_user_mailer_use, + permission_user_mailer_view, permission_view_error_log ) link_send_document = Link( @@ -23,7 +25,35 @@ link_send_multiple_document = Link( link_send_multiple_document_link = Link( text=_('Email link'), view='mailer:send_multiple_document_link' ) -link_document_mailing_error_log = Link( +link_system_mailer_error_log = Link( icon='fa fa-envelope', permissions=(permission_view_error_log,), - text=_('Document mailing error log'), view='mailer:error_log', + text=_('System mailer error log'), view='mailer:system_mailer_error_log', +) +link_user_mailer_create = Link( + icon='fa fa-envelope', permissions=(permission_user_mailer_create,), + text=_('User mailer create'), view='mailer:user_mailer_backend_selection', +) +link_user_mailer_delete = Link( + args='resolved_object.pk', permissions=(permission_user_mailer_delete,), + tags='dangerous', text=_('Delete'), view='mailer:user_mailer_delete', +) +link_user_mailer_edit = Link( + args='object.pk', permissions=(permission_user_mailer_edit,), + text=_('Edit'), view='mailer:user_mailer_edit', +) +link_user_mailer_log_list = Link( + args='object.pk', permissions=(permission_user_mailer_view,), + text=_('Log'), view='mailer:user_mailer_log', +) +link_user_mailer_list = Link( + icon='fa fa-envelope', permissions=(permission_user_mailer_view,), + text=_('User mailer list'), view='mailer:user_mailer_list', +) +link_user_mailer_setup = Link( + icon='fa fa-envelope', permissions=(permission_user_mailer_view,), + text=_('User mailers'), view='mailer:user_mailer_list', +) +link_user_mailer_test = Link( + args='object.pk', permissions=(permission_user_mailer_use,), + text=_('Test'), view='mailer:user_mailer_test', ) diff --git a/mayan/apps/mailer/mailers.py b/mayan/apps/mailer/mailers.py new file mode 100644 index 0000000000..938bd0c573 --- /dev/null +++ b/mayan/apps/mailer/mailers.py @@ -0,0 +1,71 @@ +from __future__ import unicode_literals + +from django.utils.translation import ugettext_lazy as _ + +from .classes import MailerBackend + +__all__ = ('DjangoSMTP', 'DjangoFileBased') + + +class DjangoSMTP(MailerBackend): + class_path = 'django.core.mail.backends.smtp.EmailBackend' + fields = ( + { + 'name': 'host', 'label': _('Host'), + 'class': 'django.forms.CharField', 'default': 'localhost', + 'help_text': _('The host to use for sending email.'), + 'kwargs': { + 'max_length': 48 + }, 'required': False + }, + { + 'name': 'port', 'label': _('Port'), + 'class': 'django.forms.IntegerField', 'default': 25, + 'help_text': _('Port to use for the SMTP server.'), + 'required': False + }, + { + 'name': 'user', 'label': _('Username'), + 'class': 'django.forms.CharField', 'default': '', + 'help_text': _( + 'Username to use for the SMTP server. If empty, ' + 'authentication won\'t attempted.' + ), 'kwargs': { + 'max_length': 48 + }, 'required': False + }, + { + 'name': 'password', 'label': _('Password'), + 'class': 'django.forms.CharField', 'default': '', + 'help_text': _( + 'Password to use for the SMTP server. This setting is used ' + 'in conjunction with the username when authenticating to ' + 'the SMTP server. If either of these settings is empty, ' + 'authentication won\'t be attempted.' + ), 'kwargs': { + 'max_length': 48 + }, 'required': False + }, + ) + widgets = { + 'password': { + 'class': 'django.forms.widgets.PasswordInput', + 'kwargs': { + 'render_value': True + } + } + } + label = _('Django SMTP backend') + + +class DjangoFileBased(MailerBackend): + class_path = 'django.core.mail.backends.filebased.EmailBackend' + fields = ( + { + 'name': 'file_path', 'label': _('File path'), + 'class': 'django.forms.CharField', 'kwargs': { + 'max_length': 48 + } + }, + ) + label = _('Django file based backend') diff --git a/mayan/apps/mailer/migrations/0002_usermailer_usermailerlogentry.py b/mayan/apps/mailer/migrations/0002_usermailer_usermailerlogentry.py new file mode 100644 index 0000000000..6e0904aca9 --- /dev/null +++ b/mayan/apps/mailer/migrations/0002_usermailer_usermailerlogentry.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-07-02 08:12 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('mailer', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='UserMailer', + fields=[ + ( + 'id', models.AutoField( + auto_created=True, primary_key=True, serialize=False, + verbose_name='ID' + ) + ), + ( + 'label', models.CharField( + max_length=32, unique=True, verbose_name='Label' + ) + ), + ( + 'default', models.BooleanField( + default=True, verbose_name='Default' + ) + ), + ( + 'backend_path', models.CharField( + help_text='The dotted Python path to the backend ' + 'class.', max_length=128, verbose_name='Backend path' + ) + ), + ( + 'backend_data', models.TextField( + blank=True, verbose_name='Backend data' + ) + ), + ], + options={ + 'ordering': ('label',), + 'verbose_name': 'User mailer', + 'verbose_name_plural': 'User mailers', + }, + ), + migrations.CreateModel( + name='UserMailerLogEntry', + fields=[ + ( + 'id', models.AutoField( + auto_created=True, primary_key=True, serialize=False, + verbose_name='ID' + ) + ), + ( + 'datetime', models.DateTimeField( + auto_now_add=True, verbose_name='Date time' + ) + ), + ( + 'message', models.TextField( + blank=True, editable=False, verbose_name='Message' + ) + ), + ( + 'user_mailer', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='mailer.UserMailer', verbose_name='User mailer' + ) + ), + ], + options={ + 'ordering': ('-datetime',), + 'get_latest_by': 'datetime', + 'verbose_name': 'User mailer log entry', + 'verbose_name_plural': 'User mailer log entries', + }, + ), + ] diff --git a/mayan/apps/mailer/migrations/0003_auto_20170703_1535.py b/mayan/apps/mailer/migrations/0003_auto_20170703_1535.py new file mode 100644 index 0000000000..454b20131f --- /dev/null +++ b/mayan/apps/mailer/migrations/0003_auto_20170703_1535.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-07-03 15:35 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('mailer', '0002_usermailer_usermailerlogentry'), + ] + + operations = [ + migrations.AddField( + model_name='usermailer', + name='enabled', + field=models.BooleanField(default=True, verbose_name='Enabled'), + ), + migrations.AlterField( + model_name='usermailerlogentry', + name='user_mailer', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='error_log', to='mailer.UserMailer', + verbose_name='User mailer' + ), + ), + ] diff --git a/mayan/apps/mailer/models.py b/mayan/apps/mailer/models.py index ac05b4b71f..9e7972d08d 100644 --- a/mayan/apps/mailer/models.py +++ b/mayan/apps/mailer/models.py @@ -1,8 +1,11 @@ from __future__ import unicode_literals +import json import logging +from django.core import mail from django.db import models +from django.utils.module_loading import import_string from django.utils.translation import ugettext_lazy as _ logger = logging.getLogger(__name__) @@ -21,3 +24,110 @@ class LogEntry(models.Model): ordering = ('-datetime',) verbose_name = _('Log entry') verbose_name_plural = _('Log entries') + + +class UserMailer(models.Model): + label = models.CharField( + max_length=32, unique=True, verbose_name=_('Label') + ) + default = models.BooleanField( + default=True, help_text=_( + 'If default, this mailing profile will be pre-selected on the ' + 'document mailing form.' + ), verbose_name=_('Default') + ) + enabled = models.BooleanField(default=True, verbose_name=_('Enabled')) + backend_path = models.CharField( + max_length=128, + help_text=_('The dotted Python path to the backend class.'), + verbose_name=_('Backend path') + ) + backend_data = models.TextField( + blank=True, verbose_name=_('Backend data') + ) + + class Meta: + ordering = ('label',) + verbose_name = _('User mailer') + verbose_name_plural = _('User mailers') + + def __str__(self): + return self.label + + def save(self, *args, **kwargs): + if self.default: + UserMailer.objects.select_for_update().exclude(pk=self.pk).update( + default=False + ) + + return super(UserMailer, self).save(*args, **kwargs) + + def backend_label(self): + return self.get_backend().label + + def get_backend(self): + return import_string(self.backend_path) + + def get_connection(self): + return mail.get_connection( + backend=self.get_backend().class_path, **self.loads() + ) + + def loads(self): + return json.loads(self.backend_data) + + def dumps(self, data): + self.backend_data = json.dumps(data) + self.save() + + def send(self, **kwargs): + """ + https://docs.djangoproject.com/en/1.11/topics/email + #django.core.mail.EmailMessage + subject: The subject line of the email. + body: The body text. This should be a plain text message. + from_email: The sender's address. Both fred@example.com and Fred + forms are legal. If omitted, + the DEFAULT_FROM_EMAIL setting is used. + to: A list or tuple of recipient addresses. + bcc: A list or tuple of addresses used in the "Bcc" header when + sending the email. + connection: An email backend instance. Use this parameter if you want + to use the same connection for multiple messages. If omitted, a new + connection is created when send() is called. + attachments: A list of attachments to put on the message. These can be + either email.MIMEBase.MIMEBase instances, or (filename, content, + mimetype) triples. + headers: A dictionary of extra headers to put on the message. The + keys are the header name, values are the header values. It's up to + the caller to ensure header names and values are in the correct + format for an email message. The corresponding attribute is + extra_headers. + cc: A list or tuple of recipient addresses used in the "Cc" + header when sending the email. + reply_to: A list or tuple of recipient addresses used in the + "Reply-To" header when sending the email. + """ + with self.get_connection() as connection: + mail.EmailMessage(connection=connection, **kwargs).send() + + def test(self, to): + self.send(to=to, subject=_('Test email from Mayan EDMS')) + + +class UserMailerLogEntry(models.Model): + user_mailer = models.ForeignKey( + UserMailer, related_name='error_log', verbose_name=_('User mailer') + ) + datetime = models.DateTimeField( + auto_now_add=True, editable=False, verbose_name=_('Date time') + ) + message = models.TextField( + blank=True, editable=False, verbose_name=_('Message') + ) + + class Meta: + get_latest_by = 'datetime' + ordering = ('-datetime',) + verbose_name = _('User mailer log entry') + verbose_name_plural = _('User mailer log entries') diff --git a/mayan/apps/mailer/permissions.py b/mayan/apps/mailer/permissions.py index b1f880dd8f..f7c7a6122d 100644 --- a/mayan/apps/mailer/permissions.py +++ b/mayan/apps/mailer/permissions.py @@ -13,5 +13,20 @@ permission_mailing_send_document = namespace.add_permission( name='mail_document', label=_('Send document via email') ) permission_view_error_log = namespace.add_permission( - name='view_error_log', label=_('View document mailing error log') + name='view_error_log', label=_('View system mailing error log') +) +permission_user_mailer_create = namespace.add_permission( + name='user_mailer_create', label=_('Create an user mailer') +) +permission_user_mailer_delete = namespace.add_permission( + name='user_mailer_delete', label=_('Delete an user mailer') +) +permission_user_mailer_edit = namespace.add_permission( + name='user_mailer_edit', label=_('Edit an user mailer') +) +permission_user_mailer_view = namespace.add_permission( + name='user_mailer_view', label=_('View an user mailer') +) +permission_user_mailer_use = namespace.add_permission( + name='user_mailer_use', label=_('Use an user mailer') ) diff --git a/mayan/apps/mailer/tasks.py b/mayan/apps/mailer/tasks.py index 3d3a6be639..11dce91471 100644 --- a/mayan/apps/mailer/tasks.py +++ b/mayan/apps/mailer/tasks.py @@ -1,17 +1,25 @@ from __future__ import unicode_literals +from django.apps import apps from django.core.mail import EmailMultiAlternatives from documents.models import Document from mayan.celery import app -from .models import LogEntry - @app.task(ignore_result=True) -def task_send_document(subject_text, body_text_content, sender, recipient, document_id, as_attachment=False): +def task_send_document(subject_text, body_text_content, sender, recipient, document_id, user_mailer_id, as_attachment=False): + UserMailer = apps.get_model( + app_label='mailer', model_name='UserMailer' + ) + + user_mailer = UserMailer.objects.get(pk=user_mailer_id) + + connection = user_mailer.get_connection() + email_msg = EmailMultiAlternatives( - subject_text, body_text_content, sender, [recipient] + subject_text, body_text_content, sender, [recipient], + connection=connection, ) if as_attachment: @@ -24,6 +32,6 @@ def task_send_document(subject_text, body_text_content, sender, recipient, docum try: email_msg.send() except Exception as exception: - LogEntry.objects.create(message=exception) + user_mailer.error_log.create(message=exception) else: - LogEntry.objects.all().delete() + user_mailer.error_log.all().delete() diff --git a/mayan/apps/mailer/tests/literals.py b/mayan/apps/mailer/tests/literals.py new file mode 100644 index 0000000000..e93ab2f97c --- /dev/null +++ b/mayan/apps/mailer/tests/literals.py @@ -0,0 +1,5 @@ +from __future__ import unicode_literals + +TEST_EMAIL_ADDRESS = 'test@example.com' +TEST_USER_MAILER_LABEL = 'test user mailer label' +TEST_USER_MAILER_BACKEND_PATH = 'mailer.tests.mailers.TestBackend' diff --git a/mayan/apps/mailer/tests/mailers.py b/mayan/apps/mailer/tests/mailers.py new file mode 100644 index 0000000000..b56b3540ae --- /dev/null +++ b/mayan/apps/mailer/tests/mailers.py @@ -0,0 +1,8 @@ +from __future__ import unicode_literals + +from ..classes import MailerBackend + + +class TestBackend(MailerBackend): + class_path = 'django.core.mail.backends.locmem.EmailBackend' + label = 'Django local memory backend' diff --git a/mayan/apps/mailer/tests/test_views.py b/mayan/apps/mailer/tests/test_views.py index e70d416e1e..ef52f39785 100644 --- a/mayan/apps/mailer/tests/test_views.py +++ b/mayan/apps/mailer/tests/test_views.py @@ -2,66 +2,145 @@ from __future__ import unicode_literals from django.core import mail -from documents.tests.test_views import GenericDocumentViewTestCase - -from ..permissions import ( - permission_mailing_link, permission_mailing_send_document +from documents.tests.test_views import ( + GenericDocumentViewTestCase, GenericViewTestCase ) -TEST_EMAIL_ADDRESS = 'test@example.com' +from ..models import UserMailer +from ..permissions import ( + permission_mailing_link, permission_mailing_send_document, + permission_user_mailer_use, permission_user_mailer_view +) + +from .literals import ( + TEST_EMAIL_ADDRESS, TEST_USER_MAILER_BACKEND_PATH, TEST_USER_MAILER_LABEL +) -class MailerViewsTestCase(GenericDocumentViewTestCase): - def test_mail_link_view_no_permissions(self): - self.login_user() +class MailerTestMixin(object): + def _create_user_mailer(self): - response = self.post( - 'mailer:send_document_link', args=(self.document.pk,), - data={'email': TEST_EMAIL_ADDRESS}, + self.user_mailer = UserMailer.objects.create( + default=True, + enabled=True, + label=TEST_USER_MAILER_LABEL, + backend_path=TEST_USER_MAILER_BACKEND_PATH, + backend_data='{}' ) - self.assertEqual(response.status_code, 302) + +class MailerViewsTestCase(MailerTestMixin, GenericDocumentViewTestCase): + def _request_document_link_send(self): + return self.post( + 'mailer:send_document_link', args=(self.document.pk,), + data={ + 'email': TEST_EMAIL_ADDRESS, + 'user_mailer': self.user_mailer.pk + }, + ) + + def _request_document_send(self): + return self.post( + 'mailer:send_document', args=(self.document.pk,), + data={ + 'email': TEST_EMAIL_ADDRESS, + 'user_mailer': self.user_mailer.pk + }, + ) + + def test_mail_link_view_no_permissions(self): + self._create_user_mailer() + self.login_user() + + response = self._request_document_link_send() + + self.assertContains( + response, 'Select a valid choice', status_code=200 + ) def test_mail_link_view_with_permission(self): + self._create_user_mailer() self.login_user() self.grant(permission_mailing_link) + self.grant(permission_user_mailer_use) - response = self.post( - 'mailer:send_document_link', args=(self.document.pk,), - data={'email': TEST_EMAIL_ADDRESS}, - follow=True - ) + self._request_document_link_send() - self.assertContains( - response, 'queued', status_code=200 - ) self.assertEqual(len(mail.outbox), 1) self.assertEqual(mail.outbox[0].to, [TEST_EMAIL_ADDRESS]) def test_mail_document_view_no_permissions(self): + self._create_user_mailer() self.login_user() - response = self.post( - 'mailer:send_document', args=(self.document.pk,), - data={'email': TEST_EMAIL_ADDRESS}, + response = self._request_document_send() + self.assertContains( + response, 'Select a valid choice', status_code=200 ) - self.assertEqual(response.status_code, 302) - def test_mail_document_view_with_permission(self): + self._create_user_mailer() self.login_user() self.grant(permission_mailing_send_document) + self.grant(permission_user_mailer_use) - response = self.post( - 'mailer:send_document', args=(self.document.pk,), - data={'email': TEST_EMAIL_ADDRESS}, - follow=True + self._request_document_send() + + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to, [TEST_EMAIL_ADDRESS]) + + +class UserMailerViewTestCase(MailerTestMixin, GenericViewTestCase): + def _request_user_mailer_delete(self): + return self.post( + 'mailer:user_mailer_delete', args=(self.user_mailer.pk,) + ) + + def test_user_mailer_list_view_no_permissions(self): + self._create_user_mailer() + self.login_user() + + response = self.get( + 'mailer:user_mailer_list', + ) + self.assertNotContains( + response, text=self.user_mailer.label, status_code=200 + ) + + def test_user_mailer_list_view_with_permissions(self): + self._create_user_mailer() + self.login_user() + + self.grant(permission_user_mailer_view) + + response = self.get( + 'mailer:user_mailer_list', ) self.assertContains( - response, 'queued', status_code=200 + response, text=self.user_mailer.label, status_code=200 + ) + + def test_user_mailer_delete_view_no_permissions(self): + self._create_user_mailer() + self.login_user() + + self._request_user_mailer_delete() + + self.assertQuerysetEqual( + UserMailer.objects.all(), (repr(self.user_mailer),) + ) + + def test_user_mailer_delete_view_with_permissions(self): + self._create_user_mailer() + self.login_user() + + self.grant(permission_user_mailer_view) + + self._request_user_mailer_delete() + + self.assertNotEqual( + [UserMailer.objects.all()], [self.user_mailer] ) - self.assertEqual(len(mail.outbox), 1) - self.assertEqual(mail.outbox[0].to, [TEST_EMAIL_ADDRESS]) diff --git a/mayan/apps/mailer/urls.py b/mayan/apps/mailer/urls.py index 462e63e880..82a594a0fa 100644 --- a/mayan/apps/mailer/urls.py +++ b/mayan/apps/mailer/urls.py @@ -2,7 +2,12 @@ from __future__ import unicode_literals from django.conf.urls import url -from .views import LogEntryListView, MailDocumentLinkView, MailDocumentView +from .views import ( + SystemMailerLogEntryListView, MailDocumentLinkView, MailDocumentView, + UserMailerBackendSelectionView, UserMailingCreateView, + UserMailingDeleteView, UserMailingEditView, UserMailerLogEntryListView, + UserMailerTestView, UserMailerListView +) urlpatterns = [ url( @@ -22,6 +27,36 @@ urlpatterns = [ name='send_multiple_document' ), url( - r'^log/$', LogEntryListView.as_view(), name='error_log' + r'^system_mailer/log/$', SystemMailerLogEntryListView.as_view(), + name='system_mailer_error_log' + ), + url( + r'^user_mailers/backend/selection', + UserMailerBackendSelectionView.as_view(), + name='user_mailer_backend_selection' + ), + url( + r'^user_mailers/(?P[a-zA-Z0-9_.]+)/create/$', + UserMailingCreateView.as_view(), name='user_mailer_create' + ), + url( + r'^user_mailers/(?P\d+)/delete/$', UserMailingDeleteView.as_view(), + name='user_mailer_delete' + ), + url( + r'^user_mailers/(?P\d+)/edit/$', UserMailingEditView.as_view(), + name='user_mailer_edit' + ), + url( + r'^user_mailers/(?P\d+)/log/$', + UserMailerLogEntryListView.as_view(), name='user_mailer_log' + ), + url( + r'^user_mailers/(?P\d+)/test/$', + UserMailerTestView.as_view(), name='user_mailer_test' + ), + url( + r'^user_mailers/$', UserMailerListView.as_view(), + name='user_mailer_list' ), ] diff --git a/mayan/apps/mailer/views.py b/mayan/apps/mailer/views.py index 3de1fdcf18..34b234ab71 100644 --- a/mayan/apps/mailer/views.py +++ b/mayan/apps/mailer/views.py @@ -1,23 +1,37 @@ from __future__ import absolute_import, unicode_literals from django.contrib.sites.models import Site +from django.core.urlresolvers import reverse, reverse_lazy +from django.http import Http404, HttpResponseRedirect +from django.shortcuts import get_object_or_404 from django.template import Context, Template from django.utils.html import strip_tags from django.utils.translation import ungettext, ugettext_lazy as _ -from common.generics import MultipleObjectFormActionView, SingleObjectListView +from acls.models import AccessControlList +from common.generics import ( + FormView, MultipleObjectFormActionView, SingleObjectDeleteView, + SingleObjectDynamicFormCreateView, SingleObjectDynamicFormEditView, + SingleObjectListView +) from documents.models import Document -from .forms import DocumentMailForm -from .models import LogEntry +from .classes import MailerBackend +from .forms import ( + DocumentMailForm, UserMailerBackendSelectionForm, UserMailerDynamicForm, + UserMailerTestForm +) +from .models import LogEntry, UserMailer from .permissions import ( permission_mailing_link, permission_mailing_send_document, - permission_view_error_log + permission_user_mailer_create, permission_user_mailer_delete, + permission_user_mailer_edit, permission_user_mailer_use, + permission_user_mailer_view, permission_view_error_log ) from .tasks import task_send_document -class LogEntryListView(SingleObjectListView): +class SystemMailerLogEntryListView(SingleObjectListView): extra_context = { 'hide_object': True, 'title': _('Document mailing error log'), @@ -65,7 +79,8 @@ class MailDocumentView(MultipleObjectFormActionView): def get_form_extra_kwargs(self): return { - 'as_attachment': self.as_attachment + 'as_attachment': self.as_attachment, + 'user': self.request.user } def object_action(self, form, instance): @@ -83,13 +98,19 @@ class MailDocumentView(MultipleObjectFormActionView): subject_template = Template(form.cleaned_data['subject']) subject_text = strip_tags(subject_template.render(context)) + AccessControlList.objects.check_access( + permissions=permission_user_mailer_use, user=self.request.user, + obj=form.cleaned_data['user_mailer'] + ) + task_send_document.apply_async( args=( subject_text, body_text_content, self.request.user.email, form.cleaned_data['email'] ), kwargs={ 'document_id': instance.pk, - 'as_attachment': self.as_attachment + 'as_attachment': self.as_attachment, + 'user_mailer_id': form.cleaned_data['user_mailer'].pk } ) @@ -104,3 +125,133 @@ class MailDocumentLinkView(MailDocumentView): title = 'Email document link' title_plural = 'Email document links' title_document = 'Email link for document: %s' + + +class UserMailerBackendSelectionView(FormView): + extra_context = { + 'title': _('New mailing profile backend selection'), + } + form_class = UserMailerBackendSelectionForm + view_permission = permission_user_mailer_create + + def form_valid(self, form): + backend = form.cleaned_data['backend'] + return HttpResponseRedirect( + reverse('mailer:user_mailer_create', args=(backend,),) + ) + + +class UserMailingCreateView(SingleObjectDynamicFormCreateView): + form_class = UserMailerDynamicForm + post_action_redirect = reverse_lazy('mailer:user_mailer_list') + view_permission = permission_user_mailer_create + + def get_backend(self): + try: + return MailerBackend.get(name=self.kwargs['class_path']) + except KeyError: + raise Http404( + '{} class not found'.format(self.kwargs['class_path']) + ) + + def get_extra_context(self): + return { + 'title': _( + 'Create a "%s" mailing profile' + ) % self.get_backend().label, + } + + def get_form_schema(self): + return { + 'fields': self.get_backend().fields, + 'widgets': getattr(self.get_backend(), 'widgets', {}) + } + + def get_instance_extra_data(self): + return {'backend_path': self.kwargs['class_path']} + + +class UserMailingDeleteView(SingleObjectDeleteView): + model = UserMailer + object_permission = permission_user_mailer_delete + post_action_redirect = reverse_lazy('mailer:user_mailer_list') + + def get_extra_context(self): + return { + 'title': _('Delete mailing profile: %s') % self.get_object(), + } + + +class UserMailingEditView(SingleObjectDynamicFormEditView): + form_class = UserMailerDynamicForm + model = UserMailer + object_permission = permission_user_mailer_edit + + def form_valid(self, form): + return super(UserMailingEditView, self).form_valid(form) + + def get_extra_context(self): + return { + 'title': _('Edit mailing profile: %s') % self.get_object(), + } + + def get_form_schema(self): + return { + 'fields': self.get_object().get_backend().fields, + 'widgets': getattr(self.get_object().get_backend(), 'widgets', {}) + } + + +class UserMailerLogEntryListView(SingleObjectListView): + model = LogEntry + view_permission = permission_user_mailer_view + + def get_extra_context(self): + return { + 'hide_object': True, + 'object': self.get_user_mailer(), + 'title': _('%s error log') % self.get_user_mailer(), + } + + def get_queryset(self): + return self.get_user_mailer().error_log.all() + + def get_user_mailer(self): + return get_object_or_404(UserMailer, pk=self.kwargs['pk']) + + +class UserMailerListView(SingleObjectListView): + extra_context = { + 'hide_object': True, + 'title': _('Mailing profile'), + } + model = UserMailer + object_permission = permission_user_mailer_view + + def get_form_schema(self): + return {'fields': self.get_backend().fields} + + +class UserMailerTestView(FormView): + form_class = UserMailerTestForm + + def form_valid(self, form): + self.get_user_mailer().test(to=(form.cleaned_data['email'],)) + return super(UserMailerTestView, self).form_valid(form=form) + + def get_extra_context(self): + return { + 'hide_object': True, + 'object': self.get_user_mailer(), + 'submit_label': _('Test'), + 'title': _('Test mailing profile: %s') % self.get_user_mailer(), + } + + def get_user_mailer(self): + user_mailer = get_object_or_404(UserMailer, pk=self.kwargs['pk']) + AccessControlList.objects.check_access( + permissions=permission_user_mailer_use, user=self.request.user, + obj=user_mailer + ) + + return user_mailer