├── .github └── workflows │ ├── pr_tests.yml │ └── python-publish.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── coolpeople.md ├── django_notification_system ├── __init__.py ├── admin.py ├── apps.py ├── exceptions.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── create_email_target_user_records.py │ │ └── process_notifications.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20201201_1720.py │ └── __init__.py ├── models │ ├── __init__.py │ ├── abstract.py │ ├── notification.py │ ├── opt_out.py │ ├── target.py │ └── target_user_record.py ├── notification_creators │ ├── __init__.py │ ├── email.py │ ├── expo.py │ └── twilio.py ├── notification_handlers │ ├── __init__.py │ ├── email.py │ ├── expo.py │ └── twilio.py ├── tests │ ├── __init__.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ ├── test_create_email_user_targets.py │ │ │ └── test_process_notifications.py │ ├── mock_exponent_server_sdk.py │ ├── smtp_tests │ │ ├── __init__.py │ │ └── test_smtp_exceptions.py │ └── utils │ │ ├── __init__.py │ │ ├── test_create_email_notification.py │ │ ├── test_create_expo_notification.py │ │ ├── test_create_twilio_notification.py │ │ └── test_send_email.py ├── urls.py └── utils │ ├── __init__.py │ └── admin_site_utils.py ├── docs ├── Makefile ├── _static │ └── css │ │ └── custom.css ├── conf.py ├── extending.rst ├── images │ ├── create_email_target_user_records │ │ ├── create_email_target_user_records_1.png │ │ └── create_email_target_user_records_2.png │ └── utility_functions │ │ ├── create_email_notification.png │ │ ├── create_expo_notification.png │ │ └── create_twilio_notification.png ├── index.rst ├── installation.rst ├── introduction.rst ├── make.bat ├── management_commands.rst ├── models.rst └── utility_functions.rst ├── manage.py ├── requirements.txt ├── setup.cfg ├── setup.py └── test_project ├── __init__.py ├── asgi.py ├── settings.py ├── urls.py └── wsgi.py /.github/workflows/pr_tests.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: PR Tests 4 | 5 | # Controls when the action will run. 6 | on: 7 | # Triggers the workflow on pull request events but only for the main branch 8 | pull_request: 9 | branches: [ main, develop ] 10 | 11 | # Allows you to run this workflow manually from the Actions tab 12 | workflow_dispatch: 13 | 14 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 15 | jobs: 16 | # This workflow contains a single job called "build" 17 | build: 18 | # The type of runner that the job will run on 19 | runs-on: ubuntu-latest 20 | strategy: 21 | max-parallel: 4 22 | matrix: 23 | python-version: [3.8] 24 | 25 | # Steps represent a sequence of tasks that will be executed as part of the job 26 | steps: 27 | - uses: actions/checkout@v2 28 | - name: Set up Python ${{ matrix.python-version }} 29 | uses: actions/setup-python@v2 30 | with: 31 | python-version: ${{ matrix.python-version }} 32 | - name: Install Dependencies 33 | run: | 34 | python -m pip install --upgrade pip 35 | pip install -r requirements.txt 36 | - name: Run Tests 37 | run: | 38 | python manage.py test 39 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine check dist/* 32 | twine upload dist/* 33 | -------------------------------------------------------------------------------- /.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 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # Mac Crap 132 | .DS_Store 133 | 134 | # IDE Stuff 135 | .vscode/ 136 | .vscode/* 137 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Center for Research Computing, University of Notre Dame 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Django Notification System][docs] 2 | 3 | [![pypi-version]][pypi] 4 | 5 | **Finally, an awesome Django Notification System.** 6 | 7 | Full documentation for the package is avalaible at https://django-notification-system.readthedocs.io/en/latest/ 8 | 9 | # Overview 10 | 11 | Perhaps you've got a Django application that you'd like to send notifications from? 12 | 13 | Well, we certainly have our share of them. And guess what? We're tired of writing code to create and send various 14 | types of messages over and over again! 15 | 16 | So, we've created this package to simplify things 17 | a bit for future projects. Hopefully, it will help you too. 18 | 19 | **Here's the stuff you get:** 20 | 21 | 1. A few Django models that are very important: 22 | 23 | - `Notification`: A single notification. Flexible enough to handle many different types of notifications. 24 | - `NotificationTarget`: A target for notifications. Email, SMS, etc. 25 | - `TargetUserRecord`: Info about the user in a given target (Ex. Your "address" in the "email" target). 26 | - `NotificationOptOut`: Single location to keep track of user opt outs. You don't want the spam police after you. 27 | 28 | 2. Built in support for [email, Twilio SMS, and Expo push notifications][docs-util]. 29 | 3. Some cool management commands that: 30 | 31 | - Process all pending notifications. 32 | - Create `UserInNotificationTarget` objects for the email target for all the current users in your database. Just in case you are adding this to an older project. 33 | 34 | 4. A straightforward and fairly easy way to for you to add support for addition notification types while tying into the existing functionality. No whining about it not being super easy! This is still a work in progress. :) 35 | 36 | _Brought to you by the cool kids (er, kids that wanted to be cool) in the Center for Research Computing at Notre Dame._ 37 | 38 | # Requirements 39 | 40 | - Python (3.5, 3.6, 3.7, 3.8) 41 | - Django (3.1+) 42 | 43 | We **highly recommend** and only officially support the latest patch release of 44 | each Python and Django series. 45 | 46 | # Installation 47 | 48 | `pip install django-notification-system` 49 | 50 | # Post-Install Setup (Optional) 51 | 52 | If you would like to add support for addition types of notifications that don't exist in the package yet, 53 | you'll need to add the following items to your Django settings. We will cover these items in more detail 54 | in the [extending the system section of our docs][docs-ext]. So just a quick intro here. 55 | 56 | **Django Settings Additions** 57 | 58 | ```python 59 | # You will need to add email information as specified here: https://docs.djangoproject.com/en/3.1/topics/email/ 60 | # This can include: 61 | EMAIL_HOST = '' 62 | EMAIL_PORT = '' 63 | EMAIL_HOST_USER = '' 64 | EMAIL_HOST_PASSWORD = '' 65 | # and the EMAIL_USE_TLS and EMAIL_USE_SSL settings control whether a secure connection is used. 66 | 67 | INSTALLED_APPS = [ 68 | "django_notification_system", 69 | ...] 70 | # Add the following variables to your Django settings if 71 | # you want to write modules to support additional notification 72 | # types not included the library. 73 | 74 | # A list of locations for the system to search for notification creators. 75 | # You can just create the list and leave it empty if you want to just put this in place. 76 | NOTIFICATION_SYSTEM_CREATORS = [ 77 | '/path/to/creator_modules', 78 | '/another/path/to/creator_modules'] 79 | 80 | # A list of locations for the system to search for notification handlers. 81 | # You can just create the list and leave it empty if you want to just put this in place. 82 | NOTIFICATION_SYSTEM_HANDLERS = [ 83 | '/path/to/handler_modules', 84 | '/another/path/to/handler_modules'] 85 | 86 | NOTIFICATION_SYSTEM_TARGETS = { 87 | # Twilio Required settings, if you're not planning on using Twilio these can be set 88 | # to empty strings 89 | "twilio_sms": { 90 | 'account_sid': '', 91 | 'auth_token': '', 92 | 'sender': '' # This is the phone number associated with the Twilio account 93 | }, 94 | "email": { 95 | 'from_email': '' # Sending email address 96 | } 97 | } 98 | ``` 99 | 100 | [pypi-version]: https://img.shields.io/pypi/v/django-notification-system.svg 101 | [pypi]: https://pypi.org/project/django-notification-system/ 102 | [docs]: https://django-notification-system.readthedocs.io/en/latest/ 103 | [docs-ext]: https://django-notification-system.readthedocs.io/en/latest/extending.html 104 | [docs-util]: https://django-notification-system.readthedocs.io/en/latest/utility_functions.html 105 | -------------------------------------------------------------------------------- /coolpeople.md: -------------------------------------------------------------------------------- 1 | # Cool People 2 | In other words, people who have contributed to this project. You could be on this list too! 3 | 4 | **From the Center for Research Computing** 5 | * Justin Branco @branks42 6 | * Michael Dunn @eikonomega 7 | 8 | **From the Wild Interwebs** 9 | * Niklas Wahl @nklsw 10 | -------------------------------------------------------------------------------- /django_notification_system/__init__.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | default_app_config = "django_notification_system.Config" 4 | 5 | 6 | class Config(AppConfig): 7 | name = "django_notification_system" 8 | -------------------------------------------------------------------------------- /django_notification_system/admin.py: -------------------------------------------------------------------------------- 1 | """Admin interface setup for Notifications feature/app""" 2 | 3 | from django.contrib import admin 4 | from django.utils.safestring import mark_safe 5 | 6 | from .models import ( 7 | Notification, 8 | NotificationOptOut, 9 | NotificationTarget, 10 | TargetUserRecord, 11 | ) 12 | from .utils.admin_site_utils import ( 13 | MeOrAllFilter, 14 | is_null_filter_factory, 15 | USER_SEARCH_FIELDS, 16 | ) 17 | 18 | 19 | @admin.register(Notification) 20 | class NotificationAdmin(admin.ModelAdmin): 21 | list_display = [ 22 | "target_user_record", 23 | "status", 24 | "scheduled_delivery", 25 | "attempted_delivery", 26 | ] 27 | list_filter = [ 28 | "status", 29 | is_null_filter_factory("attempted_delivery"), 30 | "target_user_record__target", 31 | ] 32 | 33 | search_fields = [ 34 | "target_user_record__" + field for field in USER_SEARCH_FIELDS 35 | ] + ["title", "body"] 36 | 37 | autocomplete_fields = ["target_user_record"] 38 | 39 | 40 | @admin.register(NotificationOptOut) 41 | class NotificationOptOutAdmin(admin.ModelAdmin): 42 | list_display = ["user", "active"] 43 | autocomplete_fields = ["user"] 44 | search_fields = USER_SEARCH_FIELDS 45 | 46 | 47 | @admin.register(NotificationTarget) 48 | class NotificationTargetAdmin(admin.ModelAdmin): 49 | list_display = ["name", "notification_module_name"] 50 | 51 | 52 | @admin.register(TargetUserRecord) 53 | class UserInNotificationTargetAdmin(admin.ModelAdmin): 54 | list_display = [ 55 | "user", 56 | "target", 57 | "description", 58 | "target_user_id", 59 | "active", 60 | ] 61 | 62 | list_filter = ["active", MeOrAllFilter, "target"] 63 | 64 | search_fields = USER_SEARCH_FIELDS + ( 65 | "target_user_id", 66 | "description", 67 | ) 68 | -------------------------------------------------------------------------------- /django_notification_system/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | from django.apps import AppConfig 5 | 6 | 7 | class Notifications(AppConfig): 8 | name = 'Notifications' 9 | -------------------------------------------------------------------------------- /django_notification_system/exceptions.py: -------------------------------------------------------------------------------- 1 | """Custom exceptions for Django Notification System App.""" 2 | 3 | 4 | class NotificationSystemError(Exception): 5 | """Parent class for custom exceptions.""" 6 | 7 | pass 8 | 9 | 10 | class NotificationsNotCreated(NotificationSystemError): 11 | """Exception to raise when one or more notifications cannot be created.""" 12 | 13 | pass 14 | 15 | 16 | class NotificationNotSent(NotificationSystemError): 17 | """ 18 | Exception to raise when a notification is not able 19 | to be sent, but we do not have a more specific cause. 20 | """ 21 | 22 | pass 23 | 24 | 25 | class UserIsOptedOut(NotificationNotSent): 26 | """ 27 | Exception to raise when a notification is not able to 28 | be sent because the user is opted out. 29 | """ 30 | 31 | def __init__(self): 32 | super().__init__("User is opted out") 33 | 34 | 35 | class UserHasNoTargetRecords(NotificationNotSent): 36 | """ 37 | Exception to raise when a notification is not able to 38 | be sent because the user has no available targets. 39 | """ 40 | 41 | def __init__(self): 42 | super().__init__("User has no active targets") -------------------------------------------------------------------------------- /django_notification_system/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eikonomega/django-notification-system/e107ed87f2faedba7780494cfa6dd4a155c2fdb5/django_notification_system/management/__init__.py -------------------------------------------------------------------------------- /django_notification_system/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eikonomega/django-notification-system/e107ed87f2faedba7780494cfa6dd4a155c2fdb5/django_notification_system/management/commands/__init__.py -------------------------------------------------------------------------------- /django_notification_system/management/commands/create_email_target_user_records.py: -------------------------------------------------------------------------------- 1 | """Django Management Command.""" 2 | from django.contrib.auth import get_user_model 3 | from django.core.management.base import BaseCommand 4 | 5 | from ...models import TargetUserRecord, NotificationTarget 6 | 7 | User = get_user_model() 8 | 9 | 10 | class Command(BaseCommand): 11 | """ 12 | Create Email UserTargets for all users in the DB. 13 | """ 14 | 15 | help = __doc__ 16 | 17 | def handle(self, *args, **options): 18 | """ 19 | This is what is being run by manage.py 20 | """ 21 | all_users = User.objects.all() 22 | 23 | email_target = NotificationTarget.objects.get( 24 | name='Email', notification_module_name='email') 25 | 26 | for user in all_users: 27 | # Calling this will create an email user target for each user. 28 | if user.email: 29 | TargetUserRecord.objects.update_or_create( 30 | user=user, 31 | target=email_target, 32 | target_user_id=user.email, 33 | defaults={ 34 | "active": True, 35 | "description": f"{user.first_name} {user.last_name}'s Email", 36 | }, 37 | ) 38 | else: 39 | print(f"{user.username} has no email address on record.") -------------------------------------------------------------------------------- /django_notification_system/management/commands/process_notifications.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import inspect 3 | import os 4 | from os import path 5 | 6 | from django.conf import settings 7 | from django.core.management.base import BaseCommand 8 | from django.db.models import Q 9 | from django.utils import timezone 10 | 11 | from ...models import Notification 12 | from ...notification_handlers.email import send_notification as send_email 13 | from ...notification_handlers.twilio import send_notification as send_twilio 14 | from ...notification_handlers.expo import send_notification as send_expo 15 | 16 | 17 | class Command(BaseCommand): 18 | """ 19 | Push all SCHEDULED notifications with a scheduled_delivery before the current date_time 20 | """ 21 | 22 | help = __doc__ 23 | 24 | __function_table = { 25 | "expo": send_expo, 26 | "twilio": send_twilio, 27 | "email": send_email 28 | } 29 | 30 | @classmethod 31 | def _load_function_table(cls): 32 | """ 33 | This function will get our function table populated with all available `send_notification` 34 | functions. 35 | """ 36 | if hasattr(settings, "NOTIFICATION_SYSTEM_HANDLERS"): 37 | for directory in settings.NOTIFICATION_SYSTEM_HANDLERS: 38 | try: 39 | for file in os.listdir(path.join(directory)): 40 | if "__init__" in file: 41 | # Ignore the init file 42 | continue 43 | try: 44 | # Create the module spec 45 | module_spec = importlib.util.spec_from_file_location( 46 | file, f"{directory}/{file}") 47 | # Create a new module based on the spec 48 | module = importlib.util.module_from_spec(module_spec) 49 | # An abstract method that executes the module 50 | module_spec.loader.exec_module(module) 51 | # Get the actual function 52 | real_func = getattr(module, "send_notification") 53 | # Add it to our dictionary of functions 54 | notification_system = file.partition(".py")[0] 55 | cls.__function_table[notification_system] = real_func 56 | except (ModuleNotFoundError, AttributeError): 57 | pass 58 | except FileNotFoundError: 59 | # the directory provided in the settings file does not exist 60 | pass 61 | 62 | def handle(self, *args, **options): 63 | # Load the function table 64 | self._load_function_table() 65 | 66 | # Get all SCHEDULED and RETRY notifications with a 67 | # scheduled_delivery before the current date_time 68 | notifications = Notification.objects.filter( 69 | Q(status="SCHEDULED") | Q(status="RETRY"), 70 | scheduled_delivery__lte=timezone.now(), 71 | ) 72 | 73 | # excludes all notifications where the user has NotificationOptOut object with has_opted_out=True 74 | notifications = notifications.exclude(target_user_record__user__notification_opt_out__active=True) 75 | 76 | # Loop through each notification and attempt to push it 77 | for notification in notifications: 78 | print( 79 | f"{notification.target_user_record.user.username} - {notification.scheduled_delivery} - {notification.status}") 80 | print(f"{notification.title} - {notification.body}") 81 | 82 | if not notification.target_user_record.active: 83 | notification.status = Notification.INACTIVE_DEVICE 84 | notification.save() 85 | else: 86 | notification_type = ( 87 | notification.target_user_record.target.notification_module_name 88 | ) 89 | try: 90 | # Use our function table to call the appropriate sending function 91 | response_message = self.__function_table[notification_type](notification) 92 | except KeyError: 93 | print( 94 | f"invalid notification target name {notification.target_user_record.target.name}") 95 | else: 96 | # The notification was sent successfully 97 | print(response_message) 98 | print("*********************************") 99 | -------------------------------------------------------------------------------- /django_notification_system/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-12-09 21:26 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import uuid 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='NotificationTarget', 20 | fields=[ 21 | ('created_date', models.DateTimeField(auto_now_add=True)), 22 | ('modified_date', models.DateTimeField(auto_now=True)), 23 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 24 | ('name', models.CharField(max_length=15, unique=True)), 25 | ('notification_module_name', models.CharField(max_length=50)), 26 | ], 27 | options={ 28 | 'verbose_name_plural': 'Notification Targets', 29 | 'db_table': 'notification_system_target', 30 | }, 31 | ), 32 | migrations.CreateModel( 33 | name='TargetUserRecord', 34 | fields=[ 35 | ('created_date', models.DateTimeField(auto_now_add=True)), 36 | ('modified_date', models.DateTimeField(auto_now=True)), 37 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 38 | ('target_user_id', models.CharField(max_length=200)), 39 | ('description', models.CharField(max_length=200)), 40 | ('active', models.BooleanField(default=True)), 41 | ('target', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='django_notification_system.notificationtarget')), 42 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_target_user_records', to=settings.AUTH_USER_MODEL)), 43 | ], 44 | options={ 45 | 'verbose_name_plural': 'User In Notification Targets', 46 | 'db_table': 'notification_system_target_user_record', 47 | }, 48 | ), 49 | migrations.CreateModel( 50 | name='NotificationOptOut', 51 | fields=[ 52 | ('created_date', models.DateTimeField(auto_now_add=True)), 53 | ('modified_date', models.DateTimeField(auto_now=True)), 54 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 55 | ('active', models.BooleanField(default=False)), 56 | ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='notification_opt_out', to=settings.AUTH_USER_MODEL)), 57 | ], 58 | options={ 59 | 'verbose_name_plural': 'Notification Opt Outs', 60 | 'db_table': 'notification_system_opt_out', 61 | }, 62 | ), 63 | migrations.CreateModel( 64 | name='Notification', 65 | fields=[ 66 | ('created_date', models.DateTimeField(auto_now_add=True)), 67 | ('modified_date', models.DateTimeField(auto_now=True)), 68 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 69 | ('title', models.CharField(max_length=100)), 70 | ('body', models.TextField()), 71 | ('extra', models.JSONField(blank=True, default=dict, null=True)), 72 | ('status', models.CharField(choices=[('DELIVERED', 'Delivered'), ('DELIVERY FAILURE', 'Delivery Failure'), ('INACTIVE DEVICE', 'Inactive Device'), ('OPTED OUT', 'Opted Out'), ('RETRY', 'Retry'), ('SCHEDULED', 'Scheduled')], max_length=16)), 73 | ('scheduled_delivery', models.DateTimeField()), 74 | ('attempted_delivery', models.DateTimeField(blank=True, null=True)), 75 | ('retry_time_interval', models.PositiveIntegerField(default=0)), 76 | ('retry_attempts', models.PositiveIntegerField(default=0)), 77 | ('max_retries', models.PositiveIntegerField(default=3)), 78 | ('target_user_record', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='django_notification_system.targetuserrecord')), 79 | ], 80 | options={ 81 | 'verbose_name_plural': 'Notifications', 82 | 'db_table': 'notification_system_notification', 83 | }, 84 | ), 85 | migrations.AddConstraint( 86 | model_name='targetuserrecord', 87 | constraint=models.UniqueConstraint(fields=('user', 'target', 'target_user_id'), name='user target ids cannot be repeated'), 88 | ), 89 | migrations.AlterUniqueTogether( 90 | name='notification', 91 | unique_together={('target_user_record', 'scheduled_delivery', 'title', 'extra')}, 92 | ), 93 | ] 94 | -------------------------------------------------------------------------------- /django_notification_system/migrations/0002_auto_20201201_1720.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-12-03 21:28 2 | 3 | from django.db import migrations 4 | 5 | 6 | def create_initial_targets(apps, schema_editor): 7 | """ 8 | Initialize the database with the Targets that we have functions available for. 9 | """ 10 | NotificationTarget = apps.get_model('django_notification_system', 'NotificationTarget') 11 | NotificationTarget.objects.get_or_create( 12 | name="Twilio", 13 | notification_module_name="twilio") 14 | NotificationTarget.objects.get_or_create( 15 | name="Email", 16 | notification_module_name="email") 17 | NotificationTarget.objects.get_or_create( 18 | name="Expo", 19 | notification_module_name="expo") 20 | 21 | 22 | class Migration(migrations.Migration): 23 | 24 | dependencies = [ 25 | ('django_notification_system', '0001_initial'), 26 | ] 27 | 28 | operations = [ 29 | migrations.RunPython(create_initial_targets), 30 | ] 31 | -------------------------------------------------------------------------------- /django_notification_system/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eikonomega/django-notification-system/e107ed87f2faedba7780494cfa6dd4a155c2fdb5/django_notification_system/migrations/__init__.py -------------------------------------------------------------------------------- /django_notification_system/models/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This package contains all model definitions for the Django Notification System. 3 | As a convience, the models are made available directly on the package namespace. 4 | """ 5 | 6 | from .notification import Notification 7 | from .opt_out import NotificationOptOut 8 | from .target import NotificationTarget 9 | from .target_user_record import TargetUserRecord 10 | 11 | __all__ = [ 12 | "NotificationOptOut", 13 | "NotificationTarget", 14 | "TargetUserRecord", 15 | "Notification", 16 | ] 17 | -------------------------------------------------------------------------------- /django_notification_system/models/abstract.py: -------------------------------------------------------------------------------- 1 | """Application Wide Abstract Model Definitions""" 2 | 3 | from django.db import models 4 | 5 | 6 | class CreatedModifiedAbstractModel(models.Model): 7 | """ 8 | Abstract base model that is used to add `created_date` 9 | and `modified_date` fields to all descendant models. 10 | """ 11 | 12 | created_date = models.DateTimeField(auto_now_add=True) 13 | modified_date = models.DateTimeField(auto_now=True) 14 | 15 | class Meta: 16 | abstract = True -------------------------------------------------------------------------------- /django_notification_system/models/notification.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.core.exceptions import ValidationError 4 | from django.db import models 5 | 6 | from .abstract import CreatedModifiedAbstractModel 7 | from .target_user_record import TargetUserRecord 8 | 9 | 10 | class Notification(CreatedModifiedAbstractModel): 11 | """ 12 | Definition of a Notification. 13 | 14 | Attributes 15 | ---------- 16 | id : UUID 17 | The unique UUID of the record. 18 | target_user_record : UserInNotificationTarget 19 | The UserInNotificationTarget associated with notification 20 | title : str 21 | The title for the notification. Exact representation depends on the target. 22 | For example, for an email notification this will be used as the subject of the email. 23 | body : str 24 | The main message of the notification to be sent. 25 | extra : dict 26 | A dictionary of extra data to be sent to the notification processor. Valid keys 27 | are determined by each processor. 28 | status : CharField 29 | The status of Notification. Options are: 'SCHEDULED', 'DELIVERED', 'DELIVERY_FAILURE', 'RETRY', 'INACTIVE_DEVICE' 30 | scheduled_delivery : DateTimeField 31 | Day and time Notification is to be sent. 32 | attempted_delivery : DateTImeField 33 | Day and time attempted to deliver Notification. 34 | retry_time_interval : PositiveIntegerField 35 | If a notification fails, this is the amount of time to wait until retrying to send it. 36 | retry_attempts : PositiveIntegerField 37 | The number of retries that have been attempted. 38 | max_retries : PositiveIntegerField 39 | The max number of allowed retries. 40 | """ 41 | 42 | DELIVERED = "DELIVERED" 43 | DELIVERY_FAILURE = "DELIVERY FAILURE" 44 | INACTIVE_DEVICE = "INACTIVE DEVICE" 45 | OPTED_OUT = "OPTED OUT" 46 | RETRY = "RETRY" 47 | SCHEDULED = "SCHEDULED" 48 | 49 | STATUS_CHOICES = ( 50 | (DELIVERED, "Delivered"), 51 | (DELIVERY_FAILURE, "Delivery Failure"), 52 | (INACTIVE_DEVICE, "Inactive Device"), 53 | (OPTED_OUT, "Opted Out"), 54 | (RETRY, "Retry"), 55 | (SCHEDULED, "Scheduled"), 56 | ) 57 | 58 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 59 | target_user_record = models.ForeignKey( 60 | TargetUserRecord, 61 | on_delete=models.CASCADE, 62 | related_name="notifications", 63 | ) 64 | title = models.CharField(max_length=100) 65 | body = models.TextField() 66 | extra = models.JSONField(blank=True, null=True, default=dict) 67 | status = models.CharField(max_length=16, choices=STATUS_CHOICES) 68 | scheduled_delivery = models.DateTimeField() 69 | attempted_delivery = models.DateTimeField(null=True, blank=True) 70 | retry_time_interval = models.PositiveIntegerField(default=0) 71 | retry_attempts = models.PositiveIntegerField(default=0) 72 | max_retries = models.PositiveIntegerField(default=3) 73 | 74 | class Meta: 75 | db_table = "notification_system_notification" 76 | verbose_name_plural = "Notifications" 77 | unique_together = [ 78 | "target_user_record", 79 | "scheduled_delivery", 80 | "title", 81 | "extra", 82 | ] 83 | 84 | def __str__(self): 85 | return "{} - {} - {}".format( 86 | self.target_user_record.user.username, 87 | self.status, 88 | self.scheduled_delivery, 89 | ) 90 | 91 | def clean(self): 92 | """ 93 | Perform a few data checks whenever an instance is saved. 94 | 95 | 1. Don't allow notifications with an attempted delivery date to 96 | have a status of 'SCHEDULED'. 97 | 2. If a notification has a status other than 'SCHEDULED' it MUST 98 | have an attempted delivery date. 99 | 3. Don't allow notifications to be saved if the user has opted out. 100 | 101 | Raises 102 | ------ 103 | ValidationError 104 | Will include details of what caused the validation error. 105 | """ 106 | opted_out = ( 107 | hasattr(self.target_user_record.user, "notification_opt_out") 108 | and self.target_user_record.user.notification_opt_out.has_opted_out 109 | ) 110 | if opted_out: 111 | raise ValidationError("This user has opted out of Notifications.") 112 | 113 | if self.attempted_delivery and self.status == "SCHEDULED": 114 | raise ValidationError( 115 | "Status cannot be 'SCHEDULED' if there is an attempted delivery." 116 | ) 117 | 118 | if not self.attempted_delivery and self.status not in ["SCHEDULED", "OPTED OUT"]: 119 | raise ValidationError( 120 | "Attempted Delivery must be filled out if Status is {}".format( 121 | self.status 122 | ) 123 | ) 124 | -------------------------------------------------------------------------------- /django_notification_system/models/opt_out.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.conf import settings 4 | from django.db import models 5 | 6 | from .abstract import CreatedModifiedAbstractModel 7 | from .notification import Notification 8 | 9 | 10 | class NotificationOptOut(CreatedModifiedAbstractModel): 11 | """ 12 | Definition of a User Opt-Out Model. 13 | 14 | Users who have opted-out of communications will have an instance of this model. 15 | 16 | Attributes 17 | ---------- 18 | id : UUID 19 | The unique UUID of the record. 20 | user : Django User / Custom User Instance 21 | The User/Custom User instance associated with this record. 22 | active : boolean 23 | Indicator for whether the opt out is active or not. 24 | """ 25 | 26 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 27 | user = models.OneToOneField( 28 | settings.AUTH_USER_MODEL, 29 | on_delete=models.CASCADE, 30 | related_name="notification_opt_out", 31 | ) 32 | active = models.BooleanField(default=False) 33 | 34 | class Meta: 35 | db_table = "notification_system_opt_out" 36 | verbose_name_plural = "Notification Opt Outs" 37 | 38 | def __str__(self): 39 | return self.user.username 40 | 41 | def save(self, *args, **kwargs): 42 | """ 43 | When an instance of this model is saved, if the opt out is active 44 | change the status of notifications with a current status of 45 | SCHEDULED or RETRY to OPTED_OUT. 46 | """ 47 | if self.active: 48 | Notification.objects.filter( 49 | status__in=[Notification.SCHEDULED, Notification.RETRY], 50 | target_user_record__user=self.user, 51 | ).update(status=Notification.OPTED_OUT) 52 | super(NotificationOptOut, self).save(*args, **kwargs) 53 | -------------------------------------------------------------------------------- /django_notification_system/models/target.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.db import models 4 | 5 | from .abstract import CreatedModifiedAbstractModel 6 | 7 | 8 | class NotificationTarget(CreatedModifiedAbstractModel): 9 | """ 10 | Definition of a Notification Target. 11 | 12 | A target represents something that can receive a 13 | notication from our system. 14 | 15 | Attributes 16 | ---------- 17 | id : UUID 18 | The unique UUID of the record. 19 | name : CharField 20 | The human friendly name for the target. 21 | notification_module_name : str 22 | The name of the module in the notification_creators directory which 23 | will be used to create notifications for this target. 24 | """ 25 | 26 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 27 | name = models.CharField(max_length=15, unique=True) 28 | notification_module_name = models.CharField(max_length=50) 29 | 30 | def __str__(self): 31 | return self.name 32 | 33 | class Meta: 34 | db_table = "notification_system_target" 35 | verbose_name_plural = "Notification Targets" 36 | -------------------------------------------------------------------------------- /django_notification_system/models/target_user_record.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.conf import settings 4 | from django.db import models 5 | from django.db import transaction 6 | 7 | from .abstract import CreatedModifiedAbstractModel 8 | from .target import NotificationTarget 9 | 10 | 11 | class TargetUserRecord(CreatedModifiedAbstractModel): 12 | """ 13 | Definition of a TargetUserRecord. 14 | 15 | Each user will have a unique ID for each notification target, 16 | which is how we identify the individual who will receive the 17 | notification. 18 | 19 | For example, for an email target, we need to store the 20 | user's email address. 21 | 22 | Attributes 23 | ---------- 24 | id : UUID 25 | The unique UUID of the record. 26 | user : Django User / Custom User Instance 27 | The User/Custom User instance associated with this record. 28 | target: Foreign Key 29 | The associated target instance. 30 | target_user_id : str 31 | The ID used in the target to uniquely identify the user. 32 | description : str 33 | A human friendly note about the user target. 34 | active : boolean 35 | Indicator of whether user target is active or not. For example, 36 | we have an outdated email record for a user. 37 | """ 38 | 39 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 40 | user = models.ForeignKey( 41 | settings.AUTH_USER_MODEL, 42 | on_delete=models.CASCADE, 43 | related_name="notification_target_user_records", 44 | ) 45 | target = models.ForeignKey(NotificationTarget, on_delete=models.PROTECT) 46 | target_user_id = models.CharField(max_length=200) 47 | description = models.CharField(max_length=200) 48 | active = models.BooleanField(default=True) 49 | 50 | class Meta: 51 | db_table = "notification_system_target_user_record" 52 | verbose_name_plural = "User In Notification Targets" 53 | constraints = [ 54 | models.UniqueConstraint( 55 | fields=["user", "target", "target_user_id"], 56 | name="user target ids cannot be repeated", 57 | ) 58 | ] 59 | 60 | def __str__(self): 61 | return "{}: {}".format(self.user.username, self.description) 62 | -------------------------------------------------------------------------------- /django_notification_system/notification_creators/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eikonomega/django-notification-system/e107ed87f2faedba7780494cfa6dd4a155c2fdb5/django_notification_system/notification_creators/__init__.py -------------------------------------------------------------------------------- /django_notification_system/notification_creators/email.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from django_notification_system.utils import ( 3 | check_for_user_opt_out, 4 | user_notification_targets, 5 | ) 6 | 7 | from django.contrib.auth.models import User 8 | from django.template.loader import get_template 9 | from django.utils import timezone 10 | 11 | from ..models import Notification 12 | from ..exceptions import ( 13 | NotificationsNotCreated, 14 | UserHasNoTargetRecords, 15 | UserIsOptedOut, 16 | ) 17 | 18 | 19 | def create_notification( 20 | user: User, 21 | title: str, 22 | body: str = "", 23 | scheduled_delivery: datetime = None, 24 | retry_time_interval: int = 1440, 25 | max_retries: int = 3, 26 | quiet=False, 27 | extra: dict = None, 28 | ) -> None: 29 | """ 30 | This function will generate an email notification. 31 | 32 | Args: 33 | user (User): The user to whom the notification will be sent. 34 | title (str): The title for the notification. 35 | body (str, optional): Body of the email. Defaults to a blank string if not given. 36 | Additionally, if this parameter is not specific AND "template_name" is present 37 | in `extra`, an attempt will be made to generate the body from that template. 38 | scheduled_delivery (datetime, optional): When to delivery the notification. Defaults to immediately. 39 | retry_time_interval (int, optional): When to retry sending the notification if a delivery failure occurs. Defaults to 1440 seconds. 40 | max_retries (int, optional): Maximum number of retry attempts. Defaults to 3. 41 | quiet (bool, optional): Suppress exceptions from being raised. Defaults to False. 42 | extra (dict, optional): User specified additional data that will be used to 43 | populate an HTML template if "template_name" is present inside. 44 | 45 | Raises: 46 | UserIsOptedOut: When the user has an active opt-out. 47 | UserHasNoTargetRecords: When the user has no eligible targets for this notification type. 48 | NotificationsNotCreated: When the notifications could not be created. 49 | """ 50 | 51 | try: 52 | check_for_user_opt_out(user=user) 53 | except UserIsOptedOut: 54 | if quiet: 55 | return 56 | else: 57 | raise UserIsOptedOut() 58 | 59 | target_user_records = user_notification_targets(user=user, target_name="Email") 60 | 61 | if not target_user_records: 62 | if quiet: 63 | return 64 | else: 65 | raise UserHasNoTargetRecords() 66 | 67 | if scheduled_delivery is None: 68 | scheduled_delivery = timezone.now() 69 | 70 | # Determine the body of the email. Preference is 71 | if body: 72 | email_body = body 73 | elif "template_name" in extra: 74 | # TODO: Look into how this function works and if we can just instruct people to include email templates in the TEMPLATE_DIRS setting. 75 | template = get_template(extra["template_name"]) 76 | email_body = template.render(extra) 77 | else: 78 | raise ValueError( 79 | "You must either specify a `body` value or include 'template_name' in `extra` to create an email notification." 80 | ) 81 | 82 | notifications_created = [] 83 | for target_user_record in target_user_records: 84 | notification, created = Notification.objects.get_or_create( 85 | target_user_record=target_user_record, 86 | title=title, 87 | scheduled_delivery=scheduled_delivery, 88 | defaults={ 89 | "body": email_body, 90 | "status": "SCHEDULED", 91 | "retry_time_interval": retry_time_interval, 92 | "max_retries": max_retries, 93 | }, 94 | ) 95 | 96 | if created: 97 | notifications_created.append(notification) 98 | 99 | if not notifications_created: 100 | if quiet: 101 | return 102 | else: 103 | raise NotificationsNotCreated() 104 | -------------------------------------------------------------------------------- /django_notification_system/notification_creators/expo.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from django_notification_system.exceptions import ( 3 | NotificationsNotCreated, 4 | UserHasNoTargetRecords, 5 | UserIsOptedOut, 6 | ) 7 | from django_notification_system.utils import ( 8 | check_for_user_opt_out, 9 | user_notification_targets, 10 | ) 11 | 12 | from django.contrib.auth.models import User 13 | from django.utils import timezone 14 | 15 | from ..models import Notification 16 | 17 | # TODO: Document what keys Expo supports for the `extra` argument. 18 | 19 | 20 | def create_notification( 21 | user: User, 22 | title: str, 23 | body: str, 24 | scheduled_delivery: datetime = None, 25 | retry_time_interval: int = 60, 26 | max_retries: int = 3, 27 | quiet=False, 28 | extra: dict = None, 29 | ) -> None: 30 | """ 31 | Generate an Expo push notification. 32 | 33 | Expo is a service that is commonly used in React Native mobile development. 34 | 35 | Args: 36 | user (User): The user to whom the push notification will be sent. 37 | title (str): The title for the push notification. 38 | body (str): The body of the push notification. 39 | scheduled_delivery (datetime, optional): Defaults to immediately. 40 | retry_time_interval (int, optional): Delay between send attempts. Defaults to 60 seconds. 41 | max_retries (int, optional): Maximum number of retry attempts for delivery. Defaults to 3. 42 | quiet (bool, optional): Suppress exceptions from being raised. Defaults to False. 43 | extra (dict, optional): Defaults to None. 44 | 45 | Raises: 46 | UserIsOptedOut: When the user has an active opt-out. 47 | UserHasNoTargetRecords: When the user has no eligible targets for this notification type. 48 | NotificationsNotCreated: When the notifications could not be created. 49 | """ 50 | try: 51 | check_for_user_opt_out(user=user) 52 | except UserIsOptedOut: 53 | if quiet: 54 | return 55 | else: 56 | raise UserIsOptedOut() 57 | 58 | target_user_records = user_notification_targets( 59 | user=user, target_name="Expo" 60 | ) 61 | 62 | if not target_user_records: 63 | if quiet: 64 | return 65 | else: 66 | raise UserHasNoTargetRecords() 67 | 68 | if scheduled_delivery is None: 69 | scheduled_delivery = timezone.now() 70 | 71 | notifications_created = [] 72 | for target_user_record in target_user_records: 73 | # JSONField casts None to {}, so we have to check if the server value = {} 74 | if extra is None: 75 | extra = {} # I hate casting so much 76 | notification, created = Notification.objects.get_or_create( 77 | target_user_record=target_user_record, 78 | title=title, 79 | scheduled_delivery=scheduled_delivery, 80 | extra=extra, 81 | defaults={ 82 | "body": body, 83 | "status": "SCHEDULED", 84 | "retry_time_interval": retry_time_interval, 85 | "max_retries": max_retries, 86 | }, 87 | ) 88 | 89 | if created: 90 | notifications_created.append(notification) 91 | 92 | if not notifications_created: 93 | if quiet: 94 | return 95 | else: 96 | raise NotificationsNotCreated() 97 | -------------------------------------------------------------------------------- /django_notification_system/notification_creators/twilio.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from django_notification_system.utils import ( 3 | check_for_user_opt_out, 4 | user_notification_targets, 5 | ) 6 | 7 | from django.contrib.auth.models import User 8 | from django.utils import timezone 9 | 10 | from ..models import Notification 11 | from ..exceptions import ( 12 | NotificationsNotCreated, 13 | UserHasNoTargetRecords, 14 | UserIsOptedOut, 15 | ) 16 | 17 | 18 | def create_notification( 19 | user: User, 20 | title: str, 21 | body: str, 22 | scheduled_delivery: datetime = None, 23 | retry_time_interval: int = 1440, 24 | max_retries: int = 3, 25 | quiet=False, 26 | extra: dict = None, 27 | ) -> None: 28 | """ 29 | This function will generate a Twilio SMS notification. 30 | 31 | Args: 32 | user (User): The user to whom the push notification will be sent. 33 | title (str): The title for the push notification. 34 | body (str, optional): The body of the sms notification. 35 | scheduled_delivery (datetime, optional): Defaults to immediately. 36 | retry_time_interval (int, optional): Delay between send attempts. Defaults to 60 seconds. 37 | max_retries (int, optional): Maximum number of retry attempts for delivery. Defaults to 3. 38 | quiet (bool, optional): Suppress exceptions from being raised. Defaults to False. 39 | extra (dict, optional): Defaults to None. 40 | 41 | Raises: 42 | UserIsOptedOut: When the user has an active opt-out. 43 | UserHasNoTargetRecords: When the user has no eligible targets for this notification type. 44 | NotificationsNotCreated: When the notifications could not be created. 45 | """ 46 | try: 47 | check_for_user_opt_out(user=user) 48 | except UserIsOptedOut: 49 | if quiet: 50 | return 51 | else: 52 | raise UserIsOptedOut() 53 | 54 | target_user_records = user_notification_targets(user=user, target_name="Twilio") 55 | 56 | if not target_user_records: 57 | if quiet: 58 | return 59 | else: 60 | raise UserHasNoTargetRecords() 61 | 62 | if scheduled_delivery is None: 63 | scheduled_delivery = timezone.now() 64 | 65 | notifications_created = [] 66 | for target_user_record in target_user_records: 67 | # JSONField casts None to {}, so we have to check if the server value = {} 68 | if extra is None: 69 | extra = {} # I hate casting so much 70 | notification, created = Notification.objects.get_or_create( 71 | target_user_record=target_user_record, 72 | title=title, 73 | scheduled_delivery=scheduled_delivery, 74 | extra=extra, 75 | defaults={ 76 | "body": body, 77 | "status": 'SCHEDULED', 78 | "retry_time_interval": retry_time_interval, 79 | "max_retries": max_retries 80 | } 81 | ) 82 | 83 | if created: 84 | notifications_created.append(notification) 85 | 86 | if not notifications_created: 87 | if quiet: 88 | return 89 | else: 90 | raise NotificationsNotCreated() 91 | -------------------------------------------------------------------------------- /django_notification_system/notification_handlers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eikonomega/django-notification-system/e107ed87f2faedba7780494cfa6dd4a155c2fdb5/django_notification_system/notification_handlers/__init__.py -------------------------------------------------------------------------------- /django_notification_system/notification_handlers/email.py: -------------------------------------------------------------------------------- 1 | import html2text 2 | import socket 3 | from smtplib import SMTPException 4 | 5 | import django.core.mail 6 | from django.conf import settings 7 | from django.utils import timezone 8 | 9 | from ..utils import check_and_update_retry_attempts 10 | 11 | 12 | def send_notification(notification): 13 | """ 14 | Send email notifications to the target device using the email server. 15 | 16 | Args: 17 | notification (Notification): The email notification to be sent. 18 | 19 | Returns: 20 | str: Whether the email has successfully sent, or an error message. 21 | """ 22 | try: 23 | django.core.mail.send_mail( 24 | subject=notification.title, 25 | message=html2text.html2text(notification.body), 26 | html_message=notification.body, 27 | from_email=settings.NOTIFICATION_SYSTEM_TARGETS['email']['from_email'], 28 | recipient_list=[notification.target_user_record.target_user_id], 29 | fail_silently=False, 30 | ) 31 | 32 | except SMTPException as e: 33 | # Update the notification to retry tomorrow if we are not at the 34 | # max amount of retries. SMTPEXceptions are usually the result of 35 | # hitting our daily limit of emails. 36 | check_and_update_retry_attempts(notification) 37 | return "Email could not be sent: {}".format(e) 38 | 39 | except socket.error as se: 40 | # Update the notification to retry in 90 minutes if we are not at the 41 | # max amount of retries. Socket errors are rare, sporadic and 42 | # inconsistent but usually resolved relatively quickly 43 | check_and_update_retry_attempts(notification, 90) 44 | return "Email could not be sent: {}".format(se) 45 | 46 | # If everything is fine, we update the notification 47 | # to DELIVERED 48 | notification.status = notification.DELIVERED 49 | notification.attempted_delivery = timezone.now() 50 | notification.save() 51 | return "Email Successfully Sent" 52 | -------------------------------------------------------------------------------- /django_notification_system/notification_handlers/expo.py: -------------------------------------------------------------------------------- 1 | """A Django Notification System Handler.""" 2 | from exponent_server_sdk import ( 3 | PushClient, 4 | PushMessage, 5 | PushResponseError, 6 | PushServerError, 7 | DeviceNotRegisteredError, 8 | ) 9 | from requests import HTTPError 10 | 11 | from django.utils import timezone 12 | 13 | from ..utils import check_and_update_retry_attempts 14 | 15 | 16 | def send_notification(notification) -> str: 17 | """ 18 | Send push notifications (Expo) to the target device using the Expo server. 19 | 20 | Args: 21 | notification (Notification): The Expo push notification to be sent. 22 | 23 | Returns: 24 | String: Whether the push notification has successfully sent, or an error message. 25 | """ 26 | extra = prepare_extra(notification.extra) 27 | 28 | try: 29 | response = PushClient().publish( 30 | PushMessage( 31 | to=str(notification.target_user_record.target_user_id), 32 | title=notification.title, 33 | body=notification.body, 34 | data=extra["data"], 35 | sound=extra["sound"], 36 | ttl=extra["ttl"], 37 | expiration=extra["expiration"], 38 | priority=extra["priority"], 39 | badge=extra["badge"], 40 | channel_id=extra["channel_id"], 41 | ) 42 | ) 43 | except (PushServerError, HTTPError, ValueError) as e: 44 | check_and_update_retry_attempts(notification) 45 | return "{}: {}".format(type(e), e) 46 | 47 | return handle_push_response(notification, response) 48 | 49 | 50 | def prepare_extra(extra): 51 | """ 52 | Take in a JSON object from the Notification model instance and prepare a dictionary with all 53 | options available for PushMessage(). Loop through expected push_options and if the option 54 | exists in 'extra' JSON then it gets added to the dict with its value or else it gets added 55 | to the dictionary with None as its value. 56 | 57 | Args: 58 | extra (dict): JSON from the Notification model instance field 'extra' 59 | 60 | Returns: 61 | dict: Dictionary consisting of PushMessage() options 62 | """ 63 | final_extra = {} 64 | push_options = [ 65 | "data", 66 | "sound", 67 | "ttl", 68 | "expiration", 69 | "priority", 70 | "badge", 71 | "channel_id", 72 | ] 73 | for item in push_options: 74 | try: 75 | final_extra[item] = extra[item] 76 | except (KeyError, TypeError): 77 | final_extra[item] = None 78 | return final_extra 79 | 80 | 81 | def handle_push_response(notification, response): 82 | """ 83 | This function handles the push response requested in send_expo() 84 | 85 | Args: 86 | notification (Notification): The Expo push notification to be sent. 87 | response (PushResponse): The Expo push response 88 | 89 | Returns: 90 | str: Whether the push has successfully sent, or an error message. 91 | """ 92 | try: 93 | # We got a response back, but we don't know whether it's an error yet. 94 | # This call raises errors so we can handle them with normal exception 95 | # flows. 96 | response.validate_response() 97 | except DeviceNotRegisteredError as e: 98 | target_user_record = notification.target_user_record 99 | target_user_record.active = False 100 | target_user_record.save() 101 | return "{}: {}".format(type(e), e) 102 | except PushResponseError as e: 103 | check_and_update_retry_attempts(notification) 104 | return "{}: {}".format(type(e), e) 105 | else: 106 | notification.status = notification.DELIVERED 107 | notification.attempted_delivery = timezone.now() 108 | notification.save() 109 | return "Notification Successfully Pushed!" 110 | -------------------------------------------------------------------------------- /django_notification_system/notification_handlers/twilio.py: -------------------------------------------------------------------------------- 1 | from twilio.rest import Client 2 | 3 | from django.conf import settings 4 | from django.utils import timezone 5 | 6 | from ..utils import check_and_update_retry_attempts 7 | 8 | 9 | def send_notification(notification): 10 | """ 11 | Send Twilio notifications to the target device using the Twilio Client. 12 | 13 | Args: 14 | notification (Notification): The email notification to be sent. 15 | 16 | Returns: 17 | String: Whether the SMS has successfully sent, or an error message. 18 | """ 19 | try: 20 | twilio_settings = settings.NOTIFICATION_SYSTEM_TARGETS['twilio_sms'] 21 | twilio_account_sid = twilio_settings['account_sid'] 22 | twilio_auth_token = twilio_settings['auth_token'] 23 | 24 | twilio_sender = twilio_settings['sender'] 25 | twilio_receiver = notification.target_user_record.target_user_id 26 | 27 | client = Client(twilio_account_sid, twilio_auth_token) 28 | 29 | client.messages.create( 30 | body=notification.body, 31 | from_=twilio_sender, 32 | to=twilio_receiver) 33 | 34 | except Exception as e: 35 | check_and_update_retry_attempts(notification) 36 | return ("{}: {}".format(type(e), e)) 37 | 38 | else: 39 | notification.status = notification.DELIVERED 40 | notification.attempted_delivery = timezone.now() 41 | notification.save() 42 | return('SMS Successfully sent!') 43 | -------------------------------------------------------------------------------- /django_notification_system/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eikonomega/django-notification-system/e107ed87f2faedba7780494cfa6dd4a155c2fdb5/django_notification_system/tests/__init__.py -------------------------------------------------------------------------------- /django_notification_system/tests/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eikonomega/django-notification-system/e107ed87f2faedba7780494cfa6dd4a155c2fdb5/django_notification_system/tests/management/__init__.py -------------------------------------------------------------------------------- /django_notification_system/tests/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eikonomega/django-notification-system/e107ed87f2faedba7780494cfa6dd4a155c2fdb5/django_notification_system/tests/management/commands/__init__.py -------------------------------------------------------------------------------- /django_notification_system/tests/management/commands/test_create_email_user_targets.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.core.management import call_command 3 | from django.test import TestCase 4 | from six import StringIO 5 | 6 | from django_notification_system.management.commands.create_email_target_user_records import Command 7 | from django_notification_system.models import TargetUserRecord 8 | 9 | cmd = Command() 10 | 11 | 12 | class TestCommand(TestCase): 13 | def setUp(self): 14 | self.user = User.objects.create_user( 15 | username="Danglesauce", 16 | email="danglesauce@gmail.com", 17 | first_name="Dangle", 18 | last_name="Sauce", 19 | password="ImpressivePassword") 20 | 21 | def test_create_email_target_user_records(self): 22 | """ 23 | Ensure command is functioning correctly. 24 | """ 25 | # Delete UserInNotificationTarget from initial signal. 26 | TargetUserRecord.objects.all().delete() 27 | 28 | pre_call = TargetUserRecord.objects.all() 29 | self.assertEqual(len(pre_call), 0) 30 | 31 | out = StringIO() 32 | call_command('create_email_target_user_records', stdout=out) 33 | 34 | post_call = TargetUserRecord.objects.all() 35 | self.assertEqual(len(post_call), 1) 36 | 37 | for target in post_call: 38 | if target.user == self.user: 39 | self.assertEqual(target.target_user_id, 40 | self.user.email) 41 | -------------------------------------------------------------------------------- /django_notification_system/tests/management/commands/test_process_notifications.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from unittest.mock import patch 3 | 4 | from django.contrib.auth.models import User 5 | from django.core.management import call_command 6 | from django.test import TestCase 7 | from django.utils import timezone 8 | from exponent_server_sdk import PushResponse 9 | from six import StringIO 10 | 11 | from django_notification_system.models import ( 12 | NotificationTarget, TargetUserRecord, Notification, NotificationOptOut) 13 | from django_notification_system.notification_handlers.expo import handle_push_response 14 | from ...mock_exponent_server_sdk import MockPushClient 15 | 16 | 17 | @patch('django_notification_system.notification_handlers.expo.PushClient', new=MockPushClient) 18 | class TestCommand(TestCase): 19 | def setUp(self): 20 | self.user_with_notifications = User.objects.create_user( 21 | username="Eggless", 22 | email="eggless@gmail.com", 23 | first_name="Egg", 24 | last_name="Less", 25 | password="ImpressivePassword") 26 | 27 | self.dev_user = User.objects.create_user( 28 | username="Danglesauce", 29 | email="danglesauce@gmail.com", 30 | first_name="Dangle", 31 | last_name="Sauce", 32 | password="ImpressivePassword") 33 | 34 | self.user_target = TargetUserRecord.objects.create( 35 | user=self.user_with_notifications, 36 | target=NotificationTarget.objects.get(name='Expo'), 37 | target_user_id='ExponentPushToken[ByAAmjPd96SUb1Is5eUzXX]', 38 | description="It's Expo", 39 | active=True 40 | ) 41 | 42 | self.dev_user_target = TargetUserRecord.objects.create( 43 | user=self.dev_user, 44 | target=NotificationTarget.objects.get(name="Expo"), 45 | target_user_id='ExponentPushToken[ByAAmjPd96SUb1Is5eUzXX]', 46 | description='Dangle Expo', 47 | active=True 48 | ) 49 | 50 | self.user_target_email = TargetUserRecord.objects.create( 51 | user=self.user_with_notifications, 52 | target=NotificationTarget.objects.get(name='Email'), 53 | target_user_id=self.user_with_notifications.email, 54 | description='Its email', 55 | active=True, 56 | ) 57 | 58 | self.user_target_twilio = TargetUserRecord.objects.create( 59 | user=self.user_with_notifications, 60 | target=NotificationTarget.objects.get(name='Twilio'), 61 | target_user_id='6766676677', 62 | description='Test Phone #', 63 | active=True 64 | ) 65 | 66 | self.notification = Notification.objects.create( 67 | target_user_record=self.user_target, 68 | status=Notification.SCHEDULED, 69 | title="Title", 70 | body="Body of the message", 71 | extra={ 72 | "sound": "default", 73 | "data": {"Junk": "Junk"}, 74 | "priority": "high", 75 | "ttl": "3", 76 | "expiration": "1486080000", 77 | "badge": "3", 78 | }, 79 | scheduled_delivery="2018-10-24T12:42:13-04:00", 80 | ) 81 | 82 | self.dev_notification = Notification.objects.create( 83 | target_user_record=self.dev_user_target, 84 | status=Notification.SCHEDULED, 85 | title="Title", 86 | body="Whoops", 87 | scheduled_delivery=timezone.now() - timedelta(1), 88 | ) 89 | 90 | self.notification_email = Notification.objects.create( 91 | target_user_record=self.user_target_email, 92 | status=Notification.SCHEDULED, 93 | title="Title", 94 | body="
Body of the message
", 95 | scheduled_delivery=timezone.now() - timedelta(1), 96 | ) 97 | 98 | self.notification_twilio = Notification.objects.create( 99 | target_user_record=self.user_target_twilio, 100 | status=Notification.SCHEDULED, 101 | title='Test SMS', 102 | body="Whoops", 103 | scheduled_delivery=timezone.now() - timedelta(1), 104 | ) 105 | 106 | self.notification_not_to_push = Notification.objects.create( 107 | target_user_record=self.user_target, 108 | status=Notification.DELIVERED, 109 | title="Title2", 110 | body="Body of the message2", 111 | scheduled_delivery=timezone.now() - timedelta(2), 112 | attempted_delivery=timezone.now() - timedelta(1), 113 | ) 114 | 115 | self.email_notification_not_to_push = Notification.objects.create( 116 | target_user_record=self.user_target_email, 117 | status=Notification.DELIVERED, 118 | title="Title2", 119 | body="Body of the message
", 294 | scheduled_delivery="2018-10-24T12:42:13-04:00", 295 | ) 296 | out = StringIO() 297 | call_command("process_notifications", stdout=out) 298 | 299 | Notification.objects.create( 300 | target_user_record=target_user_record, 301 | status=Notification.SCHEDULED, 302 | title="Notification to Not be recieved 1", 303 | body="Body of the message
", 304 | scheduled_delivery="2018-10-24T12:42:13-04:00", 305 | ) 306 | 307 | Notification.objects.create( 308 | target_user_record=target_user_record, 309 | status=Notification.SCHEDULED, 310 | title="Notification to Not be recieved 2", 311 | body="Body of the message
", 312 | scheduled_delivery="2018-10-24T12:42:13-04:00", 313 | ) 314 | 315 | NotificationOptOut.objects.create(user=user, active=True) 316 | 317 | call_command("process_notifications", stdout=out) 318 | 319 | notif_not_sent_1 = Notification.objects.get( 320 | target_user_record=target_user_record, 321 | title="Notification to Not be recieved 1") 322 | notif_not_sent_2 = Notification.objects.get( 323 | target_user_record=target_user_record, 324 | title="Notification to Not be recieved 2") 325 | 326 | self.assertEqual(notif_not_sent_1.status, "OPTED OUT") 327 | self.assertEqual(notif_not_sent_2.status, "OPTED OUT") 328 | 329 | def test_command__inactive_target(self): 330 | """ 331 | Verify a Notification scheduled to be sent to an inactive device is marked as INACTIVE_DEVICE and ignored 332 | """ 333 | notification_to_push = Notification.objects.get(id=self.notification.id) 334 | target_user_record = TargetUserRecord.objects.get( 335 | user=notification_to_push.target_user_record.user, target__name='Expo') 336 | target_user_record.active = False 337 | target_user_record.save() 338 | notification_to_push.user_target = target_user_record 339 | notification_to_push.save() 340 | 341 | self.assertEqual(notification_to_push.status, Notification.SCHEDULED) 342 | self.assertIsNone(notification_to_push.attempted_delivery) 343 | 344 | out = StringIO() 345 | call_command("process_notifications", stdout=out) 346 | 347 | notification_to_push = Notification.objects.get(id=self.notification.id) 348 | self.assertEqual(notification_to_push.status, Notification.INACTIVE_DEVICE) 349 | self.assertIsNone(notification_to_push.attempted_delivery) 350 | 351 | self.assertEqual( 352 | Notification.objects.filter( 353 | target_user_record=notification_to_push.target_user_record, 354 | title=notification_to_push.title, 355 | body=notification_to_push.body, 356 | extra=notification_to_push.extra, 357 | scheduled_delivery=notification_to_push.scheduled_delivery 358 | ).count(), 359 | 1, 360 | ) 361 | 362 | def test_deactivates_on_device_not_registered(self): 363 | """ 364 | Test that a user target is deactivated 365 | when Expo/Firebase Cloud Messaging returns 366 | a device not registered error. 367 | Returns 368 | ------- 369 | 370 | """ 371 | self.assertEqual(self.dev_user_target.active, True) 372 | response = PushResponse( 373 | push_message="", 374 | status=PushResponse.ERROR_STATUS, 375 | message='"adsf" is not a registered push notification recipient', 376 | details={'error': PushResponse.ERROR_DEVICE_NOT_REGISTERED} 377 | ) 378 | handle_push_response(self.dev_notification, response=response) 379 | self.dev_user_target.refresh_from_db() 380 | self.assertEqual(self.dev_user_target.active, False) 381 | -------------------------------------------------------------------------------- /django_notification_system/tests/mock_exponent_server_sdk.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from exponent_server_sdk import PushResponseError, DeviceNotRegisteredError, MessageTooBigError, MessageRateExceededError, PushServerError, PushMessage, PushResponse 3 | 4 | 5 | class MockPushClient(object): 6 | """Exponent push client 7 | 8 | See full API docs at https://docs.expo.io/versions/latest/guides/push-notifications.html#http2-api 9 | """ 10 | DEFAULT_HOST = "https://exp.host" # TODO 11 | DEFAULT_BASE_API_URL = "/--/api/v2" 12 | 13 | def __init__(self, host=None, api_url=None): 14 | """Construct a new PushClient object. 15 | 16 | Args: 17 | host: The server protocol, hostname, and port. 18 | api_url: The api url at the host. 19 | """ 20 | self.host = host 21 | if not self.host: 22 | self.host = MockPushClient.DEFAULT_HOST 23 | 24 | self.api_url = api_url 25 | if not self.api_url: 26 | self.api_url = MockPushClient.DEFAULT_BASE_API_URL 27 | 28 | @classmethod 29 | def is_exponent_push_token(cls, token): 30 | """Returns `True` if the token is an Exponent push token""" 31 | import six 32 | 33 | return ( 34 | isinstance(token, six.string_types) and 35 | token.startswith('ExponentPushToken')) 36 | 37 | def _publish_internal(self, push_messages): 38 | """Send push notifications 39 | 40 | The server will validate any type of syntax errors and the client will 41 | raise the proper exceptions for the user to handle. 42 | 43 | Each notification is of the form: 44 | { 45 | 'to': 'ExponentPushToken[xxx]', 46 | 'body': 'This text gets display in the notification', 47 | 'badge': 1, 48 | 'data': {'any': 'json object'}, 49 | } 50 | 51 | Args: 52 | push_messages: An array of PushMessage objects. 53 | """ 54 | receipts = [] 55 | for i, message in enumerate(push_messages): 56 | payload = message.get_payload() 57 | receipts.append(PushResponse( 58 | push_message=message, 59 | status=PushResponse.SUCCESS_STATUS, 60 | message='', 61 | details=None)) 62 | if payload.get('sound', 'default') != 'default': 63 | raise PushServerError('Request failed', {}) 64 | 65 | return receipts 66 | 67 | def publish(self, push_message): 68 | """Sends a single push notification 69 | 70 | Args: 71 | push_message: A single PushMessage object. 72 | 73 | Returns: 74 | A PushResponse object which contains the results. 75 | """ 76 | return self.publish_multiple([push_message])[0] 77 | 78 | def publish_multiple(self, push_messages): 79 | """Sends multiple push notifications at once 80 | 81 | Args: 82 | push_messages: An array of PushMessage objects. 83 | 84 | Returns: 85 | An array of PushResponse objects which contains the results. 86 | """ 87 | return self._publish_internal(push_messages) 88 | -------------------------------------------------------------------------------- /django_notification_system/tests/smtp_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eikonomega/django-notification-system/e107ed87f2faedba7780494cfa6dd4a155c2fdb5/django_notification_system/tests/smtp_tests/__init__.py -------------------------------------------------------------------------------- /django_notification_system/tests/smtp_tests/test_smtp_exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | from unittest.mock import patch 3 | from smtplib import SMTPException 4 | 5 | from django.contrib.auth.models import User 6 | from django.test.testcases import TestCase 7 | from django.utils import timezone 8 | 9 | 10 | from django_notification_system.models import ( 11 | Notification, NotificationTarget, TargetUserRecord) 12 | 13 | from django_notification_system.notification_handlers.email import send_notification 14 | 15 | 16 | class TestSMTPExceptionEmailNotification(TestCase): 17 | def setUp(self): 18 | self.user_with_target = User.objects.create_user( 19 | username='sadboi@gmail.com', 20 | first_name='Sad', 21 | last_name='Boi', 22 | password='Ok.', 23 | email='sadboi@gmail.com') 24 | 25 | self.target, created = NotificationTarget.objects.get_or_create( 26 | name='Email', 27 | notification_module_name='email' 28 | ) 29 | 30 | self.user_target = TargetUserRecord.objects.create( 31 | user=self.user_with_target, 32 | target=self.target, 33 | target_user_id='sadboi@gmail.com', 34 | description="Sad Boi's Email", 35 | active=True 36 | ) 37 | 38 | self.notification = Notification.objects.create( 39 | target_user_record=self.user_target, 40 | title="Hi.", 41 | body="It me. Is it me?", 42 | status='SCHEDULED', 43 | scheduled_delivery=timezone.now(), 44 | max_retries=2) 45 | 46 | def test_smtp_exception(self): 47 | """ 48 | Test that an smtp_exception is correctly handled. 49 | """ 50 | with patch('django.core.mail.send_mail') as fake_send: 51 | fake_send.side_effect = SMTPException("No server") 52 | 53 | # Assert exception is correctly handled 54 | response_message = send_notification( 55 | self.notification) 56 | 57 | self.assertEqual(response_message, 58 | 'Email could not be sent: No server') 59 | # Assert DELIVERY_FAILURE after max_retries hit 60 | send_notification(self.notification) 61 | self.assertEqual(self.notification.status, 'DELIVERY FAILURE') 62 | -------------------------------------------------------------------------------- /django_notification_system/tests/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eikonomega/django-notification-system/e107ed87f2faedba7780494cfa6dd4a155c2fdb5/django_notification_system/tests/utils/__init__.py -------------------------------------------------------------------------------- /django_notification_system/tests/utils/test_create_email_notification.py: -------------------------------------------------------------------------------- 1 | 2 | from django.contrib.auth.models import User 3 | from django.test.testcases import TestCase 4 | 5 | 6 | from django_notification_system.models import ( 7 | Notification, NotificationTarget, TargetUserRecord) 8 | from django_notification_system.notification_creators.email import ( 9 | create_notification) 10 | 11 | 12 | class TestCreateEmailNotification(TestCase): 13 | def setUp(self): 14 | self.user_with_targets = User.objects.create_user( 15 | username='sadboi@gmail.com', 16 | email='sadboi@gmail.com', 17 | first_name='Sad', 18 | last_name='Boi', 19 | password='Ok.') 20 | 21 | self.user_without_target = User.objects.create_user( 22 | username='skeeter@gmail.com', 23 | email='skeeter@gmail.com', 24 | first_name='Skeeter', 25 | last_name='Skeetington', 26 | password='Sure.') 27 | 28 | # We call this to create the email user_target. 29 | self.user_with_targets.save() 30 | 31 | self.target, created = NotificationTarget.objects.get_or_create( 32 | name='Email', 33 | notification_module_name='email' 34 | ) 35 | 36 | self.target_user_record = TargetUserRecord.objects.create( 37 | user=self.user_with_targets, 38 | target=self.target, 39 | target_user_id='sadboi@gmail.com', 40 | description="Sad Boi's Email", 41 | active=True 42 | ) 43 | 44 | def test_successfully_create_notifications(self): 45 | """ 46 | This test checks that email notifications are created for all 47 | email user targets associated with the user. 48 | """ 49 | pre_function_notifications = Notification.objects.all() 50 | self.assertEqual(len(pre_function_notifications), 0) 51 | 52 | create_notification( 53 | user=self.user_with_targets, 54 | title="Hi.", 55 | body="Hello there, friend.") 56 | 57 | post_function_notifications = Notification.objects.all() 58 | self.assertEqual(len(post_function_notifications), 1) 59 | -------------------------------------------------------------------------------- /django_notification_system/tests/utils/test_create_expo_notification.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.test.testcases import TestCase 3 | from django.utils import timezone 4 | 5 | from django_notification_system.models import ( 6 | Notification, NotificationTarget, TargetUserRecord, NotificationOptOut) 7 | from django_notification_system.notification_creators.expo import ( 8 | create_notification) 9 | from django_notification_system.exceptions import UserIsOptedOut, UserHasNoTargetRecords 10 | 11 | 12 | class TestCreateNotification(TestCase): 13 | def setUp(self): 14 | self.user_with_targets = User.objects.create_user( 15 | username='sadboi@gmail.com', 16 | email='sadboi@gmail.com', 17 | first_name='Sad', 18 | last_name='Boi', 19 | password='Ok.') 20 | 21 | self.user_without_target = User.objects.create_user( 22 | username='skeeter@gmail.com', 23 | email='skeeter@gmail.com', 24 | first_name='Skeeter', 25 | last_name='Skeetington', 26 | password='Sure.') 27 | 28 | self.target, created = NotificationTarget.objects.get_or_create( 29 | name='Expo', 30 | notification_module_name='expo') 31 | 32 | self.user_target1 = TargetUserRecord.objects.create( 33 | user=self.user_with_targets, 34 | target=self.target, 35 | target_user_id='291747127401', 36 | description='Happy Phone') 37 | 38 | self.user_target2 = TargetUserRecord.objects.create( 39 | user=self.user_with_targets, 40 | target=self.target, 41 | target_user_id='92369ryweifwe', 42 | description='Happier Phone') 43 | 44 | def test_successfully_create_expo_notificationss(self): 45 | """ 46 | This test checks that notifications are created for all 47 | user targets associated with the user. 48 | """ 49 | pre_function_notifications = Notification.objects.all() 50 | self.assertEqual(len(pre_function_notifications), 0) 51 | 52 | create_notification(user=self.user_with_targets, 53 | title="Wow", 54 | body="You really did it!") 55 | 56 | post_function_notifications = Notification.objects.all() 57 | self.assertEqual(len(post_function_notifications), 2) 58 | 59 | def test_successfully_create_expo_notif_w_opt_out(self): 60 | """ 61 | This test checks that notifications are created for all 62 | user targets associated with the user. 63 | """ 64 | pre_function_notifications = Notification.objects.all() 65 | self.assertEqual(len(pre_function_notifications), 0) 66 | 67 | NotificationOptOut.objects.create(user=self.user_with_targets) 68 | 69 | create_notification(user=self.user_with_targets, 70 | title="Wow", 71 | body="You really did it!") 72 | 73 | post_function_notifications = Notification.objects.all() 74 | self.assertEqual(len(post_function_notifications), 2) 75 | 76 | def test_no_notifications_created__no_target(self): 77 | """ 78 | If a user has no targets then no notifications can 79 | be created. 80 | """ 81 | pre_function_notifications = Notification.objects.all() 82 | self.assertEqual(len(pre_function_notifications), 0) 83 | 84 | try: 85 | create_notification(user=self.user_without_target, 86 | title="Wow", 87 | body="You really did it!") 88 | except UserHasNoTargetRecords: 89 | pass 90 | 91 | post_function_notifications = Notification.objects.all() 92 | self.assertEqual(len(post_function_notifications), 0) 93 | 94 | def test_no_notifications_created__opt_out(self): 95 | """ 96 | This test checks that notifications are created for all 97 | user targets associated with the user. 98 | """ 99 | pre_function_notifications = Notification.objects.all() 100 | self.assertEqual(len(pre_function_notifications), 0) 101 | 102 | NotificationOptOut.objects.create(user=self.user_with_targets, active=True) 103 | 104 | try: 105 | create_notification(user=self.user_with_targets, 106 | title="Wow", 107 | body="You really did it!") 108 | except UserIsOptedOut: 109 | pass 110 | 111 | post_function_notifications = Notification.objects.all() 112 | self.assertEqual(len(post_function_notifications), 0) 113 | -------------------------------------------------------------------------------- /django_notification_system/tests/utils/test_create_twilio_notification.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.test.testcases import TestCase 3 | from django.utils import timezone 4 | 5 | from django_notification_system.models import ( 6 | Notification, NotificationTarget, TargetUserRecord, NotificationOptOut) 7 | from django_notification_system.notification_creators.twilio import ( 8 | create_notification) 9 | from django_notification_system.exceptions import UserIsOptedOut, UserHasNoTargetRecords 10 | 11 | 12 | class TestCreateNotification(TestCase): 13 | def setUp(self): 14 | self.user_with_targets = User.objects.create_user( 15 | username='sadboi@gmail.com', 16 | email='sadboi@gmail.com', 17 | first_name='Sad', 18 | last_name='Boi', 19 | password='Ok.') 20 | 21 | self.user_without_target = User.objects.create_user( 22 | username='skeeter@gmail.com', 23 | email='skeeter@gmail.com', 24 | first_name='Skeeter', 25 | last_name='Skeetington', 26 | password='Sure.') 27 | 28 | self.target, created = NotificationTarget.objects.get_or_create( 29 | name='Twilio', 30 | notification_module_name='twilio') 31 | 32 | self.user_target1 = TargetUserRecord.objects.create( 33 | user=self.user_with_targets, 34 | target=self.target, 35 | target_user_id='2917471274', 36 | description='Happy Phone') 37 | 38 | self.user_target2 = TargetUserRecord.objects.create( 39 | user=self.user_with_targets, 40 | target=self.target, 41 | target_user_id='2917471271', 42 | description='Happier Phone') 43 | 44 | def test_successfully_create_expo_notificationss(self): 45 | """ 46 | This test checks that notifications are created for all 47 | user targets associated with the user. 48 | """ 49 | pre_function_notifications = Notification.objects.all() 50 | self.assertEqual(len(pre_function_notifications), 0) 51 | 52 | create_notification(user=self.user_with_targets, 53 | title="Wow", 54 | body="You really did it!") 55 | 56 | post_function_notifications = Notification.objects.all() 57 | self.assertEqual(len(post_function_notifications), 2) 58 | 59 | def test_successfully_create_expo_notif_w_opt_out(self): 60 | """ 61 | This test checks that notifications are created for all 62 | user targets associated with the user. 63 | """ 64 | pre_function_notifications = Notification.objects.all() 65 | self.assertEqual(len(pre_function_notifications), 0) 66 | 67 | NotificationOptOut.objects.create(user=self.user_with_targets) 68 | 69 | create_notification(user=self.user_with_targets, 70 | title="Wow", 71 | body="You really did it!") 72 | 73 | post_function_notifications = Notification.objects.all() 74 | self.assertEqual(len(post_function_notifications), 2) 75 | 76 | def test_no_notifications_created__no_target(self): 77 | """ 78 | If a user has no targets then no notifications can 79 | be created. 80 | """ 81 | pre_function_notifications = Notification.objects.all() 82 | self.assertEqual(len(pre_function_notifications), 0) 83 | 84 | try: 85 | create_notification(user=self.user_without_target, 86 | title="Wow", 87 | body="You really did it!") 88 | except UserHasNoTargetRecords: 89 | pass 90 | 91 | post_function_notifications = Notification.objects.all() 92 | self.assertEqual(len(post_function_notifications), 0) 93 | 94 | def test_no_notifications_created__opt_out(self): 95 | """ 96 | This test checks that notifications are created for all 97 | user targets associated with the user. 98 | """ 99 | pre_function_notifications = Notification.objects.all() 100 | self.assertEqual(len(pre_function_notifications), 0) 101 | 102 | NotificationOptOut.objects.create(user=self.user_with_targets, active=True) 103 | 104 | try: 105 | create_notification(user=self.user_with_targets, 106 | title="Wow", 107 | body="You really did it!") 108 | except UserIsOptedOut: 109 | pass 110 | 111 | post_function_notifications = Notification.objects.all() 112 | self.assertEqual(len(post_function_notifications), 0) -------------------------------------------------------------------------------- /django_notification_system/tests/utils/test_send_email.py: -------------------------------------------------------------------------------- 1 | 2 | from django.contrib.auth.models import User 3 | from django.test.testcases import TestCase 4 | from django.utils import timezone 5 | 6 | 7 | from django_notification_system.models import ( 8 | Notification, NotificationTarget, TargetUserRecord) 9 | from django_notification_system.notification_handlers.email import ( 10 | send_notification) 11 | 12 | 13 | class TestCreateEmailNotification(TestCase): 14 | def setUp(self): 15 | self.user_with_target = User.objects.create_user( 16 | username='sadboi@gmail.com', 17 | first_name='Sad', 18 | last_name='Boi', 19 | password='Ok.', 20 | email='sadboi@gmail.com') 21 | 22 | self.target_user_record = TargetUserRecord.objects.create( 23 | user=self.user_with_target, 24 | target=NotificationTarget.objects.get(name='Email'), 25 | target_user_id='sadboi@gmail.com', 26 | description='Sad Bois Email', 27 | active=True 28 | ) 29 | 30 | self.notification = Notification.objects.create( 31 | target_user_record=self.target_user_record, 32 | title="Hi.", 33 | body="It me. Is it me?", 34 | status='SCHEDULED', 35 | scheduled_delivery=timezone.now()) 36 | 37 | def test_send_email(self): 38 | """ 39 | Test proper functionality of send_email function. 40 | """ 41 | pre_function_notification = Notification.objects.all() 42 | self.assertEqual(pre_function_notification[0].status, 43 | 'SCHEDULED') 44 | 45 | response_message = send_notification(self.notification) 46 | 47 | self.assertEqual(response_message, 'Email Successfully Sent') 48 | -------------------------------------------------------------------------------- /django_notification_system/urls.py: -------------------------------------------------------------------------------- 1 | from .views.notification_blast import ( 2 | NotificationBlasterView, 3 | NotificationsBlastedView, 4 | ) 5 | 6 | from django.urls import path 7 | 8 | 9 | urlpatterns = [] 10 | 11 | -------------------------------------------------------------------------------- /django_notification_system/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from dateutil.relativedelta import relativedelta 2 | 3 | from django.contrib.auth.models import User 4 | from django.utils import timezone 5 | 6 | from django_notification_system.exceptions import UserIsOptedOut 7 | from django_notification_system.models.target_user_record import ( 8 | TargetUserRecord, 9 | ) 10 | 11 | 12 | def check_for_user_opt_out(user: User): 13 | """Determine if a user has an active opt-out. 14 | 15 | Args: 16 | user (User): The user to perform the check on. 17 | 18 | Raises: 19 | UserIsOptedOut: If the user has an active opt out. 20 | """ 21 | if ( 22 | hasattr(user, "notification_opt_out") 23 | and user.notification_opt_out.active 24 | ): 25 | raise UserIsOptedOut 26 | 27 | 28 | def user_notification_targets(user: User, target_name: str): 29 | """Return all active user notifications targets for a given notification target. 30 | 31 | Args: 32 | user (User): The user to retrieve user targets for. 33 | target_name (str): The name of the target to retrieve user targets for. 34 | 35 | Returns: 36 | [UserInNotificationTarget]: A Django queryset of UserInNotificationTarget instances. 37 | """ 38 | return TargetUserRecord.objects.filter( 39 | user=user, 40 | target__name=target_name, 41 | active=True, 42 | ) 43 | 44 | 45 | def check_and_update_retry_attempts(notification, minute_interval=None): 46 | """ 47 | Check if the retry_attempt and max_retries are equal. 48 | If they are we want to change the status to DELIVERY_FAILURE. 49 | If not, we want to RETRY at a given minute interval. 50 | """ 51 | if notification.max_retries != notification.retry_attempts: 52 | notification.retry_attempts += 1 53 | notification.save() 54 | # We have to go deeper... 55 | if notification.max_retries != notification.retry_attempts: 56 | notification.status = notification.RETRY 57 | if minute_interval is not None: 58 | notification.scheduled_delivery = ( 59 | timezone.now() + relativedelta(minutes=minute_interval) 60 | ) 61 | else: 62 | notification.scheduled_delivery = ( 63 | timezone.now() 64 | + relativedelta(minutes=notification.retry_time_interval) 65 | ) 66 | notification.attempted_delivery = timezone.now() 67 | notification.save() 68 | 69 | else: 70 | notification.status = notification.DELIVERY_FAILURE 71 | notification.attempted_delivery = timezone.now() 72 | notification.save() 73 | -------------------------------------------------------------------------------- /django_notification_system/utils/admin_site_utils.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.db.models import QuerySet 3 | from django.http import HttpRequest 4 | from django.urls import reverse 5 | from django.utils.safestring import mark_safe 6 | 7 | 8 | class EditLinkToInlineObject(object): 9 | """ 10 | Adapted from https://stackoverflow.com/a/22113967/5666845 11 | 12 | Usage: 13 | 14 | @admin.register(MyModel) 15 | class MyModelAdmin: 16 | pass 17 | 18 | class MyModelInline(EditLinkToInlineObject, admin.TabularInline): 19 | model = MyModel 20 | readonly_fields = ('edit_link', ) 21 | 22 | @admin.register(MySecondModel) 23 | class MySecondModelAdmin(admin.ModelAdmin): 24 | inlines = (MyModelInline, ) 25 | 26 | """ 27 | 28 | def edit_link(self, instance): 29 | url = reverse( 30 | "admin:%s_%s_change" 31 | % (instance._meta.app_label, instance._meta.model_name), 32 | args=[instance.pk], 33 | ) 34 | if instance.pk: 35 | return mark_safe( 36 | 'edit'.format(u=url) 37 | ) 38 | else: 39 | return "" 40 | 41 | 42 | class MeOrAllFilter(admin.SimpleListFilter): 43 | """ 44 | Users can filter Models with a `user` field by the current user, or all users 45 | 46 | Usage: 47 | import MeOrAllFilter and add it to the ModelAdmin's list_filter 48 | 49 | Example: 50 | ``` 51 | @admin.register(MyModel) 52 | class MyModelAdmin(admin.ModelAdmin): 53 | list_filter = [MeOrAllFilter,] 54 | ``` 55 | 56 | """ 57 | 58 | # based on https://docs.djangoproject.com/en/2.2/ref/contrib/admin/#django.contrib.admin.ModelAdmin.list_filter 59 | title = "User" 60 | parameter_name = "just_me" 61 | 62 | def lookups(self, request: HttpRequest, model_admin: admin.ModelAdmin): 63 | return [("true", "Just Me")] # 'All' option automatically provided 64 | 65 | def queryset(self, request: HttpRequest, queryset: QuerySet): 66 | if self.value() == "true": 67 | queryset = queryset.filter(user=request.user) 68 | return queryset 69 | 70 | 71 | # TODO (low priority): there's a more django-ic way to do this 72 | def is_null_filter_factory(field: str): 73 | """ 74 | Creates a filter for is/is not null for the specified field 75 | A proper FieldListFilter would do something a little more complicated 76 | usage: 77 | 78 | @admin.register(ActivityAssignment) 79 | class ActivityAssignmentAdmin(admin.ModelAdmin): 80 | list_display = [...] 81 | readonly_fields = [...] 82 | list_filter = [ 83 | ('actual_end', admin.DateFieldListFilter), 84 | ('activity', admin.RelatedFieldListFilter), 85 | is_null_filter_factory('response'), 86 | ] 87 | 88 | """ 89 | 90 | class IsNullFilter(admin.SimpleListFilter): 91 | # based on https://docs.djangoproject.com/en/2.2/ref/contrib/admin/#django.contrib.admin.ModelAdmin.list_filter 92 | title = f"{field} is null" 93 | parameter_name = f"{field}__isnull" 94 | 95 | def lookups(self, request: HttpRequest, model_admin: admin.ModelAdmin): 96 | return [("null", "Is Null"), ("not_null", "Is not Null")] 97 | 98 | def queryset(self, request: HttpRequest, queryset: QuerySet): 99 | if self.value() == "null": 100 | queryset = queryset.filter(**{field + "__isnull": True}) 101 | elif self.value() == "not_null": 102 | queryset = queryset.exclude(**{field + "__isnull": True}) 103 | return queryset 104 | 105 | return IsNullFilter 106 | 107 | 108 | USER_SEARCH_FIELDS = ( 109 | "user__username", 110 | "user__first_name", 111 | "user__last_name", 112 | "user__email", 113 | ) 114 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/_static/css/custom.css: -------------------------------------------------------------------------------- 1 | colgroup > col { 2 | width: fit-content !important; 3 | } 4 | 5 | td > p { 6 | font-size: 14px; 7 | } 8 | 9 | tr td:last-child > p { 10 | white-space: normal; 11 | } 12 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = "Django Notification System" 21 | copyright = "2020, Justin Branco, Michael Dunn" 22 | author = "Justin Branco, Michael Dunn" 23 | 24 | # The full version, including alpha/beta/rc tags 25 | release = "1.0" 26 | 27 | 28 | # -- General configuration --------------------------------------------------- 29 | master_doc = "index" 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = ["sphinx_rtd_theme", "sphinxcontrib.httpdomain"] 34 | 35 | # Add any paths that contain templates here, relative to this directory. 36 | templates_path = ["_templates"] 37 | 38 | # List of patterns, relative to source directory, that match files and 39 | # directories to ignore when looking for source files. 40 | # This pattern also affects html_static_path and html_extra_path. 41 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 42 | 43 | 44 | # -- Options for HTML output ------------------------------------------------- 45 | 46 | # The theme to use for HTML and HTML Help pages. See the documentation for 47 | # a list of builtin themes. 48 | # 49 | html_theme = "sphinx_rtd_theme" 50 | 51 | # Add any paths that contain custom static files (such as style sheets) here, 52 | # relative to this directory. They are copied after the builtin static files, 53 | # so a file named "default.css" will overwrite the builtin "default.css". 54 | html_static_path = ["_static"] 55 | html_css_files = [ 56 | "css/custom.css", 57 | ] -------------------------------------------------------------------------------- /docs/extending.rst: -------------------------------------------------------------------------------- 1 | Adding Support for Custom Notification Targets 2 | ============================================== 3 | 4 | Option 1: Beg us to do it. 5 | -------------------------- 6 | In terms of easy to do, this would be at the top of the list. However, we've got to be 7 | honest. We're crazy busy usually, so the chances that we will be able to do this aren't 8 | great. However, if we see a request that we think would have a lot of mileage in it we 9 | may take it up. 10 | 11 | If you want to try this method, just submit an issue on the |github_repo| 12 | 13 | .. |github_repo| raw:: html 14 | 15 | Github repo. 16 | 17 | Option 2: Add Support Yourself 18 | ------------------------------------------------------------- 19 | Ok, you can do this! It's actually pretty easy. Here is the big picture. 20 | Let's go through it step by step. 21 | 22 | Step 1: Add Required Django Settings 23 | ++++++++++++++++++++++++++++++++++++ 24 | 25 | The first step is to tell Django where to look for custom notification creators and handlers. 26 | Here is how you do that. 27 | 28 | **Django Settings Additions** 29 | .. code-block:: python 30 | 31 | # A list of locations for the system to search for notification creators. 32 | # For each location listed, each module will be searched for a `create_notification` function. 33 | NOTIFICATION_SYSTEM_CREATORS = [ 34 | '/path/to/creator_modules', 35 | '/another/path/to/creator_modules'] 36 | 37 | # A list of locations for the system to search for notification handlers. 38 | # For each location listed, each module will be searched for a `send_notification` function. 39 | NOTIFICATION_SYSTEM_HANDLERS = [ 40 | '/path/to/handler_modules', 41 | '/another/path/to/handler_modules'] 42 | 43 | Step 2: Create the Notification Target 44 | ++++++++++++++++++++++++++++++++++++++ 45 | 46 | Now that you've added the required Django settings, we need to create a ``NotificationTarget`` object 47 | for your custom target. 48 | 49 | **Example: Creating a New Notification Target** 50 | .. code-block:: python 51 | 52 | from django_notification_system.models import NotificationTarget 53 | 54 | # Note: The notification_module_name will be the name of the modules you will write 55 | # to support the new notification target. 56 | # Example: If in my settings I have the NOTIFICATION_SYSTEM_HANDLERS = ["/path/to/extra_handlers"], 57 | # and inside that directory I have a file called 'carrier_pigeon.py', the notification_module_name should be 'carrier_pigeon' 58 | target = NotificationTarget.objects.create( 59 | name='Carrier Pigeon', 60 | notification_module_name='carrier_pigeon') 61 | 62 | Step 2: Add a Notification Creator 63 | ++++++++++++++++++++++++++++++++++ 64 | 65 | Next, we need to create the corresponding creator and handler functions. 66 | We'll start with the handler function. 67 | 68 | In the example above, you created a ``NotificationTarget`` and set it's ``notification_module_name`` to ``carrier_pigeon``. 69 | This means that the ``process_notifications`` management command is going to look for modules named ``carrier_pigeon`` in the paths 70 | specified by your Django settings additions to find the necessary creator and handler functions. 71 | 72 | Let's start by writing our creator function. 73 | 74 | **Example: Creating the Carrier Pigeon Notification Creator** 75 | .. code-block:: python 76 | 77 | # /path/to/creators/carrier_pigeon.py 78 | 79 | from datetime import datetime 80 | from django.utils import timezone 81 | from django.contrib.auth import get_user_model 82 | 83 | # Some common exceptions you might want to use. 84 | from django_notification_system.exceptions import ( 85 | NotificationsNotCreated, 86 | UserHasNoTargetRecords, 87 | UserIsOptedOut, 88 | ) 89 | 90 | # A utility function to see if the user has an opt-out. 91 | from django_notification_system.utils import ( 92 | check_for_user_opt_out 93 | ) 94 | 95 | from ..models import Notification, TargetUserRecord 96 | 97 | # NOTE: The function MUST be named `create_notification` 98 | def create_notification( 99 | user: 'Django User', 100 | title: str, 101 | body: str, 102 | scheduled_delivery: datetime = None, 103 | retry_time_interval: int = 60, 104 | max_retries: int = 3, 105 | quiet=False, 106 | extra: dict = None, 107 | ) -> None: 108 | """ 109 | Create a Carrier Pigeon notification. 110 | 111 | Args: 112 | user (User): The user to whom the notification will be sent. 113 | title (str): The title for the notification. 114 | body (str): The body of the notification. 115 | scheduled_delivery (datetime, optional): Defaults to immediately. 116 | retry_time_interval (int, optional): Delay between send attempts. Defaults to 60 seconds. 117 | max_retries (int, optional): Maximum number of retry attempts for delivery. Defaults to 3. 118 | quiet (bool, optional): Suppress exceptions from being raised. Defaults to False. 119 | extra (dict, optional): Defaults to None. 120 | 121 | Raises: 122 | UserIsOptedOut: When the user has an active opt-out. 123 | UserHasNoTargetRecords: When the user has no eligible targets for this notification type. 124 | NotificationsNotCreated: When the notifications could not be created. 125 | """ 126 | 127 | # Check if user is opted-out. 128 | try: 129 | check_for_user_opt_out(user=user) 130 | except UserIsOptedOut: 131 | if quiet: 132 | return 133 | else: 134 | raise UserIsOptedOut() 135 | 136 | # Grab all active TargetUserRecords in the Carrier Pigeon target 137 | # the user has. You NEVER KNOW if they might have more than one pigeon. 138 | carrier_pigeon_user_records = TargetUserRecord.objects.filter( 139 | user=user, 140 | target__name="Carrier Pigeon", 141 | active=True, 142 | ) 143 | 144 | # If the user has no active carrier pigions, we 145 | # can't create any notifications for them. 146 | if not carrier_pigeon_user_records: 147 | if quiet: 148 | return 149 | else: 150 | raise UserHasNoTargetRecords() 151 | 152 | # Provide a default scheduled delivery if none is provided. 153 | if scheduled_delivery is None: 154 | scheduled_delivery = timezone.now() 155 | 156 | notifications_created = [] 157 | for record in carrier_pigeon_user_records: 158 | 159 | if extra is None: 160 | extra = {} 161 | 162 | # Create notifications while taking some precautions 163 | # not to duplicate ones that are already there. 164 | notification, created = Notification.objects.get_or_create( 165 | target_user_record=record, 166 | title=title, 167 | scheduled_delivery=scheduled_delivery, 168 | extra=extra, 169 | defaults={ 170 | "body": body, 171 | "status": "SCHEDULED", 172 | "retry_time_interval": retry_time_interval, 173 | "max_retries": max_retries, 174 | }, 175 | ) 176 | 177 | # If a new notification was created, add it to the list. 178 | if created: 179 | notifications_created.append(notification) 180 | 181 | # If no notifications were created, possibly raise an exception. 182 | if not notifications_created: 183 | if quiet: 184 | return 185 | else: 186 | raise NotificationsNotCreated() 187 | 188 | 189 | Step 3: Add a Notification Handler 190 | ++++++++++++++++++++++++++++++++++ 191 | 192 | Alright my friend, last step. The final thing you need to do is write a 193 | notification handler. These are used by the `process_notifications` management 194 | command to actual send the notifications to the various targets. 195 | 196 | For the sake of illustration, we'll continue with our carrier pigeon example. 197 | 198 | **Example: Creating the Carrier Pigeon Notification Handler** 199 | .. code-block:: python 200 | 201 | # /path/to/hanlders/carrier_pigeon.py 202 | 203 | from dateutil.relativedelta import relativedelta 204 | from django.utils import timezone 205 | 206 | # Usually, the notification provider will have either an 207 | # existing Python SDK or RestFUL API which your handler 208 | # will need to interact with. 209 | from carrior_pigeon_sdk import ( 210 | request_delivery, 211 | request_priority_delivery, 212 | request_economy_aka_old_pigeon_delivery 213 | PigeonDiedException, 214 | PigeonGotLostException 215 | ) 216 | 217 | from ..utils import check_and_update_retry_attempts 218 | 219 | # You MUST have a function called send_notification in this module. 220 | def send_notification(notification) -> str: 221 | """ 222 | Send a notification to the carrior pigeon service for delivery. 223 | 224 | Args: 225 | notification (Notification): The notification to be delivery by carrior pigeon. 226 | 227 | Returns: 228 | str: Whether the push notification has successfully sent, or an error message. 229 | """ 230 | try: 231 | # Invoke whatever method of the target service you need to. 232 | # Notice how the handler is responsible to translate data 233 | # from the `Notification` record to what is needed by the service. 234 | response = request_delivery( 235 | recipient=notification.target_user_record.target_user_id, 236 | sender="My Cool App", 237 | title=notification.title, 238 | body=notification.body, 239 | talking_pigeon=True if "speak_message" in test and extra["speak_message"] else False, 240 | pay_on_delivery=True if "cheapskate" in test and extra["cheapskate"] else False 241 | ) 242 | 243 | except PigeonDiedException as error: 244 | # Probably not going to be able to reattempt delivery. 245 | notification.attempted_delivery = timezone.now() 246 | notification.status = notification.DELIVERY_FAILURE 247 | notification.save() 248 | 249 | # This string will be displayed by the 250 | # `process_notifications` management command. 251 | return "Yeah, so, your pigeon died. Wah wah." 252 | 253 | except PigeonGotLostException as error: 254 | notification.attempted_delivery = timezone.now() 255 | 256 | # In this case, it is possible to attempt another delivery. 257 | # BUT, we should check if the max attempts have been made. 258 | if notification.retry_attempts < notification.max_retries: 259 | notification.status = notification.RETRY 260 | notification.scheduled_delivery = timezone.now() + relativedelta( 261 | minutes=notification.retry_time_interval) 262 | notification.save() 263 | return "Your bird got lost, but we'll give it another try later." 264 | else: 265 | notification.status = notification.DELIVERY_FAILURE 266 | notification.save() 267 | return "Your bird got really dumb and keeps getting lost. And it ate your message." 268 | 269 | 270 | Option 3: Be a cool kid superstar. 271 | ---------------------------------- 272 | Write your own custom stuff and submit a PR to share with others. -------------------------------------------------------------------------------- /docs/images/create_email_target_user_records/create_email_target_user_records_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eikonomega/django-notification-system/e107ed87f2faedba7780494cfa6dd4a155c2fdb5/docs/images/create_email_target_user_records/create_email_target_user_records_1.png -------------------------------------------------------------------------------- /docs/images/create_email_target_user_records/create_email_target_user_records_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eikonomega/django-notification-system/e107ed87f2faedba7780494cfa6dd4a155c2fdb5/docs/images/create_email_target_user_records/create_email_target_user_records_2.png -------------------------------------------------------------------------------- /docs/images/utility_functions/create_email_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eikonomega/django-notification-system/e107ed87f2faedba7780494cfa6dd4a155c2fdb5/docs/images/utility_functions/create_email_notification.png -------------------------------------------------------------------------------- /docs/images/utility_functions/create_expo_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eikonomega/django-notification-system/e107ed87f2faedba7780494cfa6dd4a155c2fdb5/docs/images/utility_functions/create_expo_notification.png -------------------------------------------------------------------------------- /docs/images/utility_functions/create_twilio_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eikonomega/django-notification-system/e107ed87f2faedba7780494cfa6dd4a155c2fdb5/docs/images/utility_functions/create_twilio_notification.png -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Django Notification System documentation master file, created by 2 | sphinx-quickstart on Mon Nov 30 11:57:15 2020. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Django Notification System 7 | ====================================================== 8 | 9 | .. include:: introduction.rst 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | :caption: Contents: 14 | 15 | installation 16 | models 17 | management_commands 18 | utility_functions 19 | extending 20 | 21 | 22 | 23 | Indices and tables 24 | ================== 25 | 26 | * :ref:`genindex` 27 | * :ref:`modindex` 28 | * :ref:`search` -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ================================= 3 | 4 | Requirements 5 | ---------------------------------- 6 | * Python 3. Yes, we have completely ignored Python 2. Sad face. 7 | * Django 3+ 8 | * A computer... preferrably plugged in. 9 | 10 | Excuse me sir, may I have another? 11 | ---------------------------------- 12 | Only the nerdiest of nerds put Dickens puns in their installation docs. 13 | 14 | `pip install django-notification-system` 15 | 16 | Post-Install Setup 17 | ---------------------------------- 18 | Make the following additions to your Django settings. 19 | 20 | **Django Settings Additions** 21 | .. code-block:: python 22 | 23 | # You will need to add email information as specified here: https://docs.djangoproject.com/en/3.1/topics/email/ 24 | # This can include: 25 | EMAIL_HOST = '' 26 | EMAIL_PORT = '' 27 | EMAIL_HOST_USER = '' 28 | EMAIL_HOST_PASSWORD = '' 29 | # and the EMAIL_USE_TLS and EMAIL_USE_SSL settings control whether a secure connection is used. 30 | 31 | # Add the package to your installed apps. 32 | INSTALLED_APPS = [ 33 | "django_notification_system", 34 | ... 35 | ] 36 | 37 | # Twilio Required settings, if you're not planning on using Twilio 38 | # these can be set to empty strings 39 | NOTIFICATION_SYSTEM_TARGETS={ 40 | # Twilio Required settings, if you're not planning on using Twilio these can be set 41 | # to empty strings 42 | "twilio_sms": { 43 | 'account_sid': '', 44 | 'auth_token': '', 45 | 'sender': '' # This is the phone number associated with the Twilio account 46 | }, 47 | "email": { 48 | 'from_email': '' # Sending email address 49 | } 50 | } 51 | 52 | 53 | If you would like to add support for addition types of notifications that don't exist in the package yet, 54 | you'll need to add some additional items to your Django settings. This is only necessary if you are planning on 55 | :doc:`extending the system <../extending>`. 56 | 57 | 58 | -------------------------------------------------------------------------------- /docs/introduction.rst: -------------------------------------------------------------------------------- 1 | Perhaps you've got a Django application that you'd like to send notifications from? 2 | 3 | Well, we certainly have our share of them. And guess what? We're tired of writing code to create and send various 4 | types of messages over and over again! 5 | 6 | So, we've created this package to simplify things 7 | a bit for future projects. Hopefully, it will help you too. 8 | 9 | **Here's the stuff you get:** 10 | 11 | 1. A few Django :doc:`models<../models>` that are pretty important: 12 | 13 | * `Notification`: A single notification. Flexible enough to handle many different types of notifications. 14 | * `NotificationTarget`: A target for notifications. Email, SMS, etc. 15 | * `TargetUserRecord`: Info about the user in a given target (Ex. Your "address" in the "email" target). 16 | * `NotificationOptOut`: Single location to keep track of user opt outs. You don't want the spam police after you. 17 | 18 | 2. Built in support for :doc:`email, Twilio SMS, and Expo push notifications. <../utility_functions>`. 19 | 3. Some cool management commands that: 20 | 21 | * Process all pending notifications. 22 | * Create `UserInNotificationTarget` objects for the email target for all the current users in your database. Just in case you are adding this to an older project. 23 | 24 | 4. A straightforward and fairly easy way to for you to add support for addition notification types while tying into the existing functionality. No whining about it not being super easy! This is still a work in progress. :) 25 | 26 | 27 | *Brought to you by the cool kids (er, kids that wanted to be cool) in the Center for Research Computing at Notre Dame.* -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/management_commands.rst: -------------------------------------------------------------------------------- 1 | Management Commands 2 | ============================================== 3 | 4 | Alright friends, in additional to all the goodies we've already 5 | talked about, we've got a couple of management commands to make 6 | your life easier. Like, a lot easier. 7 | 8 | Process Notifications 9 | --------------------- 10 | This is the big kahuna of the entire system. When run, this command 11 | will attempt to deliver all notifications with a status of `SCHEDULED` 12 | or `RETRY` whose ``scheduled_delivery`` attribute is anytime before the 13 | command was invoked. 14 | 15 | How to Run it 16 | +++++++++++++ 17 | .. parsed-literal:: 18 | $ python manage.py process_notifications 19 | 20 | Make Life Easy for Yourself 21 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 22 | Once you've ironed out any potential kinks in your system, 23 | consider setting up a CRON schedule for this command that runs 24 | at an appropriate interval for your application. After that, 25 | your notifications will fly off your database shelves to your 26 | users without any further work on your end. 27 | 28 | Important: If You Have Custom Notification Targets 29 | ++++++++++++++++++++++++++++++++++++++++++++++++++ 30 | If you have created custom notification targets, you MUST have 31 | created the appropriate handler modules. You can find about how 32 | to do this :doc:`here. <../extending>` 33 | 34 | If this isn't done, no notifications for custom targets will be sent. 35 | 36 | Example Usage 37 | +++++++++++++ 38 | 39 | Creating Notifications 40 | .. code-block:: python 41 | 42 | # First, we'll need to have some Notifications in our database 43 | # in order for this command to send anything. 44 | from django.contrib.auth import get_user_model 45 | from django.utils import timezone 46 | 47 | from django_notification_system.models import ( 48 | TargetUserRecord, Notification) 49 | 50 | User = get_user_model() 51 | 52 | user = User.objects.get(first_name="Eggs", last_name="Benedict") 53 | 54 | # Let's assume this user has 3 TargetUserRecord objects, 55 | # one for Expo, one for Twilio and one for Email. 56 | user_targets = TargetUserRecord.objects.filter( 57 | user=user) 58 | 59 | # We'll loop through these targets and create a basic notification 60 | # instance for each one. 61 | for user_target in user_targets: 62 | Notification.objects.create( 63 | user_target=user_target, 64 | title=f"Test notification for {user.first_name} {user.last_name}", 65 | body="lorem ipsum...", 66 | status="SCHEDULED, 67 | scheduled_delivery=timezone.now() 68 | ) 69 | 70 | Now we have three Notifications ready to send. Let's run the command. 71 | 72 | .. parsed-literal:: 73 | $ python manage.py process_notifications 74 | 75 | 76 | If all was successful, you will see the output below. What this means 77 | is that all Notifications (1) were sent and (2) have been updated 78 | to have a ``status`` of 'DELIVERED' and an ``attempted_delivery`` set 79 | to the time it was sent. 80 | 81 | .. parsed-literal:: 82 | egg - 2020-12-06 19:57:38+00:00 - SCHEDULED 83 | Test notification for Eggs Benedict - lorem ipsum... 84 | SMS Successfully sent! 85 | ********************************* 86 | egg - 2020-12-06 19:57:38+00:00 - SCHEDULED 87 | Test notification for Eggs Benedict - lorem ipsum... 88 | Email Successfully Sent 89 | ********************************* 90 | egg - 2020-12-06 19:57:38+00:00 - SCHEDULED 91 | Test notification for Eggs Benedict - lorem ipsum... 92 | Notification Successfully Pushed! 93 | ********************************* 94 | 95 | If any error occurs, that will be captured in the output. 96 | Based on the ``retry`` attribute, the affected notification(s) 97 | will try sending the next time the command is invoked. 98 | 99 | 100 | Create Email Target User Records 101 | -------------------------------- 102 | The purpose of this command is to create an email target user record for each user 103 | currently in your database or update them if they already exist. We do this by 104 | inspecting the ``email`` attribute of the user object and creating/updating the 105 | corresponding notification system models as needed. 106 | 107 | After initial installation of this package, we can see that the ``User Targets`` 108 | section of our admin panel is empty. 109 | 110 | .. figure:: images/create_email_target_user_records/create_email_target_user_records_1.png 111 | :align: center 112 | :scale: 25% 113 | 114 | Oh no! 115 | 116 | FEAR NOT! In your terminal, run the command: 117 | 118 | .. parsed-literal:: 119 | $ python manage.py create_email_target_user_records 120 | 121 | After the command has been run, navigate to ``http://yoursite/admin/django_notification_system/targetuserrecord/``. 122 | You should see a newly created UserInNotificationTarget for each user currently 123 | in the DB. 124 | 125 | .. figure:: images/create_email_target_user_records/create_email_target_user_records_2.png 126 | :align: center 127 | :scale: 25% 128 | 129 | These user targets are now available for all of your notification needs. 130 | 131 | 132 | -------------------------------------------------------------------------------- /docs/models.rst: -------------------------------------------------------------------------------- 1 | Package Models 2 | ================================= 3 | There are 4 models that the library will install in your application. 4 | 5 | Notification Target 6 | ------------------- 7 | A notification target represents something that can receive a notication from our system. 8 | In this release of the package, we natively support Email, Twilio and Expo (push notifications) targets. 9 | 10 | Unless you are :doc:`extending the system <../extending>` you won't need to create any targets 11 | that are not already pre-loaded during installation. 12 | 13 | Attributes 14 | ++++++++++ 15 | ======================== ======== =============================================================== 16 | **Key** **Type** **Description** 17 | id uuid Auto-generated record UUID. 18 | name str The human friendly name for the target. 19 | notification_module_name str The name of the module in the NOTIFICATION_SYSTEM_CREATORS & 20 | NOTIFICATION_SYSTEM_HANDLERS directories which will be used to 21 | create and process notifications for this target. 22 | ======================== ======== =============================================================== 23 | 24 | 25 | Target User Record 26 | ------------------ 27 | Each notification target will have an internal record for each of your users. For example, 28 | an email server would have a record of all the valid email addresses that it supports. This 29 | model is used to tie a Django user in your database to it's representation in a given 30 | `NotificationTarget`. 31 | 32 | For example, for the built-in email target, we need to store the user's email address on 33 | a `TargetUserRecord` instance so that when we can the email `NotificationTarget` the correct 34 | address to send email notifications to for a given user. 35 | 36 | Attributes 37 | ++++++++++ 38 | ============== =========== ================================================================================================================ 39 | **Key** **Type** **Description** 40 | id uuid Auto-generated record UUID. 41 | user Django User The Django user instance associated with this record. 42 | target foreign key The associated notification target instance. 43 | target_user_id str The ID used in the target to uniquely identify the user. 44 | description str A human friendly note about the user target. 45 | active boolean Indicator of whether user target is active or not. For example, 46 | we may have an outdated email record for a user. 47 | ============== =========== ================================================================================================================ 48 | 49 | **Example: Creating a Target User Record** 50 | .. code-block:: python 51 | 52 | from django.contrib.auth import get_user_model 53 | from django_notification_system.models import ( 54 | NotificationTarget, TargetUserRecord) 55 | 56 | # Let's assume for our example here that your user model has a `phone_number` attribute. 57 | User = get_user_model() 58 | 59 | user = User.objects.get(first_name="Eggs", last_name="Benedict") 60 | target = NotificationTarget.objects.get(name='Twilio') 61 | 62 | # Create a target user record. 63 | target_user_record = TargetUserRecord.objects.create( 64 | user=user, 65 | target=target, 66 | target_user_id=user.phone_number, 67 | description=f"{user.first_name} {user.last_name}'s Twilio", 68 | active=True 69 | ) 70 | 71 | 72 | Notification Opt Out 73 | -------------------- 74 | Use this model to track whether or not users have opted-out of receiving 75 | notifications from you. 76 | 77 | * For the built in `Process Notifications` command, we ensure that 78 | notifications are not sent to users with active opt-outs. 79 | * Make sure to check this yourself if you implement other ways of 80 | sending notifications or you may find yourself running afoul 81 | of spam rules. 82 | 83 | Attributes 84 | ++++++++++ 85 | ======= =========== ========================================================== 86 | **Key** **Type** **Description** 87 | user Django User The Django user associated with this record. 88 | active boolean Indicator for whether the opt out is active or not. 89 | ======= =========== ========================================================== 90 | 91 | **Example: Creating an Opt out** 92 | .. code-block:: python 93 | 94 | from django.contrib.auth import get_user_model 95 | from django_notification_system.models import NotificationOptOut 96 | 97 | User = get_user_model() 98 | user = User.objects.get(first_name="Eggs", last_name="Benedict") 99 | 100 | opt_out = NotificationOptOut.objects.create( 101 | user=user, 102 | active=True) 103 | 104 | Unique Behavior 105 | +++++++++++++++ 106 | When an instance of this model is saved, if the opt out is `active` 107 | existing notifications with a current status of SCHEDULED or RETRY 108 | will be changed to OPTED_OUT. 109 | 110 | We do this to help prevent them from being sent, but also to keep 111 | a record of what notifications had been scheduled before the user 112 | opted-out. 113 | 114 | Notification 115 | ------------ 116 | This model represents a notification in the database. SHOCKING! 117 | 118 | Thus far, we've found this model to be flexible enough to handle 119 | any type of notification. Hopefully, you will find the same. 120 | 121 | Core Concept 122 | ++++++++++++ 123 | 124 | Each type of notification target must have a corresponding handler module that 125 | will process notifications that belong to that target. These handlers interpret 126 | the various attributes of a `Notification` instance to construct a valid 127 | message for each target. 128 | 129 | For each of the built-in targets, we have already written these handlers. 130 | If you create additional targets, you'll need to write the corresponding handlers. 131 | See the :doc:`extending the system <../extending>` page for more information. 132 | 133 | Attributes 134 | ++++++++++ 135 | =================== ======================== ================================================================================================================= 136 | **Key** **Type** **Description** 137 | target_user_record TargetUserRecord The TargetUserRecord associated with notification. This essentially 138 | identifies the both the target (i.e. email) and the specific user in that 139 | target (coolkid@nd.edu) that will receive the notification. 140 | title str The title for the notification. 141 | body str The main message of the notification to be sent. 142 | extra dict A dictionary of extra data to be sent to the notification handler. 143 | Valid keys are determined by each handler. 144 | status str The status of Notification. Options are: 'SCHEDULED', 'DELIVERED', 145 | 'DELIVERY FAILURE', 'RETRY', 'INACTIVE DEVICE', 'OPTED OUT' 146 | scheduled_delivery DateTime Scheduled delivery date/time. 147 | attempted_delivery DateTime Last attempted delivery date/time. 148 | retry_time_interval PositiveInt If a notification delivery fails, this is the amount of time 149 | to wait until retrying to send it. 150 | retry_attempts PositiveInt The number of delivery retries that have been attempted. 151 | max_retries PositiveInt The maximun number of allowed delivery attempts. 152 | =================== ======================== ================================================================================================================= 153 | 154 | **Example: Creating an Email Notification** 155 | .. code-block:: python 156 | 157 | from django.contrib.auth import get_user_model 158 | from django.utils import timezone 159 | 160 | from django_notification_system.models import UserInNotificationTarget, Notification 161 | 162 | # Get the user. 163 | User = get_user_model() 164 | user = User.objects.get(first_name="Eggs", last_name="Benedict") 165 | 166 | # The the user's target record for the email target. 167 | emailUserRecord = TargetUserRecord.objects.get( 168 | user=User, 169 | target__name='Email') 170 | 171 | # Create the notification instance. 172 | # IMPORTANT: This does NOT send the notification, just schedules it. 173 | # See the docs on management commands for sending notifications. 174 | notification = Notification.objects.create( 175 | user_target=user_target, 176 | title=f"Good morning, {user.first_name}", 177 | body="lorem ipsum...", 178 | status="SCHEDULED", 179 | scheduled_delivery=timezone.now() 180 | ) 181 | 182 | Unique Behavior 183 | +++++++++++++++ 184 | 185 | We perform a few data checks whenever an notification instance is saved. 186 | 187 | 1. You cannot set the status of notification to 'SCHEDULED' if you 188 | also have an existing attempted delivery date. 189 | 2. If a notification has a status other than 'SCHEDULED' or 'OPTED OUT it MUST 190 | have an attempted delivery date. 191 | 3. Don't allow notifications to be saved if the user has opted out. -------------------------------------------------------------------------------- /docs/utility_functions.rst: -------------------------------------------------------------------------------- 1 | Built-In Notification Creators & Handlers 2 | ============================================ 3 | 4 | What allows for a given notification type to be supported is the existence of a 5 | **notification creator** and **notification handler** functions. Their jobs are to: 6 | 7 | 1. Create a ``Notification`` record for a given notification target. 8 | 2. Interpret a ``Notification`` record in an appropriate way for a given target and actually send the notification. 9 | 10 | Currently there are 3 different types of notifications with built-in support: 11 | 12 | * Email 13 | * Twilio SMS 14 | * Expo Push 15 | 16 | Natively Supported Notification Targets 17 | --------------------------------------- 18 | 19 | Email Notifications 20 | +++++++++++++++++++ 21 | 22 | NOTE: To send emails, you will need to have the appropriate variables in your settings file. More information can be found |email_link|. 23 | We also have examples :doc:`here. <../installation>` 24 | 25 | .. |email_link| raw:: html 26 | 27 | here 28 | 29 | Notification Creator 30 | #################### 31 | 32 | **Example: Email Notification Creator** 33 | .. code-block:: python 34 | 35 | from django.contrib.auth import get_user_model 36 | 37 | from django_notification_system.notification_creators.email import create_notification 38 | 39 | User = get_user_model() 40 | 41 | user = User.objects.get(first_name="Eggs", last_name="Benedict") 42 | 43 | # Note how the extra parameter is used here. 44 | # See function parameters below for more details. 45 | create_notification( 46 | user=user, 47 | title='Cool Email', 48 | extra={ 49 | "user": user, 50 | "date": "12-07-2020" 51 | "template_name": "templates/eggs_email.html" 52 | }) 53 | 54 | **Function Parameters** 55 | =================== ================== ========================================================= 56 | **Key** **Type** **Description** 57 | user Django User The user to whom the notification will be sent. 58 | title str The title for the notification. 59 | body str Body of the email. Defaults to a blank string if 60 | not given. Additionally, if this parameter is not 61 | specific AND "template_name" is present in `extra`, 62 | an attempt will be made to generate the body from 63 | that template. 64 | 65 | scheduled_delivery datetime(optional) When to delivery the notification. Defaults to 66 | immediately. 67 | 68 | retry_time_interval int(optional) When to retry sending the notification if a delivery 69 | failure occurs. Defaults to 1440 seconds. 70 | 71 | max_retries int(optional) Maximum number of retry attempts. 72 | Defaults to 3. 73 | 74 | quiet bool(optional) Suppress exceptions from being raised. 75 | Defaults to False. 76 | 77 | extra dict(optional) User specified additional data that will be used 78 | to populate an HTML template if 79 | "template_name" is present inside. 80 | =================== ================== ========================================================= 81 | 82 | The above example will create a Notification with the following values: 83 | 84 | .. figure:: images/utility_functions/create_email_notification.png 85 | :align: center 86 | :scale: 25% 87 | 88 | Notification Handler 89 | #################### 90 | 91 | **Example Usage** 92 | .. code-block:: python 93 | 94 | from django.utils import timezone 95 | 96 | from django_notification_system.models import Notification 97 | from django_notification_system.notification_handlers.email import send_notification 98 | 99 | # Get all email notifications. 100 | notifications_to_send = Notification.objects.filter( 101 | target_user_record__target__name='Email', 102 | status='SCHEDULED', 103 | scheduled_delivery__lte=timezone.now()) 104 | 105 | # Send each email notification to the handler. 106 | for notification in notifications_to_send: 107 | send_notification(notification) 108 | 109 | Expo Push Notifications 110 | +++++++++++++++++++++++ 111 | 112 | Notification Creator 113 | #################### 114 | 115 | **Example: Expo Notification Creator** 116 | .. code-block:: python 117 | 118 | from django.contrib.auth import get_user_model 119 | 120 | from django_notification_system.notification_creators.expo import create_notification 121 | 122 | User = get_user_model() 123 | 124 | user = User.objects.get(first_name="Eggs", last_name="Benedict") 125 | 126 | create_notification( 127 | user=user, 128 | title=f"Hello {user.first_name}", 129 | body="Test push notification") 130 | 131 | **Parameters** 132 | =================== ================== ========================================================= 133 | **Key** **Type** **Description** 134 | user Django User The user to whom the notification will be sent. 135 | title str The title for the push notification. 136 | body str The body of the push notification. 137 | scheduled_delivery datetime(optional) When to delivery the notification. Defaults to 138 | immediately. 139 | 140 | retry_time_interval int(optional) Delay between send attempts. Defaults to 60 seconds. 141 | max_retries int(optional) Maximum number of retry attempts. 142 | Defaults to 3. 143 | 144 | quiet bool(optional) Suppress exceptions from being raised. 145 | Defaults to False. 146 | 147 | extra dict(optional) Defaults to None. 148 | =================== ================== ========================================================= 149 | 150 | The above example will create a Notification with the following values: 151 | 152 | .. figure:: images/utility_functions/create_expo_notification.png 153 | :align: center 154 | :scale: 25% 155 | 156 | Notification Handler 157 | #################### 158 | 159 | **Example Usage** 160 | .. code-block:: python 161 | 162 | from django.utils import timezone 163 | 164 | from django_notification_system.models import Notification 165 | from django_notification_system.notification_handlers.expo import send_notification 166 | 167 | # Get all Expo notifications. 168 | notifications_to_send = Notification.objects.filter( 169 | target_user_record__target__name='Expo', 170 | status='SCHEDULED', 171 | scheduled_delivery__lte=timezone.now()) 172 | 173 | # Send each Expo notification to the handler. 174 | for notification in notifications_to_send: 175 | send_notification(notification) 176 | 177 | Twilio SMS 178 | ++++++++++ 179 | 180 | NOTE: All Twilio phone numbers must contain a + and the country code. Therefore, all Twilio UserTargetRecords 181 | target_user_id should be `+{country_code}7891234567'. The sender number stored in the settings file should 182 | also follow this format. 183 | 184 | Notification Creator 185 | #################### 186 | 187 | **Example: Twilio SMS Notification Creator** 188 | .. code-block:: python 189 | 190 | from django.contrib.auth import get_user_model 191 | 192 | from django_notification_system.notification_creators.twilio import create_notification 193 | 194 | User = get_user_model() 195 | 196 | user = User.objects.get(first_name="Eggs", last_name="Benedict") 197 | 198 | create_notification( 199 | user=user, 200 | title=f"Hello {user.first_name}", 201 | body="Test sms notification") 202 | 203 | **Parameters** 204 | =================== ================== ========================================================= 205 | **Key** **Type** **Description** 206 | user Django User The user to whom the notification will be sent. 207 | title str The title for the sms notification. 208 | body str The body of the sms notification. 209 | scheduled_delivery datetime(optional) When to deliver the notification. Defaults to 210 | immediately. 211 | 212 | retry_time_interval int(optional) Delay between send attempts. Defaults to 60 seconds. 213 | max_retries int(optional) Maximum number of retry attempts. 214 | Defaults to 3. 215 | 216 | quiet bool(optional) Suppress exceptions from being raised. 217 | Defaults to False. 218 | 219 | extra dict(optional) Defaults to None. 220 | =================== ================== ========================================================= 221 | 222 | 223 | 224 | The above example will create a Notification with the following values: 225 | 226 | .. figure:: images/utility_functions/create_twilio_notification.png 227 | :align: center 228 | :scale: 25% 229 | 230 | Notification Handler 231 | #################### 232 | 233 | **Example Usage** 234 | .. code-block:: python 235 | 236 | from django.utils import timezone 237 | 238 | from django_notification_system.models import Notification 239 | from django_notification_system.notification_handlers.twilio import send_notification 240 | 241 | # Get all notifications for Twilio target. 242 | notifications_to_send = Notification.objects.filter( 243 | target_user_record__target__name='Twilio', 244 | status='SCHEDULED', 245 | scheduled_delivery__lte=timezone.now()) 246 | 247 | # Send each notification to the Twilio handler. 248 | for notification in notifications_to_send: 249 | send_notification(notification) -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_project.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=3.1.3 2 | 3 | html2text==2018.1.9 4 | twilio==6.29.1 5 | exponent_server_sdk<1.0.0 # A module used to send push notifications to Exponent Experiences 6 | 7 | Sphinx==2.2.0 # Documentation Tool 8 | sphinx-rtd-theme==0.4.3 # Sphinx Theme 9 | sphinxcontrib-httpdomain==1.7.0 # API plugin for Readthedocs 10 | 11 | python-dateutil==2.8.1 -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django-notification-system 3 | version = 1.5.3 4 | description = A Django app that allows developers to send notifications of various types to users. 5 | description-file = README.md 6 | long_description_content_type = text/markdown 7 | url = https://github.com/crcresearch/django-notification-system 8 | author = Justin Branco, Mike Dunn 9 | author_email = jbranco@nd.edu 10 | license = MIT 11 | classifiers = 12 | Environment :: Web Environment 13 | Framework :: Django 14 | Framework :: Django :: 3.1 15 | Intended Audience :: Developers 16 | License :: OSI Approved :: BSD License 17 | Operating System :: OS Independent 18 | Programming Language :: Python 19 | Programming Language :: Python :: 3 20 | Programming Language :: Python :: 3 :: Only 21 | Programming Language :: Python :: 3.6 22 | Programming Language :: Python :: 3.7 23 | Programming Language :: Python :: 3.8 24 | Topic :: Internet :: WWW/HTTP 25 | Topic :: Internet :: WWW/HTTP :: Dynamic Content 26 | 27 | [options] 28 | include_package_data = true 29 | packages = find: 30 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | def read(f): 4 | return open(f, encoding='utf-8').read() 5 | 6 | setup( 7 | # This is the name of your project. The first time you publish this 8 | # package, this name will be registered for you. It will determine how 9 | # users can install this project, e.g.: 10 | # 11 | # $ pip install sampleproject 12 | # 13 | # And where it will live on PyPI: https://pypi.org/project/sampleproject/ 14 | # 15 | # There are some restrictions on what makes a valid project name 16 | # specification here: 17 | # https://packaging.python.org/specifications/core-metadata/#name 18 | name='django-notification-system', # Required 19 | 20 | # Versions should comply with PEP 440: 21 | # https://www.python.org/dev/peps/pep-0440/ 22 | # 23 | # For a discussion on single-sourcing the version across setup.py and the 24 | # project code, see 25 | # https://packaging.python.org/en/latest/single_source_version.html 26 | version='1.5.3', # Required 27 | 28 | # This is a one-line description or tagline of what your project does. This 29 | # corresponds to the "Summary" metadata field: 30 | # https://packaging.python.org/specifications/core-metadata/#summary 31 | description='Notification functionality to use within Django', # Required 32 | 33 | long_description=read('README.md'), 34 | long_description_content_type='text/markdown', 35 | 36 | # This should be a valid link to your project's main homepage. 37 | # 38 | # This field corresponds to the "Home-Page" metadata field: 39 | # https://packaging.python.org/specifications/core-metadata/#home-page-optional 40 | url='https://github.com/crcresearch/django-notification-system', # Optional 41 | 42 | # You can just specify package directories manually here if your project is 43 | # simple. Or you can use find_packages(). 44 | # 45 | # Alternatively, if you just want to distribute a single Python file, use 46 | # the `py_modules` argument instead as follows, which will expect a file 47 | # called `my_module.py` to exist: 48 | # 49 | # py_modules=["my_module"], 50 | # 51 | packages=find_packages(include=['django_notification_system', 'django_notification_system.*']), # Required 52 | 53 | # This field lists other packages that your project depends on to run. 54 | # Any package you put here will be installed by pip when your project is 55 | # installed, so they must be valid existing projects. 56 | # 57 | # For an analysis of "install_requires" vs pip's requirements files see: 58 | # https://packaging.python.org/en/latest/requirements.html 59 | install_requires=[ 60 | 'Django>=3.1.3', 61 | 'html2text>=2018.1.9', 62 | 'twilio>=6.29.1', 63 | 'exponent_server_sdk<1.0.0', 64 | 'python-dateutil>=2.8.1'] 65 | ) 66 | -------------------------------------------------------------------------------- /test_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eikonomega/django-notification-system/e107ed87f2faedba7780494cfa6dd4a155c2fdb5/test_project/__init__.py -------------------------------------------------------------------------------- /test_project/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for test_project project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_project.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /test_project/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for test_project project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.1.3. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.1/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "-fe@r!yo*h)jy2*jd^w7kve8633gcskcv$a)kuu6hm8eoup(!&" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | "django.contrib.admin", 35 | "django.contrib.auth", 36 | "django.contrib.contenttypes", 37 | "django.contrib.sessions", 38 | "django.contrib.messages", 39 | "django.contrib.staticfiles", 40 | "django_notification_system", 41 | ] 42 | 43 | MIDDLEWARE = [ 44 | "django.middleware.security.SecurityMiddleware", 45 | "django.contrib.sessions.middleware.SessionMiddleware", 46 | "django.middleware.common.CommonMiddleware", 47 | "django.middleware.csrf.CsrfViewMiddleware", 48 | "django.contrib.auth.middleware.AuthenticationMiddleware", 49 | "django.contrib.messages.middleware.MessageMiddleware", 50 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 51 | ] 52 | 53 | ROOT_URLCONF = "test_project.urls" 54 | 55 | TEMPLATES = [ 56 | { 57 | "BACKEND": "django.template.backends.django.DjangoTemplates", 58 | "DIRS": [], 59 | "APP_DIRS": True, 60 | "OPTIONS": { 61 | "context_processors": [ 62 | "django.template.context_processors.debug", 63 | "django.template.context_processors.request", 64 | "django.contrib.auth.context_processors.auth", 65 | "django.contrib.messages.context_processors.messages", 66 | ], 67 | }, 68 | }, 69 | ] 70 | 71 | WSGI_APPLICATION = "test_project.wsgi.application" 72 | 73 | 74 | # Database 75 | # https://docs.djangoproject.com/en/3.1/ref/settings/#databases 76 | 77 | DATABASES = { 78 | "default": { 79 | "ENGINE": "django.db.backends.sqlite3", 80 | "NAME": BASE_DIR / "db.sqlite3", 81 | } 82 | } 83 | 84 | 85 | # Password validation 86 | # https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators 87 | 88 | AUTH_PASSWORD_VALIDATORS = [ 89 | { 90 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 91 | }, 92 | { 93 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 94 | }, 95 | { 96 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 97 | }, 98 | { 99 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 100 | }, 101 | ] 102 | 103 | 104 | # Internationalization 105 | # https://docs.djangoproject.com/en/3.1/topics/i18n/ 106 | 107 | LANGUAGE_CODE = "en-us" 108 | 109 | TIME_ZONE = "UTC" 110 | 111 | USE_I18N = True 112 | 113 | USE_L10N = True 114 | 115 | USE_TZ = True 116 | 117 | 118 | # Static files (CSS, JavaScript, Images) 119 | # https://docs.djangoproject.com/en/3.1/howto/static-files/ 120 | 121 | STATIC_URL = "/static/" 122 | 123 | NOTIFICATION_SYSTEM_HANDLERS = [] 124 | NOTIFICATION_SYSTEM_CREATORS = [] 125 | NOTIFICATION_SYSTEM_TARGETS = { 126 | "twilio_sms": { 127 | 'account_sid': '', 128 | 'auth_token': '', 129 | 'sender': '' # This is the phone number associated with the Twilio account 130 | }, 131 | "email": { 132 | 'from_email': '' # Sending email address 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /test_project/urls.py: -------------------------------------------------------------------------------- 1 | """test_project URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path 18 | 19 | urlpatterns = [ 20 | path('admin/', admin.site.urls), 21 | ] 22 | -------------------------------------------------------------------------------- /test_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for test_project project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_project.settings') 15 | 16 | application = get_wsgi_application() 17 | --------------------------------------------------------------------------------