diff --git a/.travis.yml b/.travis.yml index 3286e97..1fe9b39 100644 --- a/.travis.yml +++ b/.travis.yml @@ -26,7 +26,7 @@ matrix: - python: 3.7 env: TOX_ENV=py37-django21 - - env: TOX_ENV=flake8 + - env: TOX_ENV=lint script: - tox -e $TOX_ENV diff --git a/CHANGELOG b/CHANGELOG index 0845a3d..652bb8c 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -4,7 +4,10 @@ Changelog 0.22 (unreleased) ----------------- -- Nothing changed yet. +- Fix mail_factory.contrib app for django >= 2.1 +- Isort the code +- Display python warnings while running tox targets +- Remove some Django < 1.11 compatibility code 0.21 (2018-09-20) diff --git a/demo/demo/settings.py b/demo/demo/settings.py index 908234f..e6768a2 100644 --- a/demo/demo/settings.py +++ b/demo/demo/settings.py @@ -96,7 +96,7 @@ }, ] -MIDDLEWARE_CLASSES = ( +MIDDLEWARE = ( 'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', @@ -106,8 +106,6 @@ # 'django.middleware.clickjacking.XFrameOptionsMiddleware', ) -MIDDLEWARE = MIDDLEWARE_CLASSES - ROOT_URLCONF = 'demo.urls' # Python dotted path to the WSGI application used by Django's runserver. diff --git a/docs/source/django.rst b/docs/source/django.rst index 4cc82da..01c9d76 100644 --- a/docs/source/django.rst +++ b/docs/source/django.rst @@ -17,14 +17,14 @@ Here is an example of how you can use Mail Factory with the You can first add this pattern in your ``urls.py``: .. code-block:: python + from mail_factory.contrib.auth.views import password_reset - url(_(r'^password_reset/$'), - 'mail_factory.contrib.auth.views.password_reset'), - url(_(r'^password_reset/(?P[0-9A-Za-z]{1,13})-(?P[0-9A-Za-z]{1,13}-' - r'[0-9A-Za-z]{1,20})/$'), - 'django.contrib.auth.views.password_reset_confirm') - url(_(r'^password_reset/done/$'), - 'django.contrib.auth.views.password_reset_done') + + urlpatterns = [ + url(_(r'^password_reset/$'), password_reset, name="password_reset"), + + ... + ] Then you can overload the default templates @@ -41,6 +41,7 @@ But you can also register your own ``PasswordResetMail``: class PasswordResetMail(AppBaseMail, PasswordResetMail): """Add the App header + i18n for PasswordResetMail.""" + template_name = 'password_reset' class PasswordResetForm(AppBaseMailForm): @@ -60,10 +61,11 @@ But you can also register your own ``PasswordResetMail``: You can then update your urls.py to use this new form: .. code-block:: python + from mail_factory.contrib.auth.views import PasswordResetView url(_(r'^password_reset/$'), - 'mail_factory.contrib.auth.views.password_reset', - {'email_template_name': 'password_reset'}), + PasswordResetView.as_view(email_template_name="password_reset"), + name="password_reset"), The default PasswordResetMail is not registered in the factory so that diff --git a/docs/source/index.rst b/docs/source/index.rst index a7ed547..4723609 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -8,6 +8,7 @@ Welcome to django-mail-factory's documentation! Django Mail Factory is a little Django app that let's you manage emails for your project very easily. +It's compatible with Django >= 1.11. Features -------- @@ -138,8 +139,6 @@ them by sending them to a custom address with a custom context. to contain 'mail_factory.SimpleMailFactoryConfig' instead of 'mail_factory'. - This is only available in Django 1.7 and above. - Contents -------- diff --git a/docs/source/interface.rst b/docs/source/interface.rst index 98988e9..19b9883 100644 --- a/docs/source/interface.rst +++ b/docs/source/interface.rst @@ -98,9 +98,9 @@ your mail. import datetime import uuid - from django.conf import settings - from django.core.urlresolvers import reverse_lazy as reverse from django import forms + from django.conf import settings + from django.urls import reverse_lazy as reverse from mail_factory import factory, MailForm, BaseMail diff --git a/mail_factory/contrib/auth/forms.py b/mail_factory/contrib/auth/forms.py index 0708869..0d9a8dc 100644 --- a/mail_factory/contrib/auth/forms.py +++ b/mail_factory/contrib/auth/forms.py @@ -1,54 +1,51 @@ # -*- coding: utf-8 -*- -from django.conf import settings -from django.contrib.auth.forms import PasswordResetForm -from django.contrib.sites.models import get_current_site + +from django.contrib.auth.forms import \ + PasswordResetForm as DjangoPasswordResetForm from django.contrib.auth.tokens import default_token_generator -from django.utils.http import int_to_base36 +from django.contrib.sites.shortcuts import get_current_site +from django.utils.encoding import force_bytes, force_text +from django.utils.http import urlsafe_base64_encode -from .mails import PasswordResetMail from mail_factory import factory +from .mails import PasswordResetMail + -class PasswordResetForm(PasswordResetForm): +class PasswordResetForm(DjangoPasswordResetForm): """MailFactory PasswordReset alternative.""" - def save(self, domain_override=None, - subject_template_name=None, # Not used anymore - email_template_name=None, # Mail Factory template name - mail_object=None, # Mail Factory Mail object - use_https=False, token_generator=default_token_generator, - from_email=None, request=None): + def mail_factory_email( + self, domain_override=None, email_template_name=None, + use_https=False, token_generator=default_token_generator, + from_email=None, request=None, extra_email_context=None): """ Generates a one-use only link for resetting password and sends to the user. """ - for user in self.users_cache: + email = self.cleaned_data["email"] + for user in self.get_users(email): if not domain_override: current_site = get_current_site(request) site_name = current_site.name domain = current_site.domain else: site_name = domain = domain_override - - context_params = { - 'email': user.email, + context = { + 'email': email, 'domain': domain, 'site_name': site_name, - 'uid': int_to_base36(user.pk), + 'uid': force_text(urlsafe_base64_encode(force_bytes(user.pk))), 'user': user, 'token': token_generator.make_token(user), - 'protocol': use_https and 'https' or 'http', + 'protocol': 'https' if use_https else 'http', } - - from_email = from_email or settings.DEFAULT_FROM_EMAIL + if extra_email_context is not None: + context.update(extra_email_context) if email_template_name is not None: - mail = factory.get_mail_object(email_template_name, - context_params) + mail = factory.get_mail_object(email_template_name, context) else: - if mail_object is None: - mail_object = PasswordResetMail - mail = mail_object(context_params) + mail = PasswordResetMail(context) - mail.send(emails=[user.email], - from_email=from_email) + mail.send(emails=[user.email], from_email=from_email) diff --git a/mail_factory/contrib/auth/views.py b/mail_factory/contrib/auth/views.py index f86c561..95e8dcc 100644 --- a/mail_factory/contrib/auth/views.py +++ b/mail_factory/contrib/auth/views.py @@ -1,13 +1,28 @@ # -*- coding: utf-8 -*- -from django.contrib.auth.views import password_reset as django_password_reset + +from django.contrib.auth.views import \ + PasswordResetView as DjangoPasswordResetView +from django.http import HttpResponseRedirect + from .forms import PasswordResetForm -def password_reset(request, **kwargs): +class PasswordResetView(DjangoPasswordResetView): """Substitute with the mail_factory PasswordResetForm.""" - if 'password_reset_form' not in kwargs: - kwargs['password_reset_form'] = PasswordResetForm - if 'email_template_name' not in kwargs: - kwargs['email_template_name'] = None + form_class = PasswordResetForm + email_template_name = None + + def form_valid(self, form): + opts = { + 'use_https': self.request.is_secure(), + 'token_generator': self.token_generator, + 'from_email': self.from_email, + 'email_template_name': self.email_template_name, + 'request': self.request, + 'extra_email_context': self.extra_email_context, + } + form.mail_factory_email(**opts) + return HttpResponseRedirect(self.get_success_url()) + - return django_password_reset(request, **kwargs) +password_reset = PasswordResetView.as_view() diff --git a/mail_factory/factory.py b/mail_factory/factory.py index 4ca3e2f..3a37e1a 100644 --- a/mail_factory/factory.py +++ b/mail_factory/factory.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import base64 + from . import exceptions from .forms import MailForm diff --git a/mail_factory/mails.py b/mail_factory/mails.py index f55b3ce..f4eaf57 100644 --- a/mail_factory/mails.py +++ b/mail_factory/mails.py @@ -89,12 +89,8 @@ def _render_part(self, part, lang=None): """ tpl = select_template(self.get_template_part(part, lang=lang)) - cur_lang = translation.get_language() - try: - translation.activate(lang or self.lang) + with translation.override(lang or self.lang): rendered = tpl.render(self.context) - finally: - translation.activate(cur_lang) return rendered.strip() def create_email_msg(self, emails, attachments=None, from_email=None, @@ -103,7 +99,7 @@ def create_email_msg(self, emails, attachments=None, from_email=None, """Create an email message instance.""" from_email = from_email or settings.DEFAULT_FROM_EMAIL - subject = self._render_part('subject.txt', lang=lang).strip() + subject = self._render_part('subject.txt', lang=lang) try: body = self._render_part('body.txt', lang=lang) except TemplateDoesNotExist: diff --git a/mail_factory/messages.py b/mail_factory/messages.py index bc595dd..fb2b31f 100644 --- a/mail_factory/messages.py +++ b/mail_factory/messages.py @@ -1,11 +1,7 @@ # -*- coding: utf-8 -*- -from os.path import basename import re - -try: - from email.MIMEBase import MIMEBase # python2 -except ImportError: - from email.mime.base import MIMEBase # python3 +from email.mime.base import MIMEBase +from os.path import basename from django.conf import settings from django.core.mail import EmailMultiAlternatives, SafeMIMEMultipart diff --git a/mail_factory/templates/mails/password_reset/body.txt b/mail_factory/templates/mails/password_reset/body.txt index a220f12..01b3bcc 100644 --- a/mail_factory/templates/mails/password_reset/body.txt +++ b/mail_factory/templates/mails/password_reset/body.txt @@ -3,7 +3,7 @@ {% trans "Please go to the following page and choose a new password:" %} {% block reset_link %} -{{ protocol }}://{{ domain }}{% url 'django.contrib.auth.views.password_reset_confirm' uidb36=uid token=token %} +{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %} {% endblock %} {% trans "Your username, in case you've forgotten:" %} {{ user.get_username }} diff --git a/mail_factory/tests/test_contrib.py b/mail_factory/tests/test_contrib.py new file mode 100644 index 0000000..12afdfc --- /dev/null +++ b/mail_factory/tests/test_contrib.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- + +"""Keep in mind throughout those tests that the mails from demo.demo_app.mails +are automatically registered, and serve as fixture.""" + +from __future__ import unicode_literals + +from django.conf.urls import url +from django.contrib import admin +from django.contrib.auth.models import User +from django.contrib.auth.views import ( + PasswordResetConfirmView, + PasswordResetDoneView +) +from django.core import mail +from django.test import TestCase, override_settings +from django.urls import reverse + +from mail_factory import factory +from mail_factory.contrib.auth.mails import PasswordResetMail +from mail_factory.contrib.auth.views import PasswordResetView, password_reset + +urlpatterns = [ + url(r'^reset/$', password_reset, name="reset"), + url(r'^reset_template_name/$', + PasswordResetView.as_view(email_template_name="password_reset"), + name="reset_template_name"), + + url(r'^password_reset/(?P[0-9A-Za-z_\-]+)/' + r'(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', + PasswordResetConfirmView.as_view(), name="password_reset_confirm"), + url(r'^password_reset/done/$', + PasswordResetDoneView.as_view(), name="password_reset_done"), + url(r'^admin/', admin.site.urls), +] + + +def with_registered_mail_klass(mail_klass): + def wrapper(func): + def wrapped(*args, **kwargs): + factory.register(mail_klass) + result = func(*args, **kwargs) + factory.unregister(mail_klass) + return result + return wrapped + return wrapper + + +@override_settings(ROOT_URLCONF='mail_factory.tests.test_contrib') +class ContribTestCase(TestCase): + def test_password_reset_default(self): + + user = User.objects.create_user(username='user', + email='admin@example.com', + password="password") + + response = self.client.post(reverse("reset"), data={ + "email": user.email + }) + self.assertRedirects(response, reverse("password_reset_done")) + + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertEqual(email.subject, "Password reset on example.com") + + @with_registered_mail_klass(PasswordResetMail) + def test_password_reset_with_template_name(self): + + user = User.objects.create_user(username='user', + email='admin@example.com', + password="password") + + response = self.client.post(reverse("reset_template_name"), data={ + "email": user.email + }) + self.assertRedirects(response, reverse("password_reset_done")) + + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertEqual(email.subject, "Password reset on example.com") diff --git a/mail_factory/tests/test_forms.py b/mail_factory/tests/test_forms.py index 295c23a..b8331a0 100644 --- a/mail_factory/tests/test_forms.py +++ b/mail_factory/tests/test_forms.py @@ -5,7 +5,6 @@ from __future__ import unicode_literals - from django import forms from django.test import TestCase diff --git a/mail_factory/tests/test_mails.py b/mail_factory/tests/test_mails.py index 2f1f599..c6d88ed 100644 --- a/mail_factory/tests/test_mails.py +++ b/mail_factory/tests/test_mails.py @@ -5,7 +5,6 @@ from __future__ import unicode_literals - from django.conf import settings from django.contrib.staticfiles import finders from django.core import mail @@ -121,11 +120,11 @@ class TestMail(BaseMail): # Default header sets reply-to msg = test_mail.create_email_msg([]) self.assertIn('Reply-To', msg.extra_headers) - self.assertEquals(msg.extra_headers['Reply-To'], - settings.DEFAULT_FROM_EMAIL) + self.assertEqual(msg.extra_headers['Reply-To'], + settings.DEFAULT_FROM_EMAIL) # If headers are forced, check the ones passed are used msg = test_mail.create_email_msg([], headers={'MyHeader': 'Override'}) - self.assertEquals(msg.extra_headers, {'MyHeader': 'Override'}) + self.assertEqual(msg.extra_headers, {'MyHeader': 'Override'}) # templates with html msg = test_mail.create_email_msg([], lang='fr') self.assertEqual(len(msg.alternatives), 1) @@ -193,12 +192,12 @@ class TestMail(BaseMail): msg = test_mail.create_email_msg([]) self.assertIn('Reply-To', msg.extra_headers) - self.assertEquals(msg.extra_headers['Reply-To'], - settings.DEFAULT_FROM_EMAIL) + self.assertEqual(msg.extra_headers['Reply-To'], + settings.DEFAULT_FROM_EMAIL) settings.NO_REPLY_EMAIL = 'no-reply@example.com' msg = test_mail.create_email_msg([]) self.assertIn('Reply-To', msg.extra_headers) - self.assertEquals(msg.extra_headers['Reply-To'], - settings.NO_REPLY_EMAIL) + self.assertEqual(msg.extra_headers['Reply-To'], + settings.NO_REPLY_EMAIL) diff --git a/mail_factory/tests/test_views.py b/mail_factory/tests/test_views.py index 9f83f85..0023a74 100644 --- a/mail_factory/tests/test_views.py +++ b/mail_factory/tests/test_views.py @@ -5,29 +5,16 @@ from __future__ import unicode_literals -from distutils.version import StrictVersion -from unittest import skipUnless -import warnings - -import django from django.contrib.auth.models import User from django.http import Http404, HttpResponse from django.test import TestCase from django.test.client import RequestFactory +from django.urls import reverse -from .. import factory -from .. import views +from .. import factory, views from ..forms import MailForm -try: - from django.urls import reverse -except ImportError: - # django.core.urlresolvers has been deprecated in favor of - # django.urls in Django 1.10 and removed in Django 2.0 - from django.core.urlresolvers import reverse - - class MailListViewTest(TestCase): def test_get_context_data(self): @@ -52,20 +39,6 @@ def setUp(self): User.objects.create_superuser(email='admin@example.com', **credentials) self.client.login(**credentials) - @skipUnless(StrictVersion(django.get_version()) <= StrictVersion('1.9'), - 'Check RemovedInDjango19Warning is not raised') - def test_get_mail_factory_list_django19_and_less(self): - with warnings.catch_warnings(record=True) as w: - # Cause all warnings to always be triggered. - warnings.simplefilter("always") - response = self.client.get(reverse('mail_factory_list')) - # We should no longer have a RemovedInDjango19Warning raised. - self.assertEqual(len(w), 0) - - self.assertEqual(response.status_code, 200) - - @skipUnless(StrictVersion(django.get_version()) > StrictVersion('1.9'), - 'Do not check if RemovedInDjango19Warning is not raised') def test_get_mail_factory_list_django110(self): response = self.client.get(reverse('mail_factory_list')) self.assertEqual(response.status_code, 200) diff --git a/mail_factory/urls.py b/mail_factory/urls.py index 531620b..d071a7d 100644 --- a/mail_factory/urls.py +++ b/mail_factory/urls.py @@ -1,11 +1,9 @@ # -*- coding: utf-8 -*- """URLconf for mail_factory admin interface.""" -from django.conf.urls import url - from django.conf import settings +from django.conf.urls import url -from mail_factory.views import mail_list, form, preview_message, html_not_found - +from mail_factory.views import form, html_not_found, mail_list, preview_message LANGUAGE_CODES = '|'.join([code for code, name in settings.LANGUAGES]) diff --git a/mail_factory/views.py b/mail_factory/views.py index 459f4fb..bd252c1 100644 --- a/mail_factory/views.py +++ b/mail_factory/views.py @@ -1,13 +1,13 @@ # -*- coding: utf-8 -*- -from django.shortcuts import redirect from django.conf import settings +from django.contrib import messages +from django.contrib.auth.decorators import user_passes_test from django.http import Http404, HttpResponse +from django.shortcuts import redirect from django.template import TemplateDoesNotExist -from django.views.generic import TemplateView, FormView -from django.contrib.auth.decorators import user_passes_test -from django.contrib import messages +from django.views.generic import FormView, TemplateView -from . import factory, exceptions +from . import exceptions, factory admin_required = user_passes_test(lambda x: x.is_superuser) @@ -155,6 +155,7 @@ def get_context_data(self, **kwargs): data['message'] = message return data + mail_list = admin_required(MailListView.as_view()) form = admin_required(MailFormView.as_view()) html_not_found = admin_required(HTMLNotFoundView.as_view()) diff --git a/setup.cfg b/setup.cfg index 5e40900..99f92a4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,8 @@ [wheel] universal = 1 + +[isort] +multi_line_output=3 +sections=FUTURE,STDLIB,THIRDPARTY,DJANGO,FIRSTPARTY,LOCALFOLDER +known_django=django +known_firstparty=mail_factory diff --git a/tox.ini b/tox.ini index a38dda4..417cd3b 100644 --- a/tox.ini +++ b/tox.ini @@ -3,10 +3,10 @@ envlist = py{27,35,36}-django111 py{35,36,37}-django{20,21} py36-djangostable - flake8 + lint [testenv] -changedir = ./demo +setenv=PYTHONWARNINGS=d deps = django111: Django>=1.11,<2.0 django20: Django>=2.0,<2.1 @@ -14,13 +14,16 @@ deps = djangostable: Django coverage commands = - pip install -e .. - coverage run --branch --source=mail_factory manage.py test mail_factory - coverage report -m --omit=mail_factory/test* + python --version + pip install -e . pip freeze + coverage run --branch --source=mail_factory demo/manage.py test mail_factory + coverage report -m --omit=mail_factory/test* -[testenv:flake8] +[testenv:lint] deps = flake8 + isort commands = - flake8 mail_factory + isort --check-only --recursive --diff mail_factory/ + flake8 mail_factory --show-source