├── 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 | 16 | 17 | 18 | 19 | {% for index, name, types, bases in notifications %} 20 | 21 | 22 | 23 | 24 | 25 | {% endfor %} 26 |
NamePreview Link(s)Bases
{{ name }}{% for type in types %}{{ type }}{% if not forloop.last %}, {% endif %}{% endfor %} {% for base in bases %}{{ base }}{% if not forloop.last %}, {% endif %}{% endfor %}
-------------------------------------------------------------------------------- /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 | [![Latest PyPI version](https://badge.fury.io/py/django-herald.svg)](https://pypi.python.org/pypi/django-herald) 4 | [![Tests](https://github.com/worthwhile/django-herald/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/worthwhile/django-herald/actions/workflows/ci.yml) 5 | [![Black](https://github.com/worthwhile/django-herald/actions/workflows/black.yml/badge.svg)](https://github.com/worthwhile/django-herald/actions/workflows/black.yml) 6 | [![Coverage Status](https://codecov.io/gh/worthwhile/django-herald/coverage.svg?branch=master)](https://app.codecov.io/gh/worthwhile/django-herald) 7 | 8 | [![Logo](https://github.com/worthwhile/django-herald/raw/master/logo.png)](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]|(?