├── tests
├── __init__.py
├── models.py
├── templates
│ └── herald
│ │ ├── text
│ │ └── hello_world.txt
│ │ └── html
│ │ ├── hello_world.html
│ │ └── hello_world_html2text.html
├── python.jpeg
├── test_migrations.py
├── urls.py
├── test_views.py
├── test_init.py
├── test_contrib_auth.py
├── notifications.py
├── settings.py
├── test_models.py
├── test_usernotifications.py
├── test_admin.py
├── test_commands.py
└── test_notifications.py
├── herald
├── contrib
│ ├── __init__.py
│ └── auth
│ │ ├── __init__.py
│ │ ├── apps.py
│ │ ├── templates
│ │ └── herald
│ │ │ ├── text
│ │ │ └── password_reset.txt
│ │ │ └── html
│ │ │ └── password_reset.html
│ │ ├── forms.py
│ │ └── notifications.py
├── management
│ ├── __init__.py
│ └── commands
│ │ ├── __init__.py
│ │ └── delnotifs.py
├── migrations
│ ├── __init__.py
│ ├── 0002_sentnotification_attachments.py
│ ├── 0004_auto_20171009_0906.py
│ ├── 0005_auto_20180516_1755.py
│ ├── 0001_initial.py
│ └── 0003_auto_20170830_1617.py
├── urls.py
├── templates
│ └── herald
│ │ └── test
│ │ └── notification_list.html
├── apps.py
├── __init__.py
├── views.py
├── models.py
├── admin.py
└── base.py
├── logo.png
├── requirements.txt
├── .git-blame-ignore-revs
├── .coveragerc
├── setup.cfg
├── MANIFEST.in
├── RELEASE.md
├── .github
└── workflows
│ ├── black.yml
│ └── ci.yml
├── manage.py
├── runtests.py
├── .gitignore
├── LICENSE
├── setup.py
├── CHANGELOG.md
└── README.md
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/models.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/herald/contrib/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/herald/management/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/herald/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/herald/management/commands/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/templates/herald/text/hello_world.txt:
--------------------------------------------------------------------------------
1 | Hello World
--------------------------------------------------------------------------------
/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/worthwhile/django-herald/HEAD/logo.png
--------------------------------------------------------------------------------
/tests/templates/herald/html/hello_world.html:
--------------------------------------------------------------------------------
1 |
Hello World
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | six
2 | mock
3 | jsonpickle
4 | twilio
5 | coveralls
6 | pytz
7 | html2text
--------------------------------------------------------------------------------
/.git-blame-ignore-revs:
--------------------------------------------------------------------------------
1 | # Migrate code style to Black
2 | 06b35cd32b87d502a3621d7562c529b586ef5bda
--------------------------------------------------------------------------------
/tests/python.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/worthwhile/django-herald/HEAD/tests/python.jpeg
--------------------------------------------------------------------------------
/herald/contrib/auth/__init__.py:
--------------------------------------------------------------------------------
1 | default_app_config = "herald.contrib.auth.apps.HeraldAuthConfig"
2 |
--------------------------------------------------------------------------------
/tests/templates/herald/html/hello_world_html2text.html:
--------------------------------------------------------------------------------
1 | Hello World
--------------------------------------------------------------------------------
/.coveragerc:
--------------------------------------------------------------------------------
1 | [paths]
2 | source = herald
3 |
4 | [run]
5 | branch = True
6 | omit =
7 | */migrations/*
8 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [metadata]
2 | license-file = LICENSE
3 |
4 | [wheel]
5 | universal=1
6 |
7 | [bdist_wheel]
8 | universal = 1
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | # Include the license file
2 | include LICENSE
3 | recursive-include herald/templates *
4 | recursive-include herald/contrib/auth/templates *
--------------------------------------------------------------------------------
/herald/contrib/auth/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class HeraldAuthConfig(AppConfig):
5 | name = "herald.contrib.auth"
6 | label = "herald_contrib_auth"
7 |
--------------------------------------------------------------------------------
/RELEASE.md:
--------------------------------------------------------------------------------
1 | 1. Bump version number in `herald/__init__.py`
2 | 2. Verify entry in `CHANGELOG.txt`
3 | 3. Run `python setup.py sdist bdist_wheel`
4 | 4. Run `twine upload dist/*`
5 | 5. Add release on GitHub
--------------------------------------------------------------------------------
/herald/contrib/auth/templates/herald/text/password_reset.txt:
--------------------------------------------------------------------------------
1 | {% include template_name %}
2 | {# By default, this just includes the django template defined from the view/form. Override this template to change that #}
--------------------------------------------------------------------------------
/.github/workflows/black.yml:
--------------------------------------------------------------------------------
1 | name: Lint
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | lint:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v2
10 | - uses: psf/black@stable
--------------------------------------------------------------------------------
/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import os, sys
3 |
4 | sys.path.append(os.path.join(os.path.dirname(__file__), "herald"))
5 | os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings"
6 | from django.core import management
7 |
8 | if __name__ == "__main__":
9 | management.execute_from_command_line()
10 |
--------------------------------------------------------------------------------
/herald/contrib/auth/templates/herald/html/password_reset.html:
--------------------------------------------------------------------------------
1 | {# By default, this just includes the django template defined from the view/form. Override this template to change that #}
2 |
3 | {% if html_template_name %}
4 | {% include html_template_name %}
5 | {% else %}
6 | {% include template_name %}
7 | {% endif %}
--------------------------------------------------------------------------------
/runtests.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import os
3 | import sys
4 | from django.core.management import execute_from_command_line
5 |
6 |
7 | def runtests():
8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings")
9 | argv = sys.argv[:1] + ["test"] + sys.argv[1:]
10 | execute_from_command_line(argv)
11 |
12 |
13 | if __name__ == "__main__":
14 | runtests()
15 |
--------------------------------------------------------------------------------
/tests/test_migrations.py:
--------------------------------------------------------------------------------
1 | from django.core.management import call_command
2 | from django.test import TestCase
3 |
4 | from mock import patch
5 |
6 |
7 | class MigrationTests(TestCase):
8 | def test_no_migrations_created(self):
9 | with patch("sys.exit") as exit_mocked:
10 | call_command(
11 | "makemigrations", "herald", dry_run=True, check=True, verbosity=0
12 | )
13 | exit_mocked.assert_not_called()
14 |
--------------------------------------------------------------------------------
/herald/urls.py:
--------------------------------------------------------------------------------
1 | """
2 | Urls for herald app
3 | """
4 |
5 | try:
6 | from django.urls import re_path as url
7 | except ImportError:
8 | from django.conf.urls import url
9 |
10 | from .views import TestNotificationList, TestNotification
11 |
12 | urlpatterns = [
13 | url(r"^$", TestNotificationList.as_view(), name="herald_preview_list"),
14 | url(
15 | r"^(?P\d+)/(?P[\w\-]+)/$",
16 | TestNotification.as_view(),
17 | name="herald_preview",
18 | ),
19 | ]
20 |
--------------------------------------------------------------------------------
/tests/urls.py:
--------------------------------------------------------------------------------
1 | from __future__ import absolute_import
2 | from __future__ import unicode_literals
3 |
4 | from django.contrib import admin
5 | from django.contrib.auth import urls as auth_urls
6 | from django.urls import include
7 |
8 | try:
9 | from django.urls import re_path as url
10 | except ImportError:
11 | from django.conf.urls import url
12 |
13 |
14 | urlpatterns = [
15 | url(r"^admin/", admin.site.urls),
16 | url("^", include(auth_urls)),
17 | url(r"^herald/", include("herald.urls")),
18 | ]
19 |
--------------------------------------------------------------------------------
/herald/migrations/0002_sentnotification_attachments.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.10 on 2017-04-07 15:19
3 | from __future__ import unicode_literals
4 |
5 | from django.db import migrations, models
6 |
7 |
8 | class Migration(migrations.Migration):
9 |
10 | dependencies = [
11 | ("herald", "0001_initial"),
12 | ]
13 |
14 | operations = [
15 | migrations.AddField(
16 | model_name="sentnotification",
17 | name="attachments",
18 | field=models.TextField(blank=True, null=True),
19 | ),
20 | ]
21 |
--------------------------------------------------------------------------------
/herald/migrations/0004_auto_20171009_0906.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.11 on 2017-10-09 14:06
3 | from __future__ import unicode_literals
4 |
5 | from django.db import migrations, models
6 |
7 |
8 | class Migration(migrations.Migration):
9 |
10 | dependencies = [
11 | ("herald", "0003_auto_20170830_1617"),
12 | ]
13 |
14 | operations = [
15 | migrations.AlterField(
16 | model_name="sentnotification",
17 | name="error_message",
18 | field=models.TextField(blank=True, null=True),
19 | ),
20 | ]
21 |
--------------------------------------------------------------------------------
/herald/templates/herald/test/notification_list.html:
--------------------------------------------------------------------------------
1 |
10 |
11 | Notification Preview
12 |
13 |
14 |
15 | | Name |
16 | Preview Link(s) |
17 | Bases |
18 |
19 | {% for index, name, types, bases in notifications %}
20 |
21 | | {{ name }} |
22 | {% for type in types %}{{ type }}{% if not forloop.last %}, {% endif %}{% endfor %} |
23 | {% for base in bases %}{{ base }}{% if not forloop.last %}, {% endif %}{% endfor %} |
24 |
25 | {% endfor %}
26 |
--------------------------------------------------------------------------------
/herald/migrations/0005_auto_20180516_1755.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 2.0.5 on 2018-05-16 22:55
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("herald", "0004_auto_20171009_0906"),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterField(
14 | model_name="notification",
15 | name="notification_class",
16 | field=models.CharField(max_length=255, unique=True),
17 | ),
18 | migrations.AlterField(
19 | model_name="notification",
20 | name="verbose_name",
21 | field=models.CharField(blank=True, max_length=255, null=True),
22 | ),
23 | ]
24 |
--------------------------------------------------------------------------------
/tests/test_views.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase, Client
2 |
3 |
4 | class ViewsTests(TestCase):
5 | def test_index(self):
6 | client = Client()
7 | response = client.get("/herald/")
8 | self.assertContains(response, "MyNotification")
9 |
10 | def test_preview_text(self):
11 | client = Client()
12 | response = client.get("/herald/0/text/")
13 | self.assertContains(response, "Hello World")
14 | self.assertEqual(response["content-type"], "text/plain; charset=utf-8")
15 |
16 | def test_preview_html(self):
17 | client = Client()
18 | response = client.get("/herald/0/html/")
19 | self.assertContains(response, "Hello World")
20 | self.assertEqual(response["content-type"], "text/html; charset=utf-8")
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | env/
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | *.egg-info/
24 | .installed.cfg
25 | *.egg
26 |
27 | # PyInstaller
28 | # Usually these files are written by a python script from a template
29 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
30 | *.manifest
31 | *.spec
32 |
33 | # Installer logs
34 | pip-log.txt
35 | pip-delete-this-directory.txt
36 |
37 | # Unit test / coverage reports
38 | htmlcov/
39 | .tox/
40 | .coverage
41 | .coverage.*
42 | .cache
43 | nosetests.xml
44 | coverage.xml
45 | *,cover
46 | .hypothesis/
47 |
48 | # Translations
49 | *.mo
50 | *.pot
51 |
52 | # Django stuff:
53 | *.log
54 |
55 | # Sphinx documentation
56 | docs/_build/
57 |
58 | # PyBuilder
59 | target/
60 |
61 | #Ipython Notebook
62 | .ipynb_checkpoints
63 |
64 | # pycharm files
65 | .idea/
66 |
--------------------------------------------------------------------------------
/tests/test_init.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | from herald import registry
4 | from herald.base import EmailNotification
5 |
6 |
7 | class InitTests(TestCase):
8 | def test_register(self):
9 | class TestNotification(EmailNotification):
10 | pass
11 |
12 | registry.register(TestNotification)
13 |
14 | self.assertEqual(len(registry._registry), 6)
15 |
16 | registry.unregister(TestNotification)
17 |
18 | self.assertEqual(len(registry._registry), 5)
19 |
20 | def test_register_decorator(self):
21 | @registry.register_decorator()
22 | class TestNotification(EmailNotification):
23 | pass
24 |
25 | self.assertEqual(len(registry._registry), 6)
26 |
27 | registry.unregister(TestNotification)
28 |
29 | self.assertEqual(len(registry._registry), 5)
30 |
31 | def test_register_invalid(self):
32 | class TestNotification(object):
33 | pass
34 |
35 | with self.assertRaises(ValueError):
36 | registry.register(TestNotification)
37 |
38 | self.assertEqual(len(registry._registry), 5)
39 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Worthwhile
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/tests/test_contrib_auth.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth import get_user_model
2 | from django.core import mail
3 | from django.test import TestCase
4 |
5 | from herald.contrib.auth.forms import HeraldPasswordResetForm
6 |
7 |
8 | class ContribAuthTests(TestCase):
9 | def test_save_form(self):
10 | User = get_user_model()
11 | User.objects.create_user(
12 | username="test@example.com", email="test@example.com", password="password"
13 | )
14 | form = HeraldPasswordResetForm({"email": "test@example.com"})
15 | form.is_valid()
16 | form.save()
17 | self.assertEqual(len(mail.outbox), 1)
18 | self.assertEqual(mail.outbox[0].to, ["test@example.com"])
19 |
20 | def test_save_form_domain_override(self):
21 | User = get_user_model()
22 | User.objects.create_user(
23 | username="test@example.com", email="test@example.com", password="password"
24 | )
25 | form = HeraldPasswordResetForm({"email": "test@example.com"})
26 | form.is_valid()
27 | form.save(domain_override="foo")
28 | self.assertEqual(len(mail.outbox), 1)
29 | self.assertEqual(mail.outbox[0].to, ["test@example.com"])
30 |
--------------------------------------------------------------------------------
/herald/apps.py:
--------------------------------------------------------------------------------
1 | """
2 | Django app config for herald. Using this to call autodiscover
3 | """
4 |
5 | from django.apps import AppConfig
6 | from django.db.utils import OperationalError, ProgrammingError
7 |
8 |
9 | class HeraldConfig(AppConfig):
10 | """
11 | Django app config for herald. Using this to call autodiscover
12 | """
13 |
14 | name = "herald"
15 |
16 | def ready(self):
17 | from herald import registry
18 |
19 | self.module.autodiscover()
20 |
21 | Notification = self.get_model("Notification")
22 |
23 | try:
24 | # add any new notifications to database.
25 | for index, klass in enumerate(registry._registry):
26 | notification, created = Notification.objects.get_or_create(
27 | notification_class=klass.get_class_path(),
28 | defaults={
29 | "verbose_name": klass.get_verbose_name(),
30 | "can_disable": klass.can_disable,
31 | },
32 | )
33 |
34 | if not created:
35 | notification.verbose_name = klass.get_verbose_name()
36 | notification.can_disable = klass.can_disable
37 | notification.save()
38 |
39 | except OperationalError:
40 | # if the table is not created yet, just keep going.
41 | pass
42 | except ProgrammingError:
43 | # if the database is not created yet, keep going (ie: during testing)
44 | pass
45 |
--------------------------------------------------------------------------------
/herald/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Notification classes. Used for sending texts and emails
3 | """
4 | __version__ = "0.3.0"
5 |
6 | default_app_config = "herald.apps.HeraldConfig"
7 |
8 |
9 | class NotificationRegistry(object):
10 | """
11 | Stores the notification classes that get registered.
12 | """
13 |
14 | def __init__(self):
15 | self._registry = []
16 |
17 | def register(self, kls):
18 | """
19 | Register a notification class
20 | """
21 |
22 | from .base import NotificationBase
23 |
24 | if not issubclass(kls, NotificationBase):
25 | raise ValueError("Notification must subclass NotificationBase.")
26 |
27 | self._registry.append(kls)
28 |
29 | return kls
30 |
31 | def unregister(self, kls):
32 | """
33 | Unregister a notification class
34 | """
35 |
36 | self._registry.remove(kls)
37 |
38 | def register_decorator(self):
39 | """
40 | Registers the given notification with Django Herald
41 | """
42 |
43 | def _notification_wrapper(kls):
44 | return self.register(kls)
45 |
46 | return _notification_wrapper
47 |
48 |
49 | registry = NotificationRegistry() # pylint: disable=C0103
50 |
51 |
52 | def autodiscover():
53 | """
54 | Auto discover notification registrations in any file called "notifications" in any app.
55 | """
56 | from django.utils.module_loading import autodiscover_modules
57 |
58 | autodiscover_modules("notifications", register_to=registry)
59 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 | strategy:
9 | matrix:
10 | include:
11 | # Django 3.2
12 | - django-version: "3.2.0"
13 | python-version: "3.6"
14 | - django-version: "3.2.0"
15 | python-version: "3.7"
16 | - django-version: "3.2.0"
17 | python-version: "3.8"
18 | - django-version: "3.2.0"
19 | python-version: "3.9"
20 | - django-version: "3.2.0"
21 | python-version: "3.10"
22 | # Django 4.0
23 | - django-version: "4.0.0"
24 | python-version: "3.8"
25 | - django-version: "4.0.0"
26 | python-version: "3.9"
27 | - django-version: "4.0.0"
28 | python-version: "3.10"
29 | # Django 4.1
30 | - django-version: "4.1.0"
31 | python-version: "3.8"
32 | - django-version: "4.1.0"
33 | python-version: "3.9"
34 | - django-version: "4.1.0"
35 | python-version: "3.10"
36 | steps:
37 | - uses: actions/checkout@v2
38 | - name: Set up Python ${{ matrix.python-version }}
39 | uses: actions/setup-python@v2
40 | with:
41 | python-version: ${{ matrix.python-version }}
42 | - name: Install dependencies
43 | run: |
44 | pip install --upgrade -r requirements.txt
45 | pip install django~=${{ matrix.django-version }}
46 | pip install .
47 | - name: Run tests
48 | run: |
49 | coverage run --source herald runtests.py -v 2
50 | - name: Report coverage to Codecov
51 | uses: codecov/codecov-action@v1
52 |
--------------------------------------------------------------------------------
/herald/views.py:
--------------------------------------------------------------------------------
1 | """
2 | Views for testing notifications. Should not be present in production
3 | """
4 | from django.conf import settings
5 | from django.http import HttpResponse
6 | from django.views.generic import TemplateView, View
7 |
8 | from . import registry
9 |
10 |
11 | class TestNotificationList(TemplateView):
12 | """
13 | View for listing out all notifications with links to view rendered versions of them
14 | """
15 |
16 | template_name = "herald/test/notification_list.html"
17 |
18 | def get_context_data(self, **kwargs):
19 | context = super(TestNotificationList, self).get_context_data(**kwargs)
20 |
21 | context["notifications"] = [
22 | (index, x.__name__, x.render_types, (y.__name__ for y in x.__bases__))
23 | for index, x in enumerate(registry._registry) # pylint: disable=W0212
24 | ]
25 |
26 | return context
27 |
28 |
29 | class TestNotification(View):
30 | """
31 | View for showing rendered test notification
32 | """
33 |
34 | def get(self, request, *args, **kwargs): # pylint: disable=W0613
35 | """
36 | GET request
37 | """
38 |
39 | index = int(kwargs["index"])
40 | render_type = kwargs["type"]
41 |
42 | obj = registry._registry[index](
43 | *registry._registry[index].get_demo_args()
44 | ) # pylint: disable=W0212
45 |
46 | context = obj.get_context_data()
47 |
48 | content = obj.render(render_type, context)
49 |
50 | render_type = "plain" if render_type == "text" else render_type
51 | charset = settings.DEFAULT_CHARSET
52 |
53 | return HttpResponse(
54 | content, content_type="text/{}; charset={}".format(render_type, charset)
55 | )
56 |
--------------------------------------------------------------------------------
/tests/notifications.py:
--------------------------------------------------------------------------------
1 | from email.mime.image import MIMEImage
2 |
3 | from django.core.files import File
4 |
5 | from herald import registry
6 | from herald.base import EmailNotification
7 | from herald.base import TwilioTextNotification
8 |
9 |
10 | @registry.register
11 | class MyNotification(EmailNotification):
12 | context = {"hello": "world"}
13 | template_name = "hello_world"
14 | to_emails = ["test@test.com"]
15 |
16 | def get_attachments(self):
17 | # this returns two attachments, one a text file, the other an inline attachment that can be referred to in a
18 | # template using cid: notation
19 | fp = open("tests/python.jpeg", "rb")
20 | img = MIMEImage(fp.read())
21 | img.add_header("Content-ID", "<{}>".format("python.jpg"))
22 |
23 | raw_data = "Some Report Data"
24 |
25 | return [
26 | ("Report.txt", raw_data, "text/plain"),
27 | img,
28 | ]
29 |
30 |
31 | @registry.register
32 | class MyOtherNotification(EmailNotification):
33 | context = {"hello": "world"}
34 | template_name = "hello_world"
35 | to_emails = ["test@test.com"]
36 |
37 |
38 | @registry.register
39 | class MyTwilioNotification(TwilioTextNotification):
40 | context = {"hello": "world"}
41 | template_name = "hello_world"
42 | to_emails = ["test@test.com"]
43 |
44 |
45 | @registry.register
46 | class MyNotificationAttachmentOpen(EmailNotification):
47 | context = {"hello": "world"}
48 | template_name = "hello_world"
49 | to_emails = ["test@test.com"]
50 |
51 | def get_attachments(self):
52 | with open("tests/python.jpeg", "rb") as f:
53 | img = File(f)
54 |
55 | img2 = File(open("tests/python.jpeg", "rb"))
56 |
57 | return [img, img2]
58 |
--------------------------------------------------------------------------------
/herald/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import unicode_literals
3 |
4 | from django.db import migrations, models
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | dependencies = []
10 |
11 | operations = [
12 | migrations.CreateModel(
13 | name="SentNotification",
14 | fields=[
15 | (
16 | "id",
17 | models.AutoField(
18 | verbose_name="ID",
19 | serialize=False,
20 | auto_created=True,
21 | primary_key=True,
22 | ),
23 | ),
24 | ("text_content", models.TextField(null=True, blank=True)),
25 | ("html_content", models.TextField(null=True, blank=True)),
26 | ("sent_from", models.CharField(max_length=100, null=True, blank=True)),
27 | ("recipients", models.CharField(max_length=2000)),
28 | ("subject", models.CharField(max_length=255, null=True, blank=True)),
29 | ("extra_data", models.TextField(null=True, blank=True)),
30 | ("date_sent", models.DateTimeField()),
31 | (
32 | "status",
33 | models.PositiveSmallIntegerField(
34 | default=0,
35 | choices=[(0, "Pending"), (1, "Success"), (2, "Failed")],
36 | ),
37 | ),
38 | ("notification_class", models.CharField(max_length=255)),
39 | (
40 | "error_message",
41 | models.CharField(max_length=255, null=True, blank=True),
42 | ),
43 | ],
44 | ),
45 | ]
46 |
--------------------------------------------------------------------------------
/herald/management/commands/delnotifs.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from django.utils import timezone
4 | from django.core.management.base import BaseCommand
5 |
6 | from ...models import SentNotification
7 |
8 |
9 | def valid_date(s):
10 | return datetime.datetime.strptime(s, "%Y-%m-%d")
11 |
12 |
13 | class Command(BaseCommand):
14 | help = "Deletes notifications between the date ranges specified."
15 |
16 | def add_arguments(self, parser):
17 | parser.add_argument(
18 | "--start", help="includes this date, format YYYY-MM-DD", type=valid_date
19 | )
20 | parser.add_argument(
21 | "--end", help="up to this date, format YYYY-MM-DD", type=valid_date
22 | )
23 |
24 | def handle(self, *args, **options):
25 | start_date = options.get("start")
26 | end_date = options.get("end")
27 |
28 | if not start_date and not end_date:
29 | qs = SentNotification.objects.filter(date_sent__date=timezone.localdate())
30 | else:
31 | today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
32 | date_filters = {
33 | "date_sent__lt": end_date or (today + datetime.timedelta(days=1))
34 | }
35 | if start_date:
36 | date_filters["date_sent__gte"] = start_date
37 | qs = SentNotification.objects.filter(**date_filters)
38 |
39 | present_notifications = qs.count()
40 | deleted_notifications = qs.delete()
41 | deleted_num = (
42 | deleted_notifications[0]
43 | if deleted_notifications is not None
44 | else present_notifications
45 | )
46 | self.stdout.write(
47 | "Successfully deleted {num} notification(s)".format(num=deleted_num)
48 | )
49 |
--------------------------------------------------------------------------------
/tests/settings.py:
--------------------------------------------------------------------------------
1 | import django
2 |
3 | DATABASES = {
4 | "default": {
5 | "ENGINE": "django.db.backends.sqlite3",
6 | "NAME": ":memory:",
7 | },
8 | }
9 |
10 | INSTALLED_APPS = (
11 | "django.contrib.contenttypes",
12 | "django.contrib.auth",
13 | "django.contrib.sites",
14 | "django.contrib.admin",
15 | "django.contrib.sessions",
16 | "django.contrib.messages",
17 | "herald",
18 | "tests",
19 | "herald.contrib.auth",
20 | )
21 |
22 | MIDDLEWARE = [
23 | "django.middleware.common.CommonMiddleware",
24 | "django.middleware.csrf.CsrfViewMiddleware",
25 | "django.contrib.sessions.middleware.SessionMiddleware",
26 | "django.contrib.auth.middleware.AuthenticationMiddleware",
27 | "django.contrib.messages.middleware.MessageMiddleware",
28 | ]
29 |
30 | SITE_ID = 1
31 |
32 | ROOT_URLCONF = "tests.urls"
33 |
34 | DEBUG = True
35 |
36 | USE_TZ = True
37 |
38 | SECRET_KEY = "foobar"
39 |
40 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
41 |
42 | EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
43 |
44 | TEMPLATES = [
45 | {
46 | "BACKEND": "django.template.backends.django.DjangoTemplates",
47 | "APP_DIRS": True,
48 | "OPTIONS": {
49 | "context_processors": [
50 | "django.contrib.auth.context_processors.auth",
51 | "django.template.context_processors.debug",
52 | "django.template.context_processors.i18n",
53 | "django.template.context_processors.media",
54 | "django.template.context_processors.static",
55 | "django.template.context_processors.tz",
56 | "django.contrib.messages.context_processors.messages",
57 | "django.template.context_processors.request",
58 | ]
59 | },
60 | }
61 | ]
62 |
--------------------------------------------------------------------------------
/tests/test_models.py:
--------------------------------------------------------------------------------
1 | import six
2 | from mock import patch
3 |
4 | from django.test import TestCase
5 |
6 | from herald.models import SentNotification, Notification
7 | from tests.notifications import MyNotification
8 |
9 |
10 | class SentNotificationTests(TestCase):
11 | def test_str(self):
12 | notification = SentNotification(
13 | notification_class="tests.notifications.MyNotification"
14 | )
15 | self.assertEqual(
16 | six.text_type(notification), "tests.notifications.MyNotification"
17 | )
18 |
19 | def test_get_recipients(self):
20 | notification = SentNotification(recipients="test@test.com,example@example.com")
21 | self.assertListEqual(
22 | notification.get_recipients(), ["test@test.com", "example@example.com"]
23 | )
24 |
25 | def test_get_extra_data_none(self):
26 | notification = SentNotification()
27 | self.assertDictEqual(notification.get_extra_data(), {})
28 |
29 | def test_get_extra_data(self):
30 | notification = SentNotification(extra_data='{"something":["one","two"]}')
31 | self.assertDictEqual(
32 | notification.get_extra_data(), {"something": ["one", "two"]}
33 | )
34 |
35 | def test_resend(self):
36 | notification = SentNotification(
37 | notification_class="tests.notifications.MyNotification"
38 | )
39 | with patch.object(MyNotification, "resend") as mocked_resend:
40 | notification.resend()
41 | mocked_resend.assert_called_once_with(notification)
42 |
43 |
44 | class NotificationTests(TestCase):
45 | def test_str(self):
46 | notification = Notification(
47 | notification_class="tests.notifications.MyNotification"
48 | )
49 | self.assertEqual(
50 | six.text_type(notification), "tests.notifications.MyNotification"
51 | )
52 |
--------------------------------------------------------------------------------
/herald/contrib/auth/forms.py:
--------------------------------------------------------------------------------
1 | """
2 | Form classes to be used with django.contrib.auth
3 | """
4 |
5 | from django.contrib.auth.forms import PasswordResetForm
6 | from django.contrib.auth.tokens import default_token_generator
7 | from django.contrib.sites.shortcuts import get_current_site
8 |
9 | from .notifications import PasswordResetEmail
10 |
11 |
12 | class HeraldPasswordResetForm(PasswordResetForm):
13 | """
14 | Form used when entering your email to send a password reset email
15 | """
16 |
17 | def save(
18 | self,
19 | domain_override=None,
20 | subject_template_name="registration/password_reset_subject.txt",
21 | email_template_name="registration/password_reset_email.html",
22 | use_https=False,
23 | token_generator=default_token_generator,
24 | from_email=None,
25 | request=None,
26 | html_email_template_name=None,
27 | extra_email_context=None,
28 | ):
29 | """
30 | Generates a one-use only link for resetting password and sends to the
31 | user.
32 | """
33 |
34 | email = self.cleaned_data["email"]
35 | for user in self.get_users(email):
36 | if not domain_override:
37 | current_site = get_current_site(request)
38 | site_name = current_site.name
39 | domain = current_site.domain
40 | else:
41 | site_name = domain = domain_override
42 |
43 | PasswordResetEmail(
44 | user,
45 | site_name=site_name,
46 | domain=domain,
47 | extra_email_context=extra_email_context,
48 | use_https=use_https,
49 | token_generator=token_generator,
50 | subject_template_name=subject_template_name,
51 | email_template_name=email_template_name,
52 | html_email_template_name=html_email_template_name,
53 | ).send()
54 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from setuptools import find_packages, setup
4 |
5 | VERSION = __import__("herald").__version__
6 |
7 |
8 | def read_file(filename):
9 | """Read a file into a string"""
10 | path = os.path.abspath(os.path.dirname(__file__))
11 | filepath = os.path.join(path, filename)
12 | try:
13 | return open(filepath).read()
14 | except IOError:
15 | return ""
16 |
17 |
18 | install_requires = [
19 | "django>=3.2",
20 | "six",
21 | "jsonpickle",
22 | ]
23 | dev_requires = [
24 | "pytz",
25 | ]
26 | twilio_requires = [
27 | "twilio",
28 | ]
29 | html2text_requires = [
30 | "html2text",
31 | ]
32 |
33 | setup(
34 | name="django-herald",
35 | version=VERSION,
36 | author="Worthwhile",
37 | author_email="devs@worthwhile.com",
38 | install_requires=install_requires,
39 | extras_require={
40 | "dev": install_requires + dev_requires,
41 | "twilio": twilio_requires,
42 | "html2text": html2text_requires,
43 | },
44 | packages=find_packages(include=("herald", "herald.*")),
45 | include_package_data=True, # declarations in MANIFEST.in
46 | license="MIT",
47 | url="https://github.com/worthwhile/django-herald/",
48 | download_url="https://github.com/worthwhile/django-herald/tarball/" + VERSION,
49 | description="Django library for separating the message content from transmission method",
50 | long_description=read_file("README.md"),
51 | long_description_content_type="text/markdown",
52 | keywords=["django", "notifications", "messaging"],
53 | classifiers=[
54 | "Framework :: Django",
55 | "Intended Audience :: Developers",
56 | "Programming Language :: Python",
57 | "Programming Language :: Python :: 3",
58 | "Programming Language :: Python :: 3.7",
59 | "Programming Language :: Python :: 3.8",
60 | "Programming Language :: Python :: 3.9",
61 | "Programming Language :: Python :: 3.10",
62 | "Programming Language :: Python :: 3 :: Only",
63 | "License :: OSI Approved :: MIT License",
64 | ],
65 | )
66 |
--------------------------------------------------------------------------------
/tests/test_usernotifications.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth import get_user_model
2 | from django.test import TestCase
3 |
4 | from herald.models import SentNotification, UserNotification, Notification
5 | from .notifications import MyNotification, MyOtherNotification
6 |
7 |
8 | class UserNotificationTests(TestCase):
9 | def setUp(self):
10 | User = get_user_model()
11 |
12 | # create a user who does not want to get MyOtherNotification
13 | user = User(username="test", password="Safepass1.")
14 | user.save()
15 | usernotification = UserNotification(user=user)
16 | usernotification.save()
17 |
18 | # refresh the user
19 | self.user = User.objects.get(id=user.id)
20 | # add a notification
21 | notification = Notification(
22 | notification_class=MyOtherNotification.get_class_path()
23 | )
24 | notification.save()
25 |
26 | # disable the notification
27 | self.user.usernotification.disabled_notifications.add(notification)
28 | self.user = User.objects.get(id=user.id)
29 |
30 | def test_send_disabled(self):
31 | result = MyOtherNotification().send(user=self.user)
32 | self.assertTrue(result, True)
33 |
34 | sent_notification = SentNotification.objects.all()[0]
35 | self.assertEqual(
36 | sent_notification.status, sent_notification.STATUS_USER_DISABLED
37 | )
38 |
39 | def test_send_enabled(self):
40 | result = MyNotification().send(user=self.user)
41 | self.assertTrue(result, True)
42 |
43 | sent_notification = SentNotification.objects.all()[0]
44 | self.assertEqual(sent_notification.status, sent_notification.STATUS_SUCCESS)
45 |
46 |
47 | class UserNotificationTestsNoSetting(TestCase):
48 | def setUp(self):
49 | User = get_user_model()
50 |
51 | # create a user who does not want to get MyOtherNotification
52 | self.user = User(username="test", password="Safepass1.")
53 | self.user.save()
54 |
55 | def test_send_enabled(self):
56 | result = MyNotification().send(user=self.user)
57 | self.assertTrue(result, True)
58 |
59 | sent_notification = SentNotification.objects.all()[0]
60 | self.assertEqual(sent_notification.status, sent_notification.STATUS_SUCCESS)
61 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## TBD (TBD)
2 |
3 |
4 | ## 0.3.0 (10-18-2022)
5 | - Added support for automatic HTML2text support for plain text emails
6 | - Added auto-complete for user in admin
7 | - #74 - Added support for conditional enable/disable to raise error for missing templates `HERALD_RAISE_MISSING_TEMPLATES`
8 | - Updated to use GitHub Actions for CI
9 | - Switched to Black for code formatting
10 |
11 |
12 | ## 0.2.1 (06-23-2019)
13 | - Fix bug that could occur sometimes in herald.contrib.auth when reversing the password reset url
14 | - Add feature to automatically delete old notifications after configured period of time
15 | - Don't set base_url to empty string when DEBUG==True
16 | - Fix for attaching files to a notification that may be closed before the notification is sent
17 | - Text view for viewing notification now uses the text/plain content type, and the default charset setting
18 |
19 |
20 | ## 0.2 (08-16-2018)
21 | - Drop official support for django < 1.11 and python 3.2-3.3
22 | - Changed Notification.notification_class and Notification.verbose_name fields to have less restrictive `max_length`
23 | - Added support for django 2.1
24 | - Added support for python 3.7
25 |
26 |
27 | ## 0.1.9 (10-09-2017)
28 | - Changed SentNotification.error_message to a TextField to fix issues when message is longer than 255 characters
29 |
30 |
31 | ## 0.1.8 (09-05-2017)
32 | - Fix for migration bug introduced in 0.1.7
33 |
34 |
35 | ## 0.1.7 (08-31-2017)
36 | - User disabled notifications support
37 |
38 |
39 | ## 0.1.6 (06-30-2017)
40 | - Email attachments support
41 |
42 |
43 | ## 0.1.5 (02-10-2017)
44 | - Added decorator to register notifications
45 | - Fixed issue where an extra migration would get created in django 1.10
46 | - Initial support for django 1.11
47 |
48 |
49 | ## 0.1.4 (01-06-2017)
50 | - Fixed an issue where sending TwilioTextNotifications would fail with an assertion error.
51 |
52 |
53 | ## 0.1.3 (12-22-2016)
54 | - Added a management command to delete old saved notifications
55 | - Fixed an issue when installing herald without django already being installed
56 | - Added django.contrib.auth for sending django's password reset email through herald
57 | - Improved the herald email preview list
58 |
59 |
60 | ## 0.1.2 (07-22-2016)
61 | - Fixed bug finding template for front-end email viewer page.
62 |
63 |
64 | ## 0.1.1 (07-22-2016)
65 | - Fixed bug in initial pypi upload
66 |
67 |
68 | ## 0.1 (07-19-2016)
69 | - Initial release
70 |
--------------------------------------------------------------------------------
/tests/test_admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth import get_user_model
2 | from django.test import TestCase, Client
3 | from django.utils import timezone
4 | from django.urls import reverse
5 |
6 | from mock import patch
7 |
8 | from herald.models import SentNotification
9 |
10 |
11 | class ViewsTests(TestCase):
12 | def setUp(self):
13 | get_user_model().objects.create_superuser(
14 | "admin", "admin@example.com", "password"
15 | )
16 | self.client = Client()
17 | self.client.login(username="admin", password="password")
18 | self.notification = SentNotification.objects.create(
19 | recipients="test@example.com",
20 | date_sent=timezone.now(),
21 | status=SentNotification.STATUS_SUCCESS,
22 | notification_class="tests.notifications.MyNotification",
23 | )
24 |
25 | def test_index(self):
26 | response = self.client.get(reverse("admin:herald_sentnotification_changelist"))
27 | self.assertEqual(response.status_code, 200)
28 |
29 | def test_detail(self):
30 | response = self.client.get(
31 | reverse(
32 | "admin:herald_sentnotification_change", args=(self.notification.pk,)
33 | )
34 | )
35 | self.assertEqual(response.status_code, 200)
36 |
37 | def test_resend(self):
38 | with patch.object(SentNotification, "resend") as mocked_resend:
39 | response = self.client.get(
40 | reverse(
41 | "admin:herald_sentnotification_resend", args=(self.notification.pk,)
42 | ),
43 | follow=True,
44 | )
45 | mocked_resend.assert_called_once()
46 |
47 | self.assertEqual(response.status_code, 200)
48 | self.assertIn(
49 | "The notification was resent successfully.",
50 | [m.message for m in list(response.context["messages"])],
51 | )
52 |
53 | def test_resend_fail(self):
54 | with patch.object(SentNotification, "resend") as mocked_resend:
55 | mocked_resend.return_value = False
56 | response = self.client.get(
57 | reverse(
58 | "admin:herald_sentnotification_resend", args=(self.notification.pk,)
59 | ),
60 | follow=True,
61 | )
62 | mocked_resend.assert_called_once()
63 |
64 | self.assertEqual(response.status_code, 200)
65 | self.assertIn(
66 | "The notification failed to resend.",
67 | [m.message for m in list(response.context["messages"])],
68 | )
69 |
--------------------------------------------------------------------------------
/herald/migrations/0003_auto_20170830_1617.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.11 on 2017-08-30 21:17
3 | from __future__ import unicode_literals
4 |
5 | from django.conf import settings
6 | from django.db import migrations, models
7 | import django.db.models.deletion
8 |
9 |
10 | class Migration(migrations.Migration):
11 | dependencies = [
12 | ("auth", "0008_alter_user_username_max_length"),
13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14 | ("herald", "0002_sentnotification_attachments"),
15 | ]
16 |
17 | operations = [
18 | migrations.CreateModel(
19 | name="Notification",
20 | fields=[
21 | (
22 | "id",
23 | models.AutoField(
24 | auto_created=True,
25 | primary_key=True,
26 | serialize=False,
27 | verbose_name="ID",
28 | ),
29 | ),
30 | ("notification_class", models.CharField(max_length=80, unique=True)),
31 | (
32 | "verbose_name",
33 | models.CharField(blank=True, max_length=100, null=True),
34 | ),
35 | ("can_disable", models.BooleanField(default=True)),
36 | ],
37 | ),
38 | migrations.CreateModel(
39 | name="UserNotification",
40 | fields=[
41 | (
42 | "user",
43 | models.OneToOneField(
44 | on_delete=django.db.models.deletion.CASCADE,
45 | primary_key=True,
46 | serialize=False,
47 | to=settings.AUTH_USER_MODEL,
48 | ),
49 | ),
50 | (
51 | "disabled_notifications",
52 | models.ManyToManyField(to="herald.Notification"),
53 | ),
54 | ],
55 | ),
56 | migrations.AddField(
57 | model_name="sentnotification",
58 | name="user",
59 | field=models.ForeignKey(
60 | default=None,
61 | null=True,
62 | on_delete=django.db.models.deletion.SET_NULL,
63 | to=settings.AUTH_USER_MODEL,
64 | ),
65 | ),
66 | migrations.AlterField(
67 | model_name="sentnotification",
68 | name="status",
69 | field=models.PositiveSmallIntegerField(
70 | choices=[
71 | (0, "Pending"),
72 | (1, "Success"),
73 | (2, "Failed"),
74 | (3, "User Disabled"),
75 | ],
76 | default=0,
77 | ),
78 | ),
79 | ]
80 |
--------------------------------------------------------------------------------
/tests/test_commands.py:
--------------------------------------------------------------------------------
1 | """
2 | Testing custom commands
3 | """
4 | from datetime import timedelta, datetime
5 |
6 | from django.core.management import call_command
7 | from django.core.exceptions import ValidationError
8 | from django.test import TestCase
9 | from django.utils import timezone
10 |
11 | from six import StringIO
12 |
13 | from herald.models import SentNotification
14 | from herald.management.commands.delnotifs import valid_date
15 |
16 | MSG = "Successfully deleted {num} notification(s)"
17 | NOTIFICATION_CLASS = "tests.notifications.MyNotification"
18 |
19 |
20 | class DeleteNotification(TestCase):
21 | out = StringIO()
22 | today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
23 |
24 | def setUp(self):
25 | SentNotification(
26 | notification_class=NOTIFICATION_CLASS,
27 | date_sent=timezone.now() - timedelta(days=3),
28 | ).save()
29 | SentNotification(
30 | notification_class=NOTIFICATION_CLASS,
31 | date_sent=timezone.now() - timedelta(days=2),
32 | ).save()
33 | SentNotification(
34 | notification_class=NOTIFICATION_CLASS,
35 | date_sent=timezone.now() - timedelta(days=1),
36 | ).save()
37 | SentNotification(
38 | notification_class=NOTIFICATION_CLASS, date_sent=timezone.now()
39 | ).save()
40 | SentNotification(
41 | notification_class=NOTIFICATION_CLASS, date_sent=timezone.now()
42 | ).save()
43 |
44 | def test_date_validator(self):
45 | self.assertEqual(valid_date("2017-01-01"), datetime(2017, 1, 1))
46 |
47 | def test_delete_without_arg(self):
48 | call_command("delnotifs", stdout=self.out)
49 | self.assertIn(MSG.format(num=2), self.out.getvalue())
50 |
51 | def test_delete_start_end_range_args(self):
52 | two_days_ago = self.today - timedelta(days=2)
53 | call_command(
54 | "delnotifs", stdout=self.out, start=str(two_days_ago), end=str(self.today)
55 | )
56 | self.assertIn(MSG.format(num=2), self.out.getvalue())
57 |
58 | def test_start_date_only_arg(self):
59 | two_days_ago = self.today - timedelta(days=2)
60 | call_command("delnotifs", stdout=self.out, start=str(two_days_ago))
61 | self.assertIn(MSG.format(num=4), self.out.getvalue())
62 |
63 | def test_end_date_only_arg(self):
64 | one_days_ago = self.today - timedelta(days=1)
65 | call_command("delnotifs", stdout=self.out, end=str(one_days_ago))
66 | self.assertIn(MSG.format(num=2), self.out.getvalue())
67 |
68 | def test_do_accept_bad_args(self):
69 | SentNotification(
70 | notification_class=NOTIFICATION_CLASS,
71 | date_sent=self.today - timedelta(days=1),
72 | ).save()
73 |
74 | with self.assertRaises(ValidationError):
75 | call_command("delnotifs", stdout=self.out, start="blargh")
76 |
77 | with self.assertRaises(ValidationError):
78 | call_command("delnotifs", stdout=self.out, start="01-01-2016")
79 |
--------------------------------------------------------------------------------
/herald/models.py:
--------------------------------------------------------------------------------
1 | """
2 | Models for notifications app.
3 | """
4 |
5 | import json
6 | import jsonpickle
7 | import six
8 |
9 | from django.conf import settings
10 | from django.db import models
11 | from django.utils.module_loading import import_string
12 |
13 |
14 | @six.python_2_unicode_compatible
15 | class SentNotification(models.Model):
16 | """
17 | Stores info on the notification that was sent.
18 | """
19 |
20 | STATUS_PENDING = 0
21 | STATUS_SUCCESS = 1
22 | STATUS_FAILED = 2
23 | STATUS_USER_DISABLED = 3
24 |
25 | STATUSES = ((0, "Pending"), (1, "Success"), (2, "Failed"), (3, "User Disabled"))
26 |
27 | text_content = models.TextField(null=True, blank=True)
28 | html_content = models.TextField(null=True, blank=True)
29 | sent_from = models.CharField(max_length=100, null=True, blank=True)
30 | recipients = models.CharField(
31 | max_length=2000
32 | ) # Comma separated list of emails or numbers
33 | subject = models.CharField(max_length=255, null=True, blank=True)
34 | extra_data = models.TextField(null=True, blank=True) # json dictionary
35 | date_sent = models.DateTimeField()
36 | status = models.PositiveSmallIntegerField(choices=STATUSES, default=STATUS_PENDING)
37 | notification_class = models.CharField(max_length=255)
38 | error_message = models.TextField(null=True, blank=True)
39 | user = models.ForeignKey(
40 | settings.AUTH_USER_MODEL, default=None, null=True, on_delete=models.SET_NULL
41 | )
42 | attachments = models.TextField(null=True, blank=True)
43 |
44 | def __str__(self):
45 | return self.notification_class
46 |
47 | def get_recipients(self):
48 | """
49 | Return the list of recipients for the notification. Recipient is defined by the notification class.
50 | """
51 |
52 | return self.recipients.split(",")
53 |
54 | def resend(self):
55 | """
56 | Re-sends the notification by calling the notification class' resend method
57 | """
58 |
59 | notification_class = import_string(self.notification_class)
60 | return notification_class.resend(self)
61 |
62 | def get_extra_data(self):
63 | """
64 | Return extra data that was saved
65 | """
66 |
67 | if not self.extra_data:
68 | return {}
69 | else:
70 | return json.loads(self.extra_data)
71 |
72 | def get_attachments(self):
73 | if self.attachments:
74 | return jsonpickle.loads(self.attachments)
75 | else:
76 | return None
77 |
78 |
79 | @six.python_2_unicode_compatible
80 | class Notification(models.Model):
81 | """
82 | NotificationClasses are created on app init.
83 | """
84 |
85 | notification_class = models.CharField(max_length=255, unique=True)
86 | verbose_name = models.CharField(max_length=255, blank=True, null=True)
87 | can_disable = models.BooleanField(default=True)
88 |
89 | def __str__(self):
90 | return self.verbose_name if self.verbose_name else self.notification_class
91 |
92 |
93 | class UserNotification(models.Model):
94 | """
95 | Add a User Notification record, then add disabled notifications to disable records.
96 | On your user Admin, add the field user_notification
97 | """
98 |
99 | user = models.OneToOneField(
100 | settings.AUTH_USER_MODEL, on_delete=models.CASCADE, primary_key=True
101 | )
102 | disabled_notifications = models.ManyToManyField(Notification)
103 |
--------------------------------------------------------------------------------
/herald/admin.py:
--------------------------------------------------------------------------------
1 | """
2 | Admin for notifications
3 | """
4 |
5 | from functools import update_wrapper
6 |
7 | try:
8 | from django.urls import re_path as url
9 | except ImportError:
10 | from django.conf.urls import url
11 | from django.contrib import admin, messages
12 | from django.contrib.admin.options import csrf_protect_m
13 | from django.contrib.admin.utils import unquote
14 | from django.utils.safestring import mark_safe
15 | from django.urls import reverse
16 |
17 | from .models import SentNotification, Notification
18 |
19 |
20 | @admin.register(SentNotification)
21 | class SentNotificationAdmin(admin.ModelAdmin):
22 | """
23 | Admin for viewing historical notifications sent
24 | """
25 |
26 | list_display = (
27 | "notification_class",
28 | "recipients",
29 | "subject",
30 | "date_sent",
31 | "status",
32 | )
33 | list_filter = ("status", "notification_class")
34 | date_hierarchy = "date_sent"
35 | readonly_fields = ("resend",)
36 | search_fields = (
37 | "recipients",
38 | "subject",
39 | "text_content",
40 | "html_content",
41 | "sent_from",
42 | )
43 | autocomplete_fields = ("user",)
44 |
45 | def resend(self, obj):
46 | """
47 | Creates a link field that takes user to re-send view to resend the notification
48 | """
49 |
50 | opts = self.model._meta # pylint: disable=W0212
51 | resend_url = reverse(
52 | "admin:%s_%s_resend" % (opts.app_label, opts.model_name),
53 | current_app=self.admin_site.name,
54 | args=(obj.pk,),
55 | )
56 |
57 | return mark_safe('Resend'.format(resend_url))
58 |
59 | def get_urls(self):
60 | urls = super(SentNotificationAdmin, self).get_urls()
61 | opts = self.model._meta # pylint: disable=W0212
62 |
63 | def wrap(view):
64 | """
65 | Copied from super class
66 | """
67 |
68 | def wrapper(*args, **kwargs):
69 | """
70 | Copied from super class
71 | """
72 | return self.admin_site.admin_view(view)(*args, **kwargs)
73 |
74 | return update_wrapper(wrapper, view)
75 |
76 | info = opts.app_label, opts.model_name
77 |
78 | return [
79 | url(r"^(.+)/resend/$", wrap(self.resend_view), name="%s_%s_resend" % info),
80 | ] + urls
81 |
82 | @csrf_protect_m
83 | def resend_view(
84 | self, request, object_id, extra_context=None
85 | ): # pylint: disable=W0613
86 | """
87 | View that re-sends the notification
88 | """
89 |
90 | obj = self.get_object(request, unquote(object_id))
91 |
92 | success = obj.resend()
93 |
94 | if success:
95 | self.message_user(
96 | request, "The notification was resent successfully.", messages.SUCCESS
97 | )
98 | else:
99 | self.message_user(
100 | request, "The notification failed to resend.", messages.ERROR
101 | )
102 |
103 | return self.response_post_save_change(request, obj)
104 |
105 |
106 | @admin.register(Notification)
107 | class NotificationAdmin(admin.ModelAdmin):
108 | """
109 | Admin for viewing/managing notifications in the system
110 | """
111 |
112 | list_display = ("notification_class", "verbose_name", "can_disable")
113 | search_fields = ("notification_class", "verbose_name")
114 |
--------------------------------------------------------------------------------
/herald/contrib/auth/notifications.py:
--------------------------------------------------------------------------------
1 | """
2 | Herald notifications for working with django.contrib.auth
3 | """
4 |
5 | from django.contrib.auth import get_user_model
6 | from django.contrib.auth.tokens import default_token_generator
7 | from django.contrib.sites.models import Site
8 | from django.template import loader
9 | from django.utils.encoding import force_bytes
10 |
11 | try:
12 | from django.utils.encoding import force_str as force_text
13 | except ImportError:
14 | from django.utils.encoding import force_text
15 |
16 | from django.utils.http import urlsafe_base64_encode
17 | from django.urls import reverse
18 |
19 | from ... import registry
20 | from ...base import EmailNotification
21 |
22 |
23 | class PasswordResetEmail(EmailNotification):
24 | """
25 | Email sent when requesting password reset using forgot password feature.
26 | This replaces django's default email
27 | """
28 |
29 | template_name = "password_reset"
30 |
31 | def __init__(
32 | self,
33 | user,
34 | site_name=None,
35 | domain=None,
36 | extra_email_context=None,
37 | use_https=False,
38 | token_generator=default_token_generator,
39 | subject_template_name="registration/password_reset_subject.txt",
40 | email_template_name="registration/password_reset_email.html",
41 | html_email_template_name=None,
42 | ):
43 | self.to_emails = [user.email]
44 | self.site_name = site_name
45 | self.domain = domain
46 | self.user = user
47 | self.token_generator = token_generator
48 | self.use_https = use_https
49 | self.extra_email_context = extra_email_context
50 | self.subject_template_name = subject_template_name
51 | self.email_template_name = email_template_name
52 | self.html_email_template_name = html_email_template_name
53 |
54 | def get_context_data(self):
55 | context = super(PasswordResetEmail, self).get_context_data()
56 |
57 | if not self.site_name or self.domain:
58 | current_site = Site.objects.get_current()
59 | self.site_name = current_site.name
60 | self.domain = current_site.domain
61 |
62 | protocol = "https" if self.use_https else "http"
63 | uid = force_text(urlsafe_base64_encode(force_bytes(self.user.pk)))
64 | token = self.token_generator.make_token(self.user)
65 |
66 | context.update(
67 | {
68 | "full_reset_url": "{}://{}{}".format(
69 | protocol,
70 | self.domain,
71 | reverse(
72 | "password_reset_confirm", kwargs={"uidb64": uid, "token": token}
73 | ),
74 | ),
75 | "email": self.user.email,
76 | "domain": self.domain,
77 | "site_name": self.site_name,
78 | "uid": uid,
79 | "user": self.user,
80 | "token": token,
81 | "protocol": protocol,
82 | "template_name": self.email_template_name,
83 | "html_template_name": self.html_email_template_name,
84 | }
85 | )
86 |
87 | if self.extra_email_context is not None:
88 | context.update(self.extra_email_context)
89 |
90 | return context
91 |
92 | def get_subject(self):
93 | subject = super(PasswordResetEmail, self).get_subject()
94 |
95 | if not subject:
96 | # subject was not defined on the class. Use the default subject template to get the subject.
97 | subject = loader.render_to_string(
98 | self.subject_template_name, self.get_context_data()
99 | )
100 | # can't have newlines
101 | return "".join(subject.splitlines())
102 |
103 | return subject
104 |
105 | @staticmethod
106 | def get_demo_args():
107 | User = get_user_model()
108 | return [User(**{User.USERNAME_FIELD: "username@example.com"})]
109 |
110 |
111 | registry.register(PasswordResetEmail)
112 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # django-herald
2 |
3 | [](https://pypi.python.org/pypi/django-herald)
4 | [](https://github.com/worthwhile/django-herald/actions/workflows/ci.yml)
5 | [](https://github.com/worthwhile/django-herald/actions/workflows/black.yml)
6 | [](https://app.codecov.io/gh/worthwhile/django-herald)
7 |
8 | [](https://github.com/worthwhile/django-herald)
9 |
10 | A Django messaging library that features:
11 |
12 | - Class-based declaration and registry approach, like Django Admin
13 | - Supports multiple transmission methods (Email, SMS, Slack, etc) per message
14 | - Browser-based previewing of messages
15 | - Maintains a history of messaging sending attempts and can view these messages
16 | - Disabling notifications per user
17 |
18 | # Python/Django Support
19 |
20 | We try to make herald support all versions of django that django supports + all versions in between.
21 |
22 | For python, herald supports all versions of python that the above versions of django support.
23 |
24 | So as of herald v0.3 we support django 3.2 and 4.x+, and python 3.6, 3.7, 3.8, 3.9, and 3.10.
25 |
26 | # Installation
27 |
28 | 1. `pip install django-herald`
29 | 2. Add `herald` and `django.contrib.sites` to `INSTALLED_APPS`.
30 | 3. Add herald's URLS:
31 |
32 | ```python
33 | from django.conf import settings
34 | from django.conf.urls import url, include
35 |
36 | urlpatterns = []
37 |
38 | if settings.DEBUG:
39 | urlpatterns = [
40 | url(r'^herald/', include('herald.urls')),
41 | ] + urlpatterns
42 | ```
43 |
44 | # Usage
45 |
46 | 1. Create a `notifications.py` file in any django app. This is where your notification classes will live. Add a class like this:
47 |
48 | ```python
49 | from herald import registry
50 | from herald.base import EmailNotification
51 |
52 |
53 | class WelcomeEmail(EmailNotification): # extend from EmailNotification for emails
54 | template_name = 'welcome_email' # name of template, without extension
55 | subject = 'Welcome' # subject of email
56 |
57 | def __init__(self, user): # optionally customize the initialization
58 | self.context = {'user': user} # set context for the template rendering
59 | self.to_emails = [user.email] # set list of emails to send to
60 |
61 | @staticmethod
62 | def get_demo_args(): # define a static method to return list of args needed to initialize class for testing
63 | from users.models import User
64 | return [User.objects.order_by('?')[0]]
65 |
66 | registry.register(WelcomeEmail) # finally, register your notification class
67 |
68 | # Alternatively, a class decorator can be used to register the notification:
69 |
70 | @registry.register_decorator()
71 | class WelcomeEmail(EmailNotification):
72 | ...
73 | ```
74 |
75 |
76 | 2. Create templates for rendering the email using this file structure:
77 |
78 | templates/
79 | herald/
80 | text/
81 | welcome_email.txt
82 | html/
83 | welcome_email.html
84 |
85 | 3. Test how your email looks by navigating to `/herald/`.
86 |
87 | 4. Send your email wherever you need in your code:
88 |
89 | WelcomeEmail(user).send()
90 |
91 | 5. View the sent emails in django admin and even be able to resend it.
92 |
93 | ## Email options
94 |
95 | The following options can be set on the email notification class. For Example:
96 |
97 | ```
98 | class WelcomeEmail(EmailNotification):
99 | cc = ['test@example.com']
100 | ```
101 |
102 | - `from_email`: (`str`, default: `settings.DEFAULT_FROM_EMAIL`) email address of sender
103 | - `subject`: (`str`, default: ) email subject
104 | - `to_emails`: (`List[str]`, default: `None`) list of email strings to send to
105 | - `bcc`: (`List[str]`, default: `None`) list of email strings to send as bcc
106 | - `cc`: (`List[str]`, default: `None`) list of email strings to send as cc
107 | - `headers`: (`dict`, default: `None`) extra headers to be passed along to the `EmailMultiAlternatives` object
108 | - `reply_to`: (`List[str]`, default: `None`) list of email strings to send as the Reply-To emails
109 | - `attachments`: (`list`) list of attachments. See "Email Attachments" below for more info
110 |
111 |
112 | ## Automatically Deleting Old Notifications
113 |
114 | Herald can automatically delete old notifications whenever a new notification is sent.
115 |
116 | To enable this, set the `HERALD_NOTIFICATION_RETENTION_TIME` setting to a timedelta instance.
117 |
118 | For example:
119 |
120 | ```
121 | HERALD_NOTIFICATION_RETENTION_TIME = timedelta(weeks=8)
122 | ```
123 |
124 | Will delete all notifications older than 8 weeks every time a new notification is sent.
125 |
126 | ## Manually Deleting Old Notifications
127 |
128 | The `delnotifs` command is useful for purging the notification history.
129 |
130 | The default usage will delete everything from sent during today:
131 |
132 | ```bash
133 | python manage.py delnotifs
134 | ```
135 |
136 | However, you can also pass arguments for `start` or `end` dates. `end` is up to, but not including that date.
137 | - if only `end` is specified, delete anything sent before the end date.
138 | - if only `start` is specified, delete anything sent since the start date.
139 | - if both `start` and `end` are specified, delete anything sent in between, not including the end date.
140 |
141 | ```bash
142 | python manage.py delnotifs --start='2016-01-01' --end='2016-01-10'
143 | ```
144 |
145 |
146 | ## Asynchronous Email Sending
147 |
148 | If you are sending slightly different emails to a large number of people, it might take quite a while to process. By default, Django will process this all synchronously. For asynchronous support, we recommend django-celery-email. It is very straightfoward to setup and integrate: https://github.com/pmclanahan/django-celery-email
149 |
150 |
151 | ## herald.contrib.auth
152 |
153 | Django has built-in support for sending password reset emails. If you would like to send those emails using herald, you can use the notification class in herald.contrib.auth.
154 |
155 | First, add `herald.contrib.auth` to `INSTALLED_APPS` (in addition to `herald`).
156 |
157 | Second, use the `HeraldPasswordResetForm` in place of django's built in `PasswordResetForm`. This step is entirely dependant on your project structure, but it essentially just involves changing the form class on the password reset view in some way:
158 |
159 | ```python
160 | # you may simply just need to override the password reset url like so:
161 | url(r'^password_reset/$', password_reset, name='password_reset', {'password_reset_form': HeraldPasswordResetForm}),
162 |
163 | # of if you are using something like django-authtools:
164 | url(r'^password_reset/$', PasswordResetView.as_view(form_class=HeraldPasswordResetForm), name='password_reset'),
165 |
166 | # or you may have a customized version of the password reset view:
167 | class MyPasswordResetView(FormView):
168 | form_class = HeraldPasswordResetForm # change the form class here
169 |
170 | # or, you may have a custom password reset form already. In that case, you will want to extend from the HeraldPasswordResetForm:
171 | class MyPasswordResetForm(HeraldPasswordResetForm):
172 | ...
173 |
174 | # alternatively, you could even just send the notification wherever you wish, seperate from the form:
175 | PasswordResetEmail(some_user).send()
176 | ```
177 |
178 | Third, you may want to customize the templates for the email. By default, herald will use the `registration/password_reset_email.html` that is provided by django for both the html and text versions of the email. But you can simply override `herald/html/password_reset.html` and/or `herald/text/password_reset.txt` to suit your needs.
179 |
180 | ## User Disabled Notifications
181 |
182 | If you want to disable certain notifications per user, add a record to the UserNotification table and
183 | add notifications to the disabled_notifications many to many table.
184 |
185 | For example:
186 |
187 | ```python
188 | user = User.objects.get(id=user.id)
189 |
190 | notification = Notification.objects.get(notification_class=MyNotification.get_class_path())
191 |
192 | # disable the notification
193 | user.usernotification.disabled_notifications.add(notification)
194 | ```
195 |
196 | By default, notifications can be disabled. You can put can_disable = False in your notification class and the system will
197 | populate the database with this default. Your Notification class can also override the verbose_name by setting it in your
198 | inherited Notification class. Like this:
199 |
200 | ```python
201 | class MyNotification(EmailNotification):
202 | can_disable = False
203 | verbose_name = "My Required Notification"
204 | ```
205 |
206 | ## Email Attachments
207 |
208 | To send attachments, assign a list of attachments to the attachments attribute of your EmailNotification instance, or override the get_attachments() method.
209 |
210 | Each attachment in the list can be one of the following:
211 |
212 | 1. A tuple which consists of the filename, the raw attachment data, and the mimetype. It is up to you to get the attachment data. Like this:
213 |
214 | ```python
215 | raw_data = get_pdf_data()
216 |
217 | email.attachments = [
218 | ('Report.pdf', raw_data, 'application/pdf'),
219 | ('report.txt', 'text version of report', 'text/plain')
220 | ]
221 | email.send()
222 | ```
223 |
224 | 2. A MIMEBase object. See the documentation for attachments under EmailMessage Objects/attachments in the Django documentation.
225 |
226 | 3. A django `File` object.
227 |
228 | ### Inline Attachments
229 |
230 | Sometimes you want to embed an image directly into the email content. Do that by using a MIMEImage assigning a content id header to a MIMEImage, like this:
231 |
232 | ```python
233 | email = WelcomeEmail(user)
234 | im = get_thumbnail(image_file.name, '600x600', quality=95)
235 | my_image = MIMEImage(im.read()) # MIMEImage inherits from MIMEBase
236 | my_image.add_header('Content-ID', '<{}>'.format(image_file.name))
237 | ```
238 |
239 | You can refer to these images in your html email templates using the Content ID (cid) like this:
240 |
241 | ```html
242 |
243 | ```
244 |
245 | You would of course need to add the "image_file" to your template context in the example above. You can also accomplish this using file operations. In this example we overrode the get_attachments method of an EmailNotification.
246 |
247 | ```python
248 | class MyNotification(EmailNotification):
249 | context = {'hello': 'world'}
250 | template_name = 'welcome_email'
251 | to_emails = ['somebody@example.com']
252 | subject = "My email test"
253 |
254 | def get_attachments(self):
255 | fp = open('python.jpeg', 'rb')
256 | img = MIMEImage(fp.read())
257 | img.add_header('Content-ID', '<{}>'.format('python.jpeg'))
258 | return [
259 | img,
260 | ]
261 | ```
262 |
263 | And in your template you would refer to it like this, and you would not need to add anything to the context:
264 |
265 | ```html
266 |
267 | ```
268 |
269 | ### HTML2Text Support
270 |
271 | Django Herald can auto convert your HTML emails to plain text. Any email without a plain text version
272 | will be auto converted if you enable this feature.
273 |
274 | ```
275 | # Install html2text
276 | pip install django-herald[html2text]
277 | ```
278 |
279 | In your settings.py file:
280 |
281 | ```
282 | HERALD_HTML2TEXT_ENABLED = True
283 | ```
284 |
285 | You can customize the output of HTML2Text by setting a configuration dictionary. See
286 | [HTML2Text Configuration](https://github.com/Alir3z4/html2text/blob/master/docs/usage.md) for options
287 |
288 | ```
289 | HERALD_HTML2TEXT_CONFIG = {
290 | # Key / value configuration of html2text
291 | 'ignore_images': True # Ignores images in conversion
292 | }
293 | ```
294 |
295 | ```
296 | HERALD_RAISE_MISSING_TEMPLATES = True
297 | ```
298 |
299 | By default, Herald will raise an exception if a template is missing when true (default).
300 |
301 | ### Twilio
302 |
303 | ```
304 | # Install twilio
305 | pip install django-herald[twilio]
306 | ```
307 |
308 | You can retrieve these values on [Twilio Console](https://twilio.com/console). Once you have retrieve the necessary ids, you can place those to your `settings.py`.
309 |
310 | For reference, Twilio has some great tutorials for python.
311 | [Twilio Python Tutorial](https://www.twilio.com/docs/sms/quickstart/python)
312 |
313 | ```
314 | # Twilio configurations
315 | # values taken from `twilio console`
316 | TWILIO_ACCOUNT_SID = "your_account_sid"
317 | TWILIO_AUTH_TOKEN = "your_auth_token"
318 | TWILIO_DEFAULT_FROM_NUMBER = "+1234567890"
319 |
320 | ```
321 |
322 | ### Other MIME attachments
323 |
324 | You can also attach any MIMEBase objects as regular attachments, but you must add a content-disposition header, or they will be inaccessible:
325 |
326 | ```python
327 | my_image.add_header('Content-Disposition', 'attachment; filename="python.jpg"')
328 | ```
329 |
330 | Attachments can cause your database to become quite large, so you should be sure to run the management commands to purge the database of old messages.
331 |
332 | # Running Tests
333 |
334 | ```bash
335 | python runtests.py
336 | ```
337 |
--------------------------------------------------------------------------------
/tests/test_notifications.py:
--------------------------------------------------------------------------------
1 | from datetime import timedelta
2 |
3 | import jsonpickle
4 | from django.core import mail
5 | from django.core.files import File
6 | from django.core.mail import EmailMultiAlternatives
7 | from django.template import TemplateDoesNotExist
8 | from django.test import TestCase, override_settings
9 | from django.utils import timezone
10 | from herald.base import EmailNotification, NotificationBase, TwilioTextNotification
11 | from herald.models import SentNotification
12 |
13 | from mock import patch
14 |
15 | from .notifications import MyNotification, MyNotificationAttachmentOpen
16 |
17 | try:
18 | # twilio version 6
19 | from twilio.rest.api.v2010.account import MessageList
20 | except ImportError:
21 | # twillio version < 6
22 | from twilio.rest.resources import Messages as MessageList
23 |
24 |
25 | class BaseNotificationTests(TestCase):
26 | def test_get_context_data(self):
27 | self.assertDictEqual(
28 | MyNotification().get_context_data(),
29 | {"hello": "world", "base_url": "http://example.com", "subject": None},
30 | )
31 |
32 | def test_get_recipients(self):
33 | self.assertRaises(NotImplementedError, NotificationBase().get_recipients)
34 |
35 | def test_get_extra_data(self):
36 | self.assertDictEqual(NotificationBase().get_extra_data(), {})
37 |
38 | def test_get_sent_from(self):
39 | self.assertRaises(NotImplementedError, NotificationBase().get_sent_from)
40 |
41 | def test_get_subject(self):
42 | self.assertIsNone(NotificationBase().get_subject())
43 |
44 | def test_get_demo_args(self):
45 | self.assertListEqual(NotificationBase.get_demo_args(), [])
46 |
47 | def test_private_send(self):
48 | self.assertRaises(NotImplementedError, NotificationBase()._send, [])
49 |
50 | def test_get_attachments(self):
51 | self.assertIsNone(NotificationBase().get_attachments())
52 |
53 | def test_send(self):
54 | with patch.object(MyNotification, "resend") as mocked_resend:
55 | MyNotification().send()
56 | mocked_resend.assert_called_once()
57 | obj = mocked_resend.call_args[0][0]
58 | self.assertEqual(obj.recipients, "test@test.com")
59 |
60 | @override_settings(HERALD_RAISE_MISSING_TEMPLATES=False)
61 | def test_send_no_text(self):
62 | class DummyNotification(EmailNotification):
63 | render_types = ["html"]
64 | to_emails = ["test@test.com"]
65 |
66 | with patch.object(DummyNotification, "resend") as mocked_resend:
67 | DummyNotification().send()
68 | mocked_resend.assert_called_once()
69 | obj = mocked_resend.call_args[0][0]
70 | self.assertEqual(obj.recipients, "test@test.com")
71 | self.assertIsNone(obj.text_content)
72 |
73 | def test_real_send(self):
74 | MyNotification().send()
75 | self.assertEqual(len(mail.outbox), 1)
76 | self.assertEqual(mail.outbox[0].to, ["test@test.com"])
77 |
78 | def test_real_send_attachments(self):
79 | MyNotification().send()
80 | self.assertEqual(len(mail.outbox), 1)
81 | self.assertEqual(mail.outbox[0].attachments[0][1], "Some Report Data")
82 |
83 | def test_real_send_attachments_open(self):
84 | MyNotificationAttachmentOpen().send()
85 | self.assertEqual(len(mail.outbox), 1)
86 | self.assertEqual(mail.outbox[0].attachments[0][0], "tests/python.jpeg")
87 | self.assertEqual(mail.outbox[0].attachments[1][0], "tests/python.jpeg")
88 |
89 | def test_render_no_type(self):
90 | class DummyNotification(NotificationBase):
91 | pass
92 |
93 | with self.assertRaises(AssertionError):
94 | DummyNotification().render("text", {})
95 |
96 | @override_settings(HERALD_RAISE_MISSING_TEMPLATES=False)
97 | def test_render_invalid_template(self):
98 | class DummyNotification(NotificationBase):
99 | render_types = ["text"]
100 | template_name = "does_not_exist"
101 |
102 | self.assertIsNone(DummyNotification().render("text", {}))
103 |
104 | @override_settings(DEBUG=True)
105 | def test_render_invalid_template_debug(self):
106 | class DummyNotification(NotificationBase):
107 | render_types = ["text"]
108 | template_name = "does_not_exist"
109 |
110 | with self.assertRaises(TemplateDoesNotExist):
111 | DummyNotification().render("text", {})
112 |
113 | def test_render_invalid(self):
114 | class DummyNotification(NotificationBase):
115 | render_types = ["text"]
116 | template_name = "hello_world"
117 |
118 | self.assertEqual(DummyNotification().render("text", {}), "Hello World")
119 |
120 | def test_resend_error(self):
121 | notification = SentNotification()
122 |
123 | with patch.object(NotificationBase, "_send") as mocked__send:
124 | mocked__send.side_effect = Exception
125 | result = NotificationBase.resend(notification)
126 | self.assertFalse(result)
127 |
128 | def test_resend_error_raise(self):
129 | notification = SentNotification()
130 |
131 | with patch.object(NotificationBase, "_send") as mocked__send:
132 | mocked__send.side_effect = Exception
133 | self.assertRaises(
134 | Exception, NotificationBase.resend, notification, raise_exception=True
135 | )
136 |
137 | def test_resend(self):
138 | notification = SentNotification()
139 |
140 | with patch.object(NotificationBase, "_send") as mocked__send:
141 | result = NotificationBase.resend(notification)
142 | self.assertTrue(result)
143 |
144 | def test_get_verbose_name(self):
145 | class TestNotification(EmailNotification):
146 | pass
147 |
148 | self.assertEqual(TestNotification.get_verbose_name(), "Test Notification")
149 |
150 | class TestNotification2(EmailNotification):
151 | verbose_name = "A verbose name"
152 |
153 | self.assertEqual(TestNotification2.get_verbose_name(), "A verbose name")
154 |
155 | def test_get_encoded_attachments_none(self):
156 | class TestNotification(EmailNotification):
157 | attachments = []
158 |
159 | self.assertJSONEqual(TestNotification()._get_encoded_attachments(), [])
160 |
161 | def test_get_encoded_attachments_basic(self):
162 | class TestNotification(EmailNotification):
163 | attachments = [("Report.txt", "raw_data", "text/plain")]
164 |
165 | self.assertJSONEqual(
166 | TestNotification()._get_encoded_attachments(),
167 | [{"py/tuple": ["Report.txt", "raw_data", "text/plain"]}],
168 | )
169 |
170 | def test_get_encoded_attachments_file(self):
171 | class TestNotification(EmailNotification):
172 | attachments = [File(open("tests/python.jpeg", "rb"))]
173 |
174 | attachments = jsonpickle.loads(TestNotification()._get_encoded_attachments())
175 | self.assertEqual(attachments[0][0], "tests/python.jpeg")
176 | self.assertEqual(attachments[0][2], "image/jpeg")
177 |
178 | def test_delete_notifications_no_setting(self):
179 | # create a test notification from a long time ago
180 | SentNotification.objects.create(
181 | recipients="test@test.com",
182 | date_sent=timezone.now() - timedelta(weeks=52),
183 | notification_class="MyNotification",
184 | )
185 | # create a test notification from recently
186 | SentNotification.objects.create(
187 | recipients="test@test.com",
188 | date_sent=timezone.now() - timedelta(weeks=10),
189 | notification_class="MyNotification",
190 | )
191 | MyNotification().send()
192 | # all three were not deleted because we didn't have a setting
193 | self.assertEqual(SentNotification.objects.count(), 3)
194 |
195 | @override_settings(HERALD_NOTIFICATION_RETENTION_TIME=timedelta(weeks=26))
196 | def test_delete_notifications(self):
197 | # create a test notification from a long time ago
198 | n1 = SentNotification.objects.create(
199 | recipients="test@test.com",
200 | date_sent=timezone.now() - timedelta(weeks=52),
201 | notification_class="MyNotification",
202 | )
203 | # create a test notification from recently
204 | n2 = SentNotification.objects.create(
205 | recipients="test@test.com",
206 | date_sent=timezone.now() - timedelta(weeks=10),
207 | notification_class="MyNotification",
208 | )
209 | MyNotification().send()
210 |
211 | # the one from a year ago was deleted, but not the one from 10 weeks ago.
212 | self.assertEqual(SentNotification.objects.count(), 2)
213 | ids = SentNotification.objects.values_list("id", flat=True)
214 | self.assertTrue(n2.id in ids)
215 | self.assertFalse(n1.id in ids)
216 |
217 |
218 | class EmailNotificationTests(TestCase):
219 | def test_get_recipients(self):
220 | self.assertListEqual(MyNotification().get_recipients(), ["test@test.com"])
221 |
222 | def test_get_sent_from(self):
223 | class TestNotification(EmailNotification):
224 | from_email = "bob@example.com"
225 |
226 | self.assertEqual(TestNotification().get_sent_from(), "bob@example.com")
227 |
228 | def test_get_sent_from_default(self):
229 | class TestNotification(EmailNotification):
230 | from_email = None
231 |
232 | with override_settings(DEFAULT_FROM_EMAIL="default@example.com"):
233 | self.assertEqual(TestNotification().get_sent_from(), "default@example.com")
234 |
235 | def test_get_subject(self):
236 | class TestNotification(EmailNotification):
237 | subject = "test subject"
238 |
239 | self.assertEqual(TestNotification().get_subject(), "test subject")
240 |
241 | def test_get_extra_data_none(self):
242 | self.assertDictEqual(EmailNotification().get_extra_data(), {})
243 |
244 | def test_get_extra_data(self):
245 | class TestNotification(EmailNotification):
246 | bcc = "bcc@test.com"
247 | cc = "cc@test.com"
248 | headers = {"HEADER": "test"}
249 | reply_to = "reply_to@test.com"
250 |
251 | self.assertDictEqual(
252 | TestNotification().get_extra_data(),
253 | {
254 | "bcc": "bcc@test.com",
255 | "cc": "cc@test.com",
256 | "headers": {"HEADER": "test"},
257 | "reply_to": "reply_to@test.com",
258 | },
259 | )
260 |
261 | @override_settings(HERALD_HTML2TEXT_ENABLED=True)
262 | def test_render_html2text(self):
263 | class TestNotificationHTML2Text(EmailNotification):
264 | template_name = "hello_world_html2text"
265 |
266 | output = TestNotificationHTML2Text().render(render_type="text", context={})
267 | self.assertEqual(output, "# Hello World\n\n")
268 |
269 | # Also test with DEBUG on so TemplateDoesNotExist is thrown
270 | with override_settings(DEBUG=True):
271 | output = TestNotificationHTML2Text().render(render_type="text", context={})
272 | self.assertEqual(output, "# Hello World\n\n")
273 |
274 | def test_send_html_content(self):
275 | class TestNotification(EmailNotification):
276 | subject = "test subject"
277 |
278 | with patch.object(
279 | EmailMultiAlternatives, "attach_alternative"
280 | ) as mocked_attach_alternative:
281 | TestNotification._send([], text_content="Text")
282 | mocked_attach_alternative.assert_not_called()
283 |
284 | with patch.object(
285 | EmailMultiAlternatives, "attach_alternative"
286 | ) as mocked_attach_alternative:
287 | TestNotification._send([], html_content="Text")
288 | mocked_attach_alternative.assert_called_once_with("Text", "text/html")
289 |
290 |
291 | class TwilioNotificationTests(TestCase):
292 | def test_get_recipients(self):
293 | class TestNotification(TwilioTextNotification):
294 | to_number = "1231231234"
295 |
296 | self.assertListEqual(TestNotification().get_recipients(), ["1231231234"])
297 |
298 | def test_get_sent_from(self):
299 | class TestNotification(TwilioTextNotification):
300 | from_number = "1231231234"
301 |
302 | self.assertEqual(TestNotification().get_sent_from(), "1231231234")
303 |
304 | def test_get_sent_from_default(self):
305 | class TestNotification(TwilioTextNotification):
306 | from_number = None
307 |
308 | with override_settings(TWILIO_DEFAULT_FROM_NUMBER="1231231234"):
309 | self.assertEqual(TestNotification().get_sent_from(), "1231231234")
310 |
311 | def test_get_sent_from_default_error(self):
312 | class TestNotification(TwilioTextNotification):
313 | from_number = None
314 |
315 | self.assertRaisesMessage(
316 | Exception,
317 | "TWILIO_DEFAULT_FROM_NUMBER setting is required for sending a TwilioTextNotification",
318 | TestNotification().get_sent_from,
319 | )
320 |
321 | @override_settings(TWILIO_ACCOUNT_SID="sid", TWILIO_AUTH_TOKEN="token")
322 | def test_send(self):
323 | class TestNotification(TwilioTextNotification):
324 | from_number = "1231231234"
325 | to_number = "1231231234"
326 | template_name = "hello_world"
327 |
328 | with patch.object(MessageList, "create") as mocked_create:
329 | TestNotification().send()
330 | mocked_create.assert_called_once_with(
331 | body="Hello World", to="1231231234", from_="1231231234"
332 | )
333 |
334 | @override_settings(TWILIO_ACCOUNT_SID="sid", TWILIO_AUTH_TOKEN="token")
335 | def test_sending_to_multiple_numbers(self):
336 | class TestNotification(TwilioTextNotification):
337 | from_number = "1231231234"
338 | template_name = "hello_world"
339 |
340 | def get_recipients(self):
341 | return ["1234567890", "0987654321"]
342 |
343 | with patch.object(MessageList, "create") as mocked_create:
344 | notification = TestNotification()
345 | notification.send()
346 | self.assertEqual(mocked_create.call_count, 2)
347 | for recipient in notification.get_recipients():
348 | mocked_create.assert_any_call(
349 | body="Hello World", to=recipient, from_=notification.get_sent_from()
350 | )
351 |
352 | def test_send_no_settings(self):
353 | class TestNotification(TwilioTextNotification):
354 | from_number = "1231231234"
355 | to_number = "1231231234"
356 | template_name = "hello_world"
357 |
358 | with self.assertRaisesMessage(
359 | Exception,
360 | "TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN settings are required for "
361 | "sending a TwilioTextNotification",
362 | ):
363 | TestNotification().send(raise_exception=True)
364 |
--------------------------------------------------------------------------------
/herald/base.py:
--------------------------------------------------------------------------------
1 | """
2 | Base notification classes
3 | """
4 |
5 | import json
6 | from email.mime.base import MIMEBase
7 | from mimetypes import guess_type
8 |
9 | import jsonpickle
10 | import re
11 | import six
12 |
13 | from django.conf import settings
14 | from django.contrib.sites.models import Site
15 | from django.core.mail import EmailMultiAlternatives
16 | from django.template import TemplateDoesNotExist
17 | from django.template.loader import render_to_string
18 | from django.utils import timezone
19 | from django.core.files import File
20 |
21 | from .models import SentNotification
22 |
23 |
24 | class NotificationBase(object):
25 | """
26 | base class for sending notifications
27 | """
28 |
29 | render_types = []
30 | template_name = None
31 | context = None
32 | user = None
33 | can_disable = True
34 | verbose_name = None
35 |
36 | def get_context_data(self):
37 | """
38 | :return: the context data for rendering the email or text template
39 | """
40 |
41 | context = self.context or {}
42 |
43 | site = Site.objects.get_current()
44 | context["base_url"] = "http://" + site.domain
45 |
46 | return context
47 |
48 | @classmethod
49 | def get_verbose_name(cls):
50 | if cls.verbose_name:
51 | return cls.verbose_name
52 | else:
53 | return re.sub(
54 | r"((?<=[a-z])[A-Z]|(?