├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs └── source │ ├── conf.py │ ├── index.rst │ ├── installation.rst │ ├── settings.rst │ ├── templates.rst │ └── usage.rst ├── magic_notifier ├── __init__.py ├── admin.py ├── apps.py ├── consumers.py ├── email_clients │ ├── __init__.py │ ├── amazon_ses.py │ └── django_email.py ├── emailer.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── connect_telegram.py │ │ └── test_email_template.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── notifier.py ├── push_clients │ ├── __init__.py │ ├── expo.py │ └── fcm.py ├── pusher.py ├── serializers.py ├── settings.py ├── sms_clients │ ├── __init__.py │ ├── base.py │ ├── cgsms_client.py │ ├── nexa_client.py │ └── twilio_client.py ├── smser.py ├── tasks.py ├── telegram_clients │ ├── __init__.py │ └── telethon.py ├── telegramer.py ├── templates │ └── base_notifier │ │ ├── email.html │ │ ├── email.txt │ │ ├── push.json │ │ └── sms.txt ├── utils.py ├── views.py ├── whatsapp_clients │ ├── __init__.py │ └── waha_client.py └── whatsapper.py ├── requirements ├── base.txt ├── deploy.txt ├── docs.txt └── test.txt ├── setup.py └── tests ├── connect.py ├── core ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ └── __init__.py ├── models.py ├── templates │ └── notifier │ │ ├── base │ │ ├── email.html │ │ ├── email.mjml │ │ ├── email.txt │ │ ├── push.json │ │ └── sms.txt │ │ ├── hello │ │ └── email.txt │ │ └── testfcm │ │ └── push.json ├── tests.py ├── utils.py └── views.py ├── example ├── __init__.py ├── asgi.py ├── settings.py ├── urls.py └── wsgi.py └── manage.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.wpr 3 | *.wpu 4 | .idea/ 5 | .env/ 6 | django_magic_notifier.egg-info/ 7 | real_settings.py 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | cache: pip 3 | python: 4 | - "3.6" 5 | - "3.7" 6 | - "3.8" 7 | - "3.9" 8 | env: 9 | - DJANGO="Django==2.2.*" 10 | - DJANGO="Django==3.0.*" 11 | - DJANGO="Django==3.1.*" 12 | - DJANGO="Django==3.2.*" 13 | install: 14 | - pip install -q $DJANGO 15 | - pip install -r requirements/base.txt 16 | - pip install -r requirements/test.txt 17 | - pip install coveralls 18 | script: 19 | - PYTHONPATH=".:tests:$PYTHONPATH" tests/manage.py makemigrations --noinput 20 | - PYTHONPATH=".:tests:$PYTHONPATH" tests/manage.py migrate --noinput 21 | - PYTHONPATH=".:tests:$PYTHONPATH" python -Wall -m coverage run --omit='setup.py' --source=. tests/manage.py test core --settings= 22 | # - if python -c 'import sys; sys.exit(1 - (sys.version_info >= (3, 6)))'; then isort --check-only magic_notifier tests; fi 23 | after_success: 24 | - coveralls 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 jefcolbi 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 | graft magic_notifier 2 | global-exclude *.py[cod] 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Magic Notifier 2 | 3 | [![Travis CI](https://api.travis-ci.com/jefcolbi/django-magic-notifier.svg?branch=main)](https://travis-ci.com/github/jefcolbi/django-magic-notifier) [![Coverage](https://coveralls.io/repos/github/jefcolbi/django-magic-notifier/badge.svg?branch=main)](https://coveralls.io/github/jefcolbi/django-magic-notifier?branch=main) [![PyPI Version](https://img.shields.io/pypi/v/django-magic-notifier.svg)](https://pypi.org/project/django-magic-notifier/) [![Documentation](http://readthedocs.org/projects/django-magic-notifier/badge/?version=stable)](https://django-magic-notifier.readthedocs.io/en/stable/) ![Python Versions](https://img.shields.io/pypi/pyversions/django-magic-notifier) ![Django Versions](https://img.shields.io/pypi/djversions/django-magic-notifier) 4 | 5 | [![Published on Django Packages](https://img.shields.io/badge/Published%20on-Django%20Packages-0c3c26)](https://djangopackages.org/packages/p/django-magic-notifier/) 6 | 7 | --- 8 | 9 | ### Why Choose Django Magic Notifier? 10 | 11 | Managing notifications in Django applications can often be a complex and cumbersome task. **Django Magic Notifier (DMN)** simplifies this by consolidating all your notification needs into a single, powerful API: `notify()`. Whether you need to send emails, SMS, WhatsApp messages, Telegram notifications, or push notifications, DMN handles it all seamlessly. 12 | 13 | --- 14 | 15 | ## Features 16 | 17 | - **Unified Notification API**: One function `notify()` to handle all notification types. 18 | - **Multi-Channel Support**: Email, SMS, WhatsApp, Telegram, and push notifications. 19 | - **Gateway Flexibility**: Configure multiple gateways per channel with ease. 20 | - **Template-Based Messages**: Use templates for consistent and professional notifications. 21 | - **File Attachments**: Include files in your notifications. 22 | - **Asynchronous Notifications**: Threaded support for background processing. 23 | - **Extensibility**: Add custom gateways or notification types. 24 | - **MJML Support**: Use modern, responsive email designs. 25 | 26 | --- 27 | 28 | ## Installation 29 | 30 | Install Django Magic Notifier using pip: 31 | 32 | ```bash 33 | pip install --upgrade django-magic-notifier 34 | ``` 35 | 36 | --- 37 | 38 | ## Configuration 39 | 40 | Add the `NOTIFIER` configuration to your Django `settings.py` file. Below is an example of a complete configuration: 41 | 42 | ```python 43 | NOTIFIER = { 44 | "EMAIL": { 45 | "default": { 46 | "HOST": "localhost", 47 | "PORT": 587, 48 | "USE_TLS": True, 49 | "USE_SSL": False, 50 | "USER": "root@localhost", 51 | "FROM": "Root ", 52 | "PASSWORD": "password", 53 | "CLIENT": "magic_notifier.email_clients.django_email.DjangoEmailClient", 54 | }, 55 | "ses": { 56 | "CLIENT": "magic_notifier.email_clients.amazon_ses.AmazonSesClient", 57 | "AWS_ACCESS_KEY": "****************", 58 | "AWS_SECRET_KEY": "****************************************", 59 | "AWS_REGION_NAME": "eu-north-1", 60 | "AWS_REGION_ENDPOINT": "https://email.eu-north-1.amazonaws.com", 61 | "FROM": "Service ", 62 | }, 63 | "DEFAULT_GATEWAY": "ses", # use amazon ses as the default gateway 64 | "FALLBACKS": ["default"] # if the default gateway fails, fallback to these gateways 65 | }, 66 | "WHATSAPP": { 67 | "DEFAULT_GATEWAY": "waha", 68 | "GATEWAYS": { 69 | "waha": { 70 | "BASE_URL": "http://localhost:3000" 71 | } 72 | } 73 | }, 74 | "TELEGRAM": { 75 | "DEFAULT_GATEWAY": "default", 76 | "GATEWAYS": { 77 | "default": { 78 | "API_ID": "xxxxxx", 79 | "API_HASH": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 80 | } 81 | } 82 | }, 83 | "SMS": { 84 | "DEFAULT_GATEWAY": "TWILIO", 85 | "GATEWAYS": { 86 | "TWILIO": { 87 | "CLIENT": "magic_notifier.sms_clients.twilio_client.TwilioClient", 88 | "ACCOUNT": "account_sid", 89 | "TOKEN": "auth_token", 90 | "FROM_NUMBER": "+1234567890" 91 | } 92 | } 93 | }, 94 | "USER_FROM_WS_TOKEN_FUNCTION": "magic_notifier.utils.get_user_from_ws_token", 95 | "GET_USER_NUMBER": "magic_notifier.utils.get_user_number", 96 | "THREADED": True 97 | } 98 | ``` 99 | 100 | ### Key Settings 101 | 102 | - **EMAIL**: Configure your email gateway, including host, port, and credentials. 103 | - **WHATSAPP**: Define the WhatsApp gateway with its base URL. 104 | - **TELEGRAM**: Configure Telegram with API credentials. 105 | - **SMS**: Specify SMS gateways such as Twilio, Nexa, or others. 106 | - **THREADED**: Enable background processing for notifications. 107 | 108 | --- 109 | 110 | ## Usage 111 | 112 | ### Sending Notifications 113 | 114 | The `notify()` function is your gateway to all notification types. 115 | 116 | #### Basic Email Notification 117 | ```python 118 | from magic_notifier.notifier import notify 119 | 120 | user = User.objects.get(email="testuser@localhost") 121 | subject = "Welcome!" 122 | notify(["email"], subject, [user], final_message="Welcome to our platform!") 123 | ``` 124 | 125 | #### SMS Notification with Template 126 | ```python 127 | notify(["sms"], "Account Alert", [user], template="account_alert") 128 | ``` 129 | 130 | #### Multi-Channel Notification 131 | ```python 132 | notify(["email", "sms"], "System Update", [user], final_message="The system will be down for maintenance.") 133 | ``` 134 | 135 | #### WhatsApp Notification 136 | ```python 137 | notify(["whatsapp"], "Welcome", [user], final_message="Hello! This is a WhatsApp test message.") 138 | ``` 139 | 140 | #### Telegram Notification 141 | ```python 142 | notify(["telegram"], "Welcome", [user], final_message="Hello! This is a Telegram test message.") 143 | ``` 144 | 145 | ### Passing Context to Templates 146 | You can pass additional context to your templates via the `context` argument, note that the `user` 147 | object is automatically passed: 148 | 149 | ```python 150 | context = { 151 | "discount": 20 152 | } 153 | notify(["email"], "Special Offer", [user], template="special_offer", context=context) 154 | ``` 155 | 156 | In the template: 157 | ```html 158 | {% block content %} 159 |

Hi {{ user.first_name }},

160 |

We are excited to offer you a {{ discount }}% discount on your next purchase!

161 | {% endblock %} 162 | ``` 163 | 164 | ### Overriding Gateways 165 | You can override default gateways directly when sending notifications: 166 | 167 | #### Override Email Gateway 168 | ```python 169 | notify(["email"], "Custom Email Gateway", [user], final_message="This uses a custom email gateway.", email_gateway="custom_gateway") 170 | ``` 171 | 172 | #### Override SMS Gateway 173 | ```python 174 | notify(["sms"], "Custom SMS Gateway", [user], final_message="This uses a custom SMS gateway.", sms_gateway="custom_sms_gateway") 175 | ``` 176 | 177 | #### Override WhatsApp Gateway 178 | ```python 179 | notify(["whatsapp"], "Custom WhatsApp Gateway", [user], final_message="This uses a custom WhatsApp gateway.", whatsapp_gateway="custom_whatsapp_gateway") 180 | ``` 181 | 182 | #### Override Telegram Gateway 183 | ```python 184 | notify(["telegram"], "Custom Telegram Gateway", [user], final_message="This uses a custom Telegram gateway.", telegram_gateway="custom_telegram_gateway") 185 | ``` 186 | 187 | ### Template Creation and Resolution 188 | When using templates, Django Magic Notifier looks for specific files depending on the notification channels. The template value designates a folder, not a file. Files are checked in the following order for each channel: 189 | 190 | - **Email**: `email.mjml` -> `email.html` -> `email.txt` 191 | - **SMS**: `sms.txt` 192 | - **WhatsApp**: `whatsapp.txt` -> `sms.txt` 193 | - **Telegram**: `telegram.txt` -> `sms.txt` 194 | 195 | If a file is not found, the next file in the sequence is checked. If no files are found, an error is raised. 196 | 197 | #### Example 198 | Suppose `notify()` is called as follows: 199 | ```python 200 | notify(["telegram"], "Welcome", [user], template="welcome") 201 | ``` 202 | 203 | The following files will be checked in order: 204 | 1. `notifier/welcome/telegram.txt` 205 | 2. `notifier/welcome/sms.txt` 206 | 207 | Ensure that at least one of these files exists in your templates directory. 208 | 209 | --- 210 | 211 | ## Advanced Features 212 | 213 | ### Sending Files 214 | Attach files to your notifications: 215 | ```python 216 | files = ["/path/to/file.pdf"] 217 | notify(["email"], "Invoice", [user], final_message="Your invoice is attached.", files=files) 218 | ``` 219 | 220 | ### Sending to Specific Receiver Groups 221 | The `notify()` function supports predefined values for the `receivers` argument to target specific user groups: 222 | 223 | #### Sending to Admin Users 224 | ```python 225 | notify(["email"], "Admin Alert", "admins", final_message="This is a message for all admin users.") 226 | ``` 227 | 228 | #### Sending to Staff Users 229 | ```python 230 | notify(["email"], "Staff Notification", "staff", final_message="This message is for all staff members.") 231 | ``` 232 | 233 | #### Sending to All Users 234 | ```python 235 | notify(["email"], "Global Announcement", "all", final_message="This is a message for all users.") 236 | ``` 237 | 238 | #### Sending to Non-Staff Users 239 | ```python 240 | notify(["email"], "Non-Staff Update", "all-staff", final_message="This message is for users who are not staff.") 241 | ``` 242 | 243 | #### Sending to Non-Admin Users 244 | ```python 245 | notify(["email"], "User Alert", "all-admins", final_message="This is a message for all users except admins.") 246 | ``` 247 | 248 | ### Asynchronous Processing 249 | Enable threaded notifications for better performance: 250 | ```python 251 | notify(["sms"], "Alert", [user], final_message="This is a test.", threaded=True) 252 | ``` 253 | 254 | --- 255 | 256 | ## Testing 257 | 258 | DMN includes comprehensive test cases for all features. To run tests: 259 | ```bash 260 | python manage.py test 261 | ``` 262 | 263 | --- 264 | 265 | ## Roadmap 266 | 267 | - Extend support for additional messaging platforms. 268 | 269 | --- 270 | 271 | ## Contributing 272 | 273 | We welcome contributions! To get started, fork the repository, make your changes, and submit a pull request. Refer to our [contributing guidelines](CONTRIBUTING.md) for more details. 274 | 275 | --- 276 | 277 | ## License 278 | 279 | This project is licensed under the MIT License. See the LICENSE file for details. 280 | 281 | --- 282 | 283 | ## Support 284 | 285 | File an issue on [GitHub](https://github.com/jefcolbi/django-magic-notifier/issues). 286 | 287 | -------------------------------------------------------------------------------- /docs/source/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 | from typing import List 16 | 17 | sys.path.insert(0, os.path.abspath("../../magic_notifier")) 18 | #sys.path.insert(1, os.path.abspath("../../src/synkio")) 19 | #os.environ["DJANGO_SETTINGS_MODULE"] = "synkio.dev" 20 | #import django 21 | 22 | #django.setup() 23 | 24 | 25 | # -- Project information ----------------------------------------------------- 26 | 27 | project = 'Django Magic Notifier' 28 | copyright = '2021, MATTON Jef' 29 | author = 'MATTON Jef' 30 | 31 | # The full version, including alpha/beta/rc tags 32 | release = '0.2.3' 33 | 34 | 35 | # -- General configuration --------------------------------------------------- 36 | 37 | # Add any Sphinx extension module names here, as strings. They can be 38 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 39 | # ones. 40 | extensions = [ 41 | "sphinx.ext.autodoc", 42 | "sphinx.ext.autodoc.typehints", 43 | # "sphinx.ext.inheritance_diagram", 44 | "autoapi.extension", 45 | ] 46 | 47 | # Add any paths that contain templates here, relative to this directory. 48 | templates_path = ['_templates'] 49 | 50 | # The language for content autogenerated by Sphinx. Refer to documentation 51 | # for a list of supported languages. 52 | # 53 | # This is also used if you do content translation via gettext catalogs. 54 | # Usually you set "language" from the command line for these cases. 55 | language = 'en' 56 | 57 | # List of patterns, relative to source directory, that match files and 58 | # directories to ignore when looking for source files. 59 | # This pattern also affects html_static_path and html_extra_path. 60 | exclude_patterns = [] 61 | 62 | 63 | # -- Options for HTML output ------------------------------------------------- 64 | 65 | # The theme to use for HTML and HTML Help pages. See the documentation for 66 | # a list of builtin themes. 67 | # 68 | html_theme = 'sphinx_rtd_theme' 69 | 70 | # Add any paths that contain custom static files (such as style sheets) here, 71 | # relative to this directory. They are copied after the builtin static files, 72 | # so a file named "default.css" will overwrite the builtin "default.css". 73 | html_static_path = ['_static'] 74 | 75 | autoapi_dirs = ["../../magic_notifier/"] 76 | autoapi_python_class_content = "both" 77 | suppress_warnings = ["autoapi.python_import_resolution", "autoapi.not_readable"] 78 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. Django Magic Notifier documentation master file, created by 2 | sphinx-quickstart on Mon Sep 20 01:43:15 2021. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Django Magic Notifier's documentation! 7 | ================================================= 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | installation 14 | settings 15 | templates 16 | usage 17 | autoapi/index 18 | 19 | Indices and tables 20 | ================== 21 | 22 | * :ref:`genindex` 23 | * :ref:`modindex` 24 | * :ref:`search` 25 | -------------------------------------------------------------------------------- /docs/source/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ------------ 3 | 4 | You can install **Django Magic Notifier** via various ways 5 | 6 | PIP:: 7 | 8 | > pip install django-magic-notifier 9 | 10 | Git:: 11 | 12 | > git clone https://github.com/jefcolbi/django-magic-notifier 13 | > cd django-magic-notifier 14 | > python setup.py install 15 | 16 | 17 | If you intend to use Push notifications, then you need to include DMN 18 | consumers in your django channels routing 19 | 20 | Python:: 21 | 22 | application = ProtocolTypeRouter({ 23 | # Django's ASGI application to handle traditional HTTP requests 24 | "http": django_asgi_app, 25 | 26 | # WebSocket chat handler 27 | "websocket": URLRouter([ 28 | path("ws/notifications//", PushNotifConsumer.as_asgi()), 29 | ]) 30 | } 31 | ) 32 | -------------------------------------------------------------------------------- /docs/source/settings.rst: -------------------------------------------------------------------------------- 1 | Settings 2 | --------- 3 | 4 | Django Magic Notifier works mainly with settings. Many objects used by DMN are configurable 5 | 6 | GENERAL SETTINGS 7 | =================== 8 | 9 | All the next settings goes in a dictionary named NOTIFIER, for example:: 10 | 11 | NOTIFIER = { 12 | 'THREADED': True 13 | } 14 | 15 | Enable, disable email notifications, Default True (Enabled):: 16 | 17 | 'EMAIL_ACTIVE': True 18 | 19 | Enable, disable sms notifications, Default False (Disabled):: 20 | 21 | 'SMS_ACTIVE': False 22 | 23 | Enable, disable push notifications, Default False (Disabled):: 24 | 25 | 'PUSH_ACTIVE': False 26 | 27 | Threading, sending or not notifications in background. Default False:: 28 | 29 | 'THREADED': False 30 | 31 | NOTIFIER EMAIL SETTINGS 32 | =========================== 33 | 34 | The next settings goes in a dictionary named EMAIL in NOTIFIER, like this:: 35 | 36 | NOTIFIER = { 37 | 'EMAIL': { 38 | } 39 | } 40 | 41 | Specify the default EMAIL gateway:: 42 | 43 | 'DEFAULT_GATEWAY': 'default' 44 | 45 | Speficifing an email gateway is by adding a dictionary in the EMAIL dicitionary:: 46 | 47 | 'default': { 48 | "CLIENT": "magic_notifier.email_clients.django_email.DjangoEmailClient" 49 | "HOST": "", 50 | "PORT": 0, 51 | "USER": "", 52 | "FROM": "", 53 | "PASSWORD": "", 54 | "USE_SSL": False, 55 | "USE_TLS": False, 56 | } 57 | 58 | Full example:: 59 | 60 | NOTIFIER = { 61 | 'EMAIL': { 62 | 'DEFAULT_GATEWAY': 'smtp_1', 63 | 'smtp_1': { 64 | "CLIENT": "magic_notifier.email_clients.django_email.DjangoEmailClient" 65 | "HOST": "", 66 | "PORT": 0, 67 | "USER": "", 68 | "FROM": "", 69 | "PASSWORD": "", 70 | "USE_SSL": False, 71 | "USE_TLS": False, 72 | }, 73 | 'smtp_2': { 74 | "CLIENT": "magic_notifier.email_clients.django_email.DjangoEmailClient" 75 | "HOST": "", 76 | "PORT": 0, 77 | "USER": "", 78 | "FROM": "", 79 | "PASSWORD": "", 80 | "USE_SSL": False, 81 | "USE_TLS": False, 82 | }, 83 | 'custom': { 84 | "CLIENT": "app.email_clients.CustomEmailClient" 85 | "Option1": "", 86 | "Option2": 0, 87 | "Option3": "", 88 | } 89 | } 90 | } 91 | 92 | NOTIFIER SMS SETTINGS 93 | ====================== 94 | 95 | The next settings goes in a dictionary named SMS in NOTIFIER, like this:: 96 | 97 | NOTIFIER = { 98 | 'SMS': { 99 | 100 | } 101 | } 102 | 103 | Specify the default EMAIL gateway:: 104 | 105 | 'DEFAULT_GATEWAY': 'default' 106 | 107 | Speficifing a sms gateway is by adding a dictionary in the SMS dictionary:: 108 | 109 | 'default': { 110 | "CLIENT": "magic_notifier.sms_clients.twilio_client.TwilioClient" 111 | "ACCOUNT": "", 112 | "TOKEN": 0, 113 | "FROM_NUMBER": "", 114 | } 115 | 116 | Full example:: 117 | 118 | NOTIFIER = { 119 | 'SMS': { 120 | 'DEFAULT_GATEWAY': 'twilio', 121 | 'twilio': { 122 | "CLIENT": "magic_notifier.sms_clients.twilio_client.TwilioClient" 123 | "ACCOUNT": "", 124 | "TOKEN": 0, 125 | "FROM_NUMBER": "", 126 | }, 127 | 'custom': { 128 | "CLIENT": "app.sms_clients.CustomEmailClient" 129 | "Option1": "", 130 | "Option2": 0, 131 | "Option3": "", 132 | } 133 | } 134 | } 135 | 136 | DMN needs a way to get a phone number from a User object. GET USER NUMBER must be path a function that accepts 137 | one parameter of type User. Default **`'magic_notifer.utils.get_user_number'`**:: 138 | 139 | 'GET_USER_NUMBER': 'path.to.function' 140 | 141 | NOTIFIER PUSH SETTINGS 142 | ====================== 143 | 144 | To connect to push notification websocket, a client must have a token. 145 | You need to specify a path to a function that returns a token given a 146 | User instance. The signature of the function must be:: 147 | 148 | def get_token_from_user(user) -> str: 149 | 150 | 151 | Setting example:: 152 | 153 | NOTIFIER = { 154 | "USER_FROM_WS_TOKEN_FUNCTION": 'magic_notifier.utils.get_user_from_ws_token' 155 | } 156 | -------------------------------------------------------------------------------- /docs/source/templates.rst: -------------------------------------------------------------------------------- 1 | Templates 2 | --------- 3 | 4 | 5 | Django Magic Notifier supports templates out of the box. 6 | To add new templates to your project to be used with DMN, you have to create 7 | a folder named **notifier** in one of your template's folder. 8 | 9 | If your app name is **app_name** then create a directory **app_name/templates/notifier** 10 | 11 | Now suppose you want to have a template named hello, then within the newly created 12 | folder created another folder like that **app_name/templates/notifier/hello** 13 | 14 | Now in this folder you have to create some files depending on how you will send your 15 | notifications. If you will send your notification via email then you must create 16 | two files within the hello folder named **email.html** and **email.txt** or **email.mjml** 17 | and **email**. Because DMN supports also mjml via 3rd party package. 18 | If you will send notifications via sms then you must create a file named **sms.txt**. 19 | If you want to send push notification then you must create a file **push.json** 20 | 21 | It is a common behavior to have a base template, you can do the same by creating a 22 | folder named **base** in the notifier folder and creating the files **email.html**, 23 | **email.txt** and **sms.txt**. 24 | 25 | Django Magic Notifier is shipped with some base templates that you can use. Let look 26 | at this example: 27 | 28 | *app_name/templates/notifier/base/email.html*:: 29 | 30 | {% extends "base_notifier/email.html" %} 31 | 32 | *app_name/templates/notifier/base/email.txt*:: 33 | 34 | {% extends "base_notifier/email.txt" %} 35 | 36 | *app_name/templates/notifier/base/sms.txt*:: 37 | 38 | {% extends "base_notifier/sms.txt" %} 39 | 40 | *app_name/templates/notifier/base/push.json*:: 41 | 42 | {% extends "base_notifier/push.json" %} 43 | 44 | 45 | Now in the hello template folder, you do: 46 | 47 | *app_name/templates/notifier/hello/email.html*:: 48 | 49 | {% extends "notifier/base/email.html" %} 50 | {% block content %} 51 | 52 |

Hello {{ user.email }} 53 | 54 | 55 | {% endblock %} 56 | 57 | *app_name/templates/notifier/hello/email.txt*:: 58 | 59 | {% extends "notifier/base/email.txt" %} 60 | {% block content %} 61 | >Hello {{ user.email }} 62 | {% endblock %} 63 | 64 | *app_name/templates/notifier/hello/email.mjml*:: 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | My Logo 76 | 77 | 78 | 79 | 80 | 81 |

Welcome

82 | 83 | 84 | Welcome to our company 85 | 86 | 87 | 88 | 89 | 90 | 91 | My Company 92 | 93 | 94 | 95 | 96 | 97 | 98 | *app_name/templates/notifier/hello/sms.txt*:: 99 | 100 | {% extends "notifier/base/sms.txt" %} 101 | {% block content %} 102 | >Hello {{ user.email }} 103 | {% endblock %} 104 | 105 | *app_name/templates/notifier/hello/push.json*:: 106 | 107 | {% extends "notifier/base/push.json" %} 108 | {% block subject %}Hello {{ user.username }}{% endblock %} 109 | 110 | -------------------------------------------------------------------------------- /docs/source/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ----- 3 | 4 | 5 | Send an email with a direct final string (no template) to a user instance:: 6 | 7 | user = User(email="testuser@localhost", username="testuser") 8 | subject = "Test magic notifier" 9 | notify(["email"], subject, [user], final_message="Nice if you get this") 10 | 11 | 12 | Send an email with a template (hello) to a user instance:: 13 | 14 | user = User(email="testuser@localhost", username="testuser") 15 | subject = "Test magic notifier" 16 | notify(["email"], subject, [user], template='hello') 17 | 18 | 19 | Send an email with a template to all superuser:: 20 | 21 | user = User(email="testuser@localhost", username="testuser") 22 | subject = "Test magic notifier" 23 | notify(["email"], subject, "admins", template='hello') 24 | 25 | 26 | Send an email with a template to all staff users:: 27 | 28 | user = User(email="testuser@localhost", username="testuser") 29 | subject = "Test magic notifier" 30 | notify(["email"], subject, "staff", template='hello') 31 | 32 | 33 | Send an email with a template to all users:: 34 | 35 | user = User(email="testuser@localhost", username="testuser") 36 | subject = "Test magic notifier" 37 | notify(["email"], subject, "all", template='hello') 38 | 39 | 40 | Send an email with a template to all users excluding staff:: 41 | 42 | user = User(email="testuser@localhost", username="testuser") 43 | subject = "Test magic notifier" 44 | notify(["email"], subject, "all-staff", template='hello') 45 | 46 | 47 | Send an email with a file and a template to all users:: 48 | 49 | user = User(email="testuser@localhost", username="testuser") 50 | subject = "Test magic notifier" 51 | notify(["email"], subject, "all-staff", template='hello', 52 | files=['path/to/file.ext']) 53 | 54 | 55 | Send a sms with a direct message (no template) to a set of users:: 56 | 57 | users = User.objects.filter(pk<10) 58 | subject = "Test magic notifier" 59 | notify(["sms"], subject, users, final_message="Nice if you get this") 60 | 61 | 62 | Send a sms with a template to a set of users:: 63 | 64 | users = User.objects.filter(pk<10) 65 | subject = "Test magic notifier" 66 | notify(["sms"], subject, users, template='hello') 67 | 68 | 69 | Send an email and sms with a template to all users excluding staff:: 70 | 71 | user = User(email="testuser@localhost", username="testuser") 72 | subject = "Test magic notifier" 73 | notify(["email", 'sms'], subject, "all-staff", template='hello') 74 | 75 | Send an email, a sms and a push notification with a template to all users excluding staff:: 76 | 77 | user = User(email="testuser@localhost", username="testuser") 78 | subject = "Test magic notifier" 79 | notify(["email", 'sms', 'push'], subject, "all-staff", template='hello') 80 | -------------------------------------------------------------------------------- /magic_notifier/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jefcolbi/django-magic-notifier/4a6cf469dd2e8720b7664d5709faff8c1606f2b2/magic_notifier/__init__.py -------------------------------------------------------------------------------- /magic_notifier/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from magic_notifier.models import Notification 4 | 5 | 6 | @admin.register(Notification) 7 | class NotificationAdmin(admin.ModelAdmin): 8 | pass 9 | -------------------------------------------------------------------------------- /magic_notifier/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class NotifConfig(AppConfig): 5 | name = "magic_notifier" 6 | -------------------------------------------------------------------------------- /magic_notifier/consumers.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | import ctypes 3 | import json 4 | import logging 5 | import traceback 6 | from datetime import date, datetime, timedelta 7 | from pathlib import Path 8 | 9 | from asgiref.sync import async_to_sync 10 | from channels.db import database_sync_to_async 11 | from channels.generic.websocket import WebsocketConsumer 12 | from django.core.exceptions import ObjectDoesNotExist 13 | from django.utils import timezone 14 | from .models import Notification 15 | from .serializers import NotificationSerializer 16 | 17 | logger = logging.getLogger("notif") 18 | 19 | 20 | class PushNotifConsumer(WebsocketConsumer): 21 | def __init__(self, *args, **kwargs): 22 | super().__init__(*args, **kwargs) 23 | self.token: str = None 24 | self.user = None 25 | 26 | def connect(self): 27 | from .utils import get_settings, import_attribute 28 | 29 | try: 30 | logger.info(f"accepting") 31 | self.accept() 32 | 33 | self.token = self.scope["url_route"]["kwargs"]["token"] 34 | get_user_from_ws_token_func = import_attribute(get_settings("USER_FROM_WS_TOKEN_FUNCTION")) 35 | self.user = get_user_from_ws_token_func(self.token) 36 | 37 | 38 | async_to_sync(self.channel_layer.group_add)(f"user-{self.user.id}", self.channel_name) 39 | 40 | logger.info("Accepted") 41 | except Exception as e: 42 | print(traceback.print_exc()) 43 | logger.error(traceback.format_exc()) 44 | 45 | def disconnect(self, close_code): 46 | try: 47 | async_to_sync(self.channel_layer.group_discard)(f"user-{self.user.id}", self.channel_name) 48 | except Exception as e: 49 | print(traceback.format_exc()) 50 | logging.error(traceback.format_exc()) 51 | 52 | def receive(self, text_data): 53 | event = json.loads(text_data) 54 | logger.info("{} >> {}".format(self.user, text_data)) 55 | 56 | event_handler = getattr(self, event["type"].lower().replace(".", "_"), None) 57 | if callable(event_handler): 58 | event_handler(event) 59 | 60 | def notify(self, data: dict): 61 | self.send(json.dumps(data)) 62 | 63 | def notification(self, data: dict): 64 | self.send(json.dumps(data)) 65 | 66 | def unread(self, event: dict): 67 | notifs = Notification.objects.filter(user=self.user, read__isnull=True) 68 | event["count"] = len(notifs) 69 | event["notifications"] = NotificationSerializer(notifs, many=True).data 70 | self.send(json.dumps(event)) 71 | 72 | def markread(self, event: dict): 73 | notifs = Notification.objects.filter(user=self.user, read__isnull=True) 74 | notification_id = event.get('notification') 75 | if notification_id: 76 | notifs = notifs.filter(pk=notification_id) 77 | notifs.update(read=timezone.now()) 78 | event["success"] = True 79 | self.send(json.dumps(event)) 80 | -------------------------------------------------------------------------------- /magic_notifier/email_clients/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jefcolbi/django-magic-notifier/4a6cf469dd2e8720b7664d5709faff8c1606f2b2/magic_notifier/email_clients/__init__.py -------------------------------------------------------------------------------- /magic_notifier/email_clients/amazon_ses.py: -------------------------------------------------------------------------------- 1 | from django.core.mail import get_connection 2 | from django_ses import SESBackend 3 | 4 | 5 | class AmazonSesClient: 6 | 7 | @classmethod 8 | def get_connection(cls, email_settings:dict): 9 | ses_backend = SESBackend( 10 | fail_silently=email_settings.pop('FAIL_SILENTLY', None), 11 | aws_session_profile=email_settings.pop('AWS_SESSION_PROFILE', None), 12 | aws_access_key=email_settings.pop('AWS_ACCESS_KEY'), 13 | aws_secret_key=email_settings.pop('AWS_SECRET_KEY'), 14 | aws_session_token=email_settings.pop('AWS_SESSION_TOKEN', None), 15 | aws_region_endpoint=email_settings.pop('AWS_REGION_ENDPOINT', None), 16 | aws_region_name=email_settings.pop('AWS_REGION_NAME', None), 17 | aws_auto_throttle=email_settings.pop('AWS_AUTO_THROTTLE', None), 18 | aws_config=email_settings.pop('AWS_CONFIG', None), 19 | dkim_domain=email_settings.pop('DKIM_DOMAIN', None), 20 | dkim_key=email_settings.pop('DKIM_KEY', None), 21 | dkim_headers=email_settings.pop('DKIM_HEADERS', None), 22 | dkim_selector=email_settings.pop('DKIM_SELECTOR', None), 23 | ses_from_arn=email_settings.pop('SES_FROM_ARN', None), 24 | ses_source_arn=email_settings.pop('SES_SOURCE_ARN', None), 25 | ses_return_path_arn=email_settings.pop('SES_RETURN_PATH_ARN', None), 26 | use_ses_v2=email_settings.pop('USE_SES_V2', None), 27 | **email_settings 28 | ) 29 | ses_backend.open() 30 | return ses_backend 31 | -------------------------------------------------------------------------------- /magic_notifier/email_clients/django_email.py: -------------------------------------------------------------------------------- 1 | from django.core.mail import get_connection 2 | 3 | 4 | class DjangoEmailClient: 5 | 6 | @classmethod 7 | def get_connection(cls, email_settings:dict): 8 | smtp_host = email_settings["HOST"] 9 | smtp_port = email_settings["PORT"] 10 | smtp_use_tls = email_settings.get("USE_TLS", False) 11 | smtp_use_ssl = email_settings.get("USE_SSL", False) 12 | smtp_username = email_settings["USER"] 13 | smtp_password = email_settings["PASSWORD"] 14 | 15 | return get_connection( 16 | host=smtp_host, 17 | port=smtp_port, 18 | username=smtp_username, 19 | password=smtp_password, 20 | use_tls=smtp_use_tls, 21 | use_ssl=smtp_use_ssl, 22 | ) -------------------------------------------------------------------------------- /magic_notifier/emailer.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os.path 3 | import traceback 4 | from argparse import OPTIONAL 5 | from pathlib import Path 6 | from threading import Thread 7 | from typing import Optional, List 8 | 9 | from django.contrib.auth.models import User 10 | from django.core.mail import EmailMultiAlternatives 11 | from django.template.exceptions import TemplateDoesNotExist 12 | from django.template.loader import render_to_string 13 | from django.utils.translation import gettext as _ 14 | from mjml import mjml2html 15 | from functools import partial 16 | from django.template.engine import Engine 17 | 18 | from magic_notifier.utils import get_settings 19 | 20 | from .utils import import_attribute 21 | 22 | logger = logging.getLogger("notifier") 23 | 24 | 25 | class Emailer: 26 | def __init__( 27 | self, 28 | subject: str, 29 | receivers: list, 30 | template: Optional[str], 31 | context: dict, 32 | email_gateway: Optional[str] = None, 33 | final_message: str = None, 34 | files: list = None, 35 | tried_gateways: Optional[List[str]] = None, 36 | **kwargs, 37 | ): 38 | """The class is reponsible of email sending. 39 | 40 | :param subject: the subject of the notification, ignored when send by sms 41 | :param receivers: list of User 42 | :param template: the name of the template to user. Default None 43 | :param context: the context to be passed to template. Default None 44 | :param email_gateway: the email gateway to use. Default 'default' 45 | :param final_message: the final message to be sent as the notification content, must be sent if template is None, template is ignored if it is sent. Default None 46 | :param files: list of files to be sent. accept file-like objects, tuple, file path. Default None 47 | :param kwargs: 48 | """ 49 | try: 50 | NOTIFIER_EMAIL = get_settings('EMAIL') 51 | NOTIFIER_EMAIL_DEFAULT_GATEWAY = NOTIFIER_EMAIL.get('DEFAULT_GATEWAY', 'default') 52 | except (AttributeError, KeyError): 53 | from magic_notifier.settings import NOTIFIER_EMAIL, NOTIFIER_EMAIL_DEFAULT_GATEWAY 54 | 55 | self.fallback_gateways = NOTIFIER_EMAIL.get('FALLBACKS', []) 56 | self.current_gateway = email_gateway if email_gateway else NOTIFIER_EMAIL_DEFAULT_GATEWAY 57 | self.email_settings: dict = NOTIFIER_EMAIL[self.current_gateway] 58 | self.email_client = import_attribute(self.email_settings["CLIENT"]) 59 | 60 | self.tried_gateways = tried_gateways if tried_gateways else [] 61 | 62 | self.connection = self.email_client.get_connection(self.email_settings) 63 | self.subject: str = subject 64 | self.receivers: list = receivers 65 | self.template: Optional[str] = template 66 | self.context: dict = context if context else {} 67 | self.final_message = final_message 68 | self.threaded: bool = kwargs.get("threaded", False) 69 | self.files: Optional[list] = files 70 | self.current_engine = Engine.get_default() 71 | if self.template: 72 | self.tpl_abs_path = self.current_engine.find_template(f"notifier/{self.template}/email.mjml")[1].name 73 | else: 74 | self.tpl_abs_path = None 75 | logger.info(f"{self.tpl_abs_path = }") 76 | 77 | def mjml_loader(self, dest: str): 78 | f_res = os.path.abspath(os.path.join(Path(self.tpl_abs_path).parent, dest)) 79 | with open(f_res) as fp: 80 | res = fp.read() 81 | return res 82 | 83 | def send(self): 84 | if self.threaded: 85 | t = Thread(target=self._send) 86 | t.setDaemon(True) 87 | t.start() 88 | else: 89 | self._send() 90 | 91 | def _send(self): 92 | try: 93 | logger.info(f"sending emails to {self.receivers}") 94 | for user in self.receivers: 95 | # activate(user.settings.lang) 96 | if isinstance(user, str): 97 | user = User(email=user, username=user) 98 | 99 | ctx = self.context.copy() 100 | ctx["user"] = user 101 | logger.info(f" Sending to user {user.username} with context {ctx}") 102 | 103 | if self.template: 104 | try: 105 | mjml_content = render_to_string( 106 | f"notifier/{self.template}/email.mjml", ctx 107 | ) 108 | logger.debug("mjml_content") 109 | logger.debug(mjml_content) 110 | html_content = mjml2html(mjml_content, include_loader=self.mjml_loader) 111 | logger.debug("html_content") 112 | logger.debug(html_content) 113 | except TemplateDoesNotExist as e: 114 | html_content = None 115 | 116 | if not html_content: 117 | try: 118 | html_content = render_to_string( 119 | f"notifier/{self.template}/email.html", ctx 120 | ) # render with dynamic value 121 | logger.debug("html_content") 122 | logger.debug(html_content) 123 | except TemplateDoesNotExist: 124 | html_content = None 125 | 126 | text_content = render_to_string( 127 | f"notifier/{self.template}/email.txt", ctx 128 | ) # render with dynamic value 129 | logger.debug("text_content") 130 | logger.debug(text_content) 131 | else: 132 | html_content = text_content = self.final_message 133 | 134 | msg = EmailMultiAlternatives( 135 | self.subject, 136 | text_content, 137 | self.email_settings["FROM"], 138 | [user.email], 139 | connection=self.connection, 140 | ) 141 | if html_content: 142 | msg.attach_alternative(html_content, "text/html") 143 | 144 | if self.files: 145 | for i, pos_file in enumerate(self.files): 146 | if isinstance(pos_file, str): 147 | msg.attach_file(pos_file) 148 | elif isinstance(pos_file, tuple): 149 | name, f = pos_file 150 | if hasattr(f, 'read'): 151 | msg.attach(name, f.read()) 152 | else: 153 | logger.warning( 154 | f"file {name} can't be added to mail because it is not a file-like object") 155 | elif hasattr(pos_file, 'read'): 156 | msg.attach(f"file {i + 1}", pos_file.read()) 157 | else: 158 | logger.warning(f"discarding possible file {pos_file}") 159 | 160 | logger.debug(f"Sending email via connection") 161 | msg.send() 162 | logger.debug(f"Email sent!") 163 | except Exception as e: 164 | logger.error(traceback.format_exc()) 165 | self.tried_gateways.append(self.current_gateway) 166 | if self.fallback_gateways: 167 | for gateway in self.fallback_gateways: 168 | if gateway not in self.tried_gateways: 169 | logger.warning(f"We are falling back to {gateway} gateway") 170 | new_emailer = Emailer(self.subject, self.receivers, self.template, self.context, 171 | email_gateway=gateway, final_message=self.final_message, 172 | files=self.files, tried_gateways=self.tried_gateways) 173 | return new_emailer.send() 174 | -------------------------------------------------------------------------------- /magic_notifier/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jefcolbi/django-magic-notifier/4a6cf469dd2e8720b7664d5709faff8c1606f2b2/magic_notifier/management/__init__.py -------------------------------------------------------------------------------- /magic_notifier/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jefcolbi/django-magic-notifier/4a6cf469dd2e8720b7664d5709faff8c1606f2b2/magic_notifier/management/commands/__init__.py -------------------------------------------------------------------------------- /magic_notifier/management/commands/connect_telegram.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.core.management.base import BaseCommand 3 | from magic_notifier.telegram_clients.telethon import TelethonClient 4 | 5 | User = get_user_model() 6 | 7 | 8 | class Command(BaseCommand): 9 | """The command `test_email_template` is used to test a template email. 10 | This is very useful in development.""" 11 | 12 | def add_arguments(self, parser): 13 | parser.add_argument('gateway', type=str, help="The gateway to connect") 14 | 15 | def handle(self, *args, **options): 16 | 17 | try: 18 | gateway = options['gateway'] 19 | TelethonClient.get_client(gateway) 20 | print('Done!') 21 | except Exception: 22 | import traceback 23 | traceback.print_exc() 24 | -------------------------------------------------------------------------------- /magic_notifier/management/commands/test_email_template.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.core.management.base import BaseCommand 3 | 4 | from magic_notifier.notifier import notify 5 | 6 | User = get_user_model() 7 | 8 | 9 | class Command(BaseCommand): 10 | """The command `test_email_template` is used to test a template email. 11 | This is very useful in development.""" 12 | 13 | def add_arguments(self, parser): 14 | parser.add_argument('template', type=str, help="The template to test") 15 | parser.add_argument('email', type=str, help="The email to use") 16 | parser.add_argument('-s', '--subject', type=str, default='Testing email template', required=False, 17 | help="The subject of email. default to `Testing email template`") 18 | parser.add_argument('-a', '--account', type=str, default='default', required=False, 19 | help="The smtp account. default to `default`") 20 | 21 | def handle(self, *args, **options): 22 | 23 | try: 24 | template = options['template'] 25 | subject = options['subject'] 26 | account = options['account'] 27 | user_email = options['email'] 28 | 29 | print(f"Sending email from template {template} to {user_email}") 30 | 31 | 32 | user = User(email=user_email, username=user_email) 33 | notify(["email"], subject=subject, email_gateway=account, receivers=[user], template=template, 34 | context={}) 35 | 36 | print('Done!') 37 | except Exception: 38 | import traceback 39 | traceback.print_exc() 40 | -------------------------------------------------------------------------------- /magic_notifier/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.15 on 2023-07-11 09:46 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='NotifyProfile', 19 | fields=[ 20 | ('id', models.BigAutoField(primary_key=True, serialize=False)), 21 | ('phone_number', models.CharField(blank=True, max_length=20, null=True)), 22 | ('current_channel', models.CharField(blank=True, max_length=255, null=True)), 23 | ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 24 | ], 25 | ), 26 | migrations.CreateModel( 27 | name='Notification', 28 | fields=[ 29 | ('id', models.BigAutoField(primary_key=True, serialize=False)), 30 | ('subject', models.CharField(blank=True, max_length=255, null=True)), 31 | ('text', models.TextField()), 32 | ('type', models.CharField(max_length=30)), 33 | ('sub_type', models.CharField(blank=True, max_length=30, null=True)), 34 | ('link', models.CharField(max_length=255, verbose_name='The link associated')), 35 | ('image', models.ImageField(blank=True, null=True, upload_to='notifications')), 36 | ('actions', models.JSONField(default=dict)), 37 | ('data', models.JSONField(default=dict)), 38 | ('read', models.DateTimeField(blank=True, null=True)), 39 | ('sent', models.DateTimeField(auto_now_add=True)), 40 | ('mode', models.CharField(choices=[('user', 'User'), ('admin', 'Admin')], default='user', max_length=10)), 41 | ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='magic_notifications', to=settings.AUTH_USER_MODEL)), 42 | ], 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /magic_notifier/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jefcolbi/django-magic-notifier/4a6cf469dd2e8720b7664d5709faff8c1606f2b2/magic_notifier/migrations/__init__.py -------------------------------------------------------------------------------- /magic_notifier/models.py: -------------------------------------------------------------------------------- 1 | import json 2 | from operator import mod 3 | 4 | from django import VERSION as DJANGO_VERSION 5 | from django.contrib.auth import get_user_model 6 | from django.core.exceptions import ValidationError 7 | from django.db import models 8 | from django.db.models.deletion import CASCADE 9 | from django.db.models.fields.related import ForeignKey, OneToOneField 10 | from django.utils.translation import gettext as _ 11 | 12 | if DJANGO_VERSION[0] >= 3 and DJANGO_VERSION[1] >= 1: 13 | from django.db.models import JSONField 14 | else: 15 | class JSONField(models.TextField): 16 | """Simple JSON field that stores python structures as JSON strings 17 | on database. 18 | """ 19 | 20 | def from_db_value(self, value, *args, **kwargs): 21 | return self.to_python(value) 22 | 23 | def to_python(self, value): 24 | """ 25 | Convert the input JSON value into python structures, raises 26 | django.core.exceptions.ValidationError if the data can't be converted. 27 | """ 28 | if self.blank and not value: 29 | return None 30 | if isinstance(value, str): 31 | try: 32 | return json.loads(value) 33 | except Exception as e: 34 | raise ValidationError(str(e)) 35 | else: 36 | return value 37 | 38 | def validate(self, value, model_instance): 39 | """Check value is a valid JSON string, raise ValidationError on 40 | error.""" 41 | if isinstance(value, str): 42 | super(JSONField, self).validate(value, model_instance) 43 | try: 44 | json.loads(value) 45 | except Exception as e: 46 | raise ValidationError(str(e)) 47 | 48 | def get_prep_value(self, value): 49 | """Convert value to JSON string before save""" 50 | try: 51 | return json.dumps(value) 52 | except Exception as e: 53 | raise ValidationError(str(e)) 54 | 55 | def value_from_object(self, obj): 56 | """Return value dumped to string.""" 57 | val = super(JSONField, self).value_from_object(obj) 58 | return self.get_prep_value(val) 59 | 60 | from .settings import NOTIFIER_AVAILABLE_MODES, NOTIFIER_DEFAULT_MODE 61 | 62 | User = get_user_model() 63 | 64 | 65 | class Notification(models.Model): 66 | 67 | id = models.BigAutoField(primary_key=True) 68 | user: models.ForeignKey = models.ForeignKey( 69 | User, models.CASCADE, null=True, blank=True, related_name="magic_notifications" 70 | ) 71 | inited_by: models.ForeignKey = models.ForeignKey( 72 | User, models.CASCADE, null=True, blank=True, related_name="magic_notifications_inited" 73 | ) 74 | subject: models.CharField = models.CharField(max_length=255, null=True, blank=True) 75 | text: models.TextField = models.TextField() 76 | type: models.CharField = models.CharField(max_length=30) 77 | sub_type: models.CharField = models.CharField(max_length=30, null=True, blank=True) 78 | link: models.CharField = models.CharField(_("The link associated"), max_length=255) 79 | image: models.ImageField = models.ImageField(upload_to="notifications", null=True, blank=True) 80 | is_visible: models.BooleanField = models.BooleanField(default=True) 81 | is_encrypted: models.BooleanField = models.BooleanField(default=False) 82 | actions: JSONField = JSONField(default=dict) 83 | data: JSONField = JSONField(default=dict) 84 | read: models.DateTimeField = models.DateTimeField(null=True, blank=True) 85 | sent: models.DateTimeField = models.DateTimeField(auto_now_add=True) 86 | expires: models.DateTimeField = models.DateTimeField(null=True, blank=True) 87 | mode: models.CharField = models.CharField( 88 | max_length=10, default=NOTIFIER_DEFAULT_MODE, choices=NOTIFIER_AVAILABLE_MODES 89 | ) 90 | masked: models.BooleanField = models.BooleanField(default=False) 91 | 92 | def __str__(self): 93 | if self.user and self.user.username: 94 | user_name = self.user.username 95 | else: 96 | user_name = "" 97 | return "{} Notif #{}".format(user_name, self.id) 98 | 99 | def save(self, *args, **kwargs): 100 | return super().save(*args, **kwargs) 101 | 102 | def mark_read(self): 103 | from django.utils import timezone 104 | self.read = timezone.now() 105 | self.save() 106 | 107 | def to_dict(self): 108 | return {'type': self.type, 'sub_type': self.sub_type, 109 | 'link': self.link, 'is_visible': self.is_visible, 110 | 'is_encrypted': self.is_encrypted, 'action': self.actions, 111 | 'data': self.data} 112 | 113 | 114 | class NotifyProfile(models.Model): 115 | 116 | id = models.BigAutoField(primary_key=True) 117 | phone_number: models.CharField = models.CharField(max_length=20, null=True, blank=True) 118 | current_channel: models.CharField = models.CharField(max_length=255, null=True, blank=True) 119 | user: models.OneToOneField = models.OneToOneField(User, models.CASCADE) 120 | -------------------------------------------------------------------------------- /magic_notifier/notifier.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import traceback 3 | from typing import Optional, Union 4 | 5 | from django.contrib.auth import get_user_model 6 | from django.db import models 7 | 8 | from magic_notifier.emailer import Emailer 9 | from magic_notifier.models import Notification 10 | from magic_notifier.pusher import Pusher 11 | from magic_notifier.settings import NOTIFIER_THREADED 12 | from magic_notifier.smser import ExternalSMS 13 | from magic_notifier.telegramer import Telegramer 14 | from magic_notifier.whatsapper import Whatsapper 15 | 16 | User = get_user_model() 17 | 18 | logger = logging.getLogger("notifier") 19 | 20 | 21 | def notify( 22 | vias: list, 23 | subject: str = None, 24 | receivers: Union[str, list, models.QuerySet, models.Manager] = None, 25 | template: str = None, 26 | context: dict = None, 27 | final_message: str = None, 28 | final_notification: Optional[Notification] = None, 29 | email_gateway: Optional[str] = None, 30 | sms_gateway: Optional[str] = None, 31 | whatsapp_gateway: Optional[str] = None, 32 | telegram_gateway: Optional[str] = None, 33 | push_gateway: Optional[str] = None, 34 | remove_notification_fields: list=None, 35 | files: list = None, 36 | threaded: bool = None, 37 | inited_by: User = None, 38 | ): 39 | """This function send a notification via the method specified in parameter vias 40 | 41 | :param vias: accepted values are email,sms,push 42 | :param subject: the subject of the notification, ignored when send by sms 43 | :param receivers: it can be a list, queryset or manager of users. if a string is passed it must be *admins* to send to (super) admins, *staff* to send to staff only, *all* to all users, *all-staff* to all users minus staff and *all-admins* to all users excepted admins 44 | :param template: the name of the template to user. Default None 45 | :param context: the context to be passed to template. Note that the context is auto-filled with the current the notification is going under the key 'user'. Default None 46 | :param final_message: the final message to be sent as the notification content, must be sent if template is None, template is ignored if it is sent. Default None 47 | :param email_gateway: the email gateway to use. Default 'default' 48 | :param sms_gateway: the sms gateway to use. Default to None 49 | :param files: list of files to be sent. accept file-like objects, tuple, file path. Default None 50 | :param threaded: if True, the notification is sent in background else sent with the current thread. Default to NOTIFIER["THREADED"] settings 51 | :return: 52 | """ 53 | logger.debug(f"Sending {subject} to {receivers} via {vias}") 54 | threaded = threaded if threaded is not None else NOTIFIER_THREADED 55 | context = {} if context is None else context 56 | 57 | assert subject, "subject not defined" 58 | 59 | 60 | if isinstance(receivers, str): 61 | if receivers in ["admins", "staff", "all", "all-staff", "all-admins"]: 62 | if receivers == "admins": 63 | receivers = User.objects.filter(is_superuser=True) 64 | elif receivers == "staff": 65 | receivers = User.objects.filter(is_staff=True) 66 | elif receivers == "all": 67 | receivers = User.objects.all() 68 | elif receivers == "all-staff": 69 | receivers = User.objects.exclude(is_staff=True) 70 | elif receivers == "all-admins": 71 | receivers = User.objects.exclude(is_superuser=True) 72 | else: 73 | raise ValueError(f"'{receivers}' is not an allowed value for receivers arguments") 74 | 75 | assert isinstance(receivers, (list, models.Manager, models.QuerySet)), f"receivers must be a list at this point not {receivers}" 76 | 77 | for via in vias: 78 | try: 79 | if via == "email": 80 | em = Emailer( 81 | subject, 82 | list(receivers), 83 | template, 84 | context, 85 | email_gateway, 86 | threaded=threaded, 87 | final_message=final_message, 88 | files=files 89 | ) 90 | em.send() 91 | 92 | elif via == "sms": 93 | ex_sms = ExternalSMS(receivers,context, threaded=threaded, 94 | template=template, final_message=final_message, 95 | sms_gateway=sms_gateway) 96 | ex_sms.send() 97 | 98 | elif via == "push": 99 | assert template, "template variable can't be None or empty" 100 | 101 | pusher = Pusher( 102 | subject, receivers, template, context, threaded=threaded, push_gateway=push_gateway, 103 | remove_notification_fields=remove_notification_fields, final_notification=final_notification, 104 | inited_by=inited_by 105 | ) 106 | pusher.send() 107 | elif via == "whatsapp": 108 | whatsapper = Whatsapper(receivers,context, threaded=threaded, 109 | template=template, final_message=final_message, 110 | whatsapp_gateway=whatsapp_gateway) 111 | whatsapper.send() 112 | elif via == "telegram": 113 | telegramer = Telegramer(receivers, context, threaded=threaded, 114 | template=template, final_message=final_message, 115 | telegram_gateway=telegram_gateway) 116 | telegramer.send() 117 | else: 118 | logger.error(f"Unknown sending method {via}") 119 | 120 | except: 121 | logger.error(traceback.format_exc()) 122 | -------------------------------------------------------------------------------- /magic_notifier/push_clients/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jefcolbi/django-magic-notifier/4a6cf469dd2e8720b7664d5709faff8c1606f2b2/magic_notifier/push_clients/__init__.py -------------------------------------------------------------------------------- /magic_notifier/push_clients/expo.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import requests 4 | from pyfcm import FCMNotification 5 | from django.contrib.auth import get_user_model 6 | from magic_notifier.models import Notification 7 | from magic_notifier.utils import import_attribute 8 | import logging 9 | 10 | User = get_user_model() 11 | 12 | logger = logging.getLogger('notifier') 13 | 14 | 15 | class ExpoClient: 16 | 17 | def send(self, user: User, notification: Notification, options: dict, remove_notification_fields=None): 18 | remove_notification_fields = [] if not remove_notification_fields else remove_notification_fields 19 | fcm_user_tokens = import_attribute(options['GET_TOKENS_FUNCTION'])(user) 20 | 21 | data = notification.data 22 | 23 | for fcm_user_token in fcm_user_tokens: 24 | logger.info(f"Token: {fcm_user_token} :: Data: {data}") 25 | self.send_push_notification(fcm_user_token, notification.subject, notification.text, data) 26 | 27 | def send_push_notification(self, token:str, title: str, body: str, data: dict): 28 | url = 'https://exp.host/--/api/v2/push/send' 29 | headers = { 30 | 'Content-Type': 'application/json', 31 | } 32 | data = { 33 | 'to': token, 34 | 'sound': 'default', 35 | 'title': title, 36 | 'body': body, 37 | 'data': data, 38 | } 39 | response = requests.post(url, headers=headers, data=json.dumps(data)) 40 | logger.info(f'Response from Expo: {response.json()}') 41 | 42 | -------------------------------------------------------------------------------- /magic_notifier/push_clients/fcm.py: -------------------------------------------------------------------------------- 1 | from pyfcm import FCMNotification 2 | from django.contrib.auth import get_user_model 3 | from magic_notifier.models import Notification 4 | from magic_notifier.utils import import_attribute 5 | import logging 6 | 7 | User = get_user_model() 8 | 9 | logger = logging.getLogger('notifier') 10 | 11 | 12 | class FCMClient: 13 | 14 | def send(self, user: User, notification: Notification, options: dict, remove_notification_fields=None): 15 | remove_notification_fields = [] if not remove_notification_fields else remove_notification_fields 16 | fcm_user_tokens = import_attribute(options['GET_TOKENS_FUNCTION'])(user) 17 | 18 | fcm = FCMNotification(service_account_file=options["SERVICE_ACCOUNT_FILE"], 19 | project_id=options['PROJECT_ID']) 20 | 21 | data = notification.data 22 | 23 | for fcm_user_token in fcm_user_tokens: 24 | logger.info(f"Token: {fcm_user_token} :: Data: {data}") 25 | fcm.notify(fcm_token=fcm_user_token, notification_title=notification.subject, 26 | data_payload=data) 27 | 28 | -------------------------------------------------------------------------------- /magic_notifier/pusher.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import logging 3 | import traceback 4 | from threading import Thread 5 | 6 | from django.template.loader import render_to_string 7 | 8 | from magic_notifier.models import Notification 9 | 10 | try: 11 | from asgiref.sync import async_to_sync 12 | from channels.layers import get_channel_layer 13 | except Exception: 14 | pass 15 | import json 16 | 17 | from django.template import Context, Template 18 | 19 | from magic_notifier.utils import NotificationBuilder, get_settings 20 | 21 | logger = logging.getLogger("notif") 22 | 23 | 24 | class Pusher: 25 | def __init__( 26 | self, subject, receivers: list, template: str, context: dict, 27 | push_gateway=None, remove_notification_fields: list=None, 28 | final_notification: Notification = None, 29 | inited_by: "User"=None, **kwargs 30 | ): 31 | """ 32 | 33 | :param subject:The subject of the notification 34 | :param receivers: The user list of receivers 35 | :param template: The template to use 36 | :param context:The context to pass to the template 37 | :param inited_by: The user who inited the notification 38 | :param kwargs: 39 | """ 40 | self.receivers: list = receivers 41 | self.template: str = template 42 | self.context: dict = context 43 | self.remove_notification_fields = remove_notification_fields 44 | self.final_notification: Notification = final_notification 45 | self.inited_by = inited_by 46 | if 'subject' not in context and subject: 47 | self.context['subject'] = subject 48 | self.threaded: bool = kwargs.get("threaded", False) 49 | self.image = kwargs.get("image") 50 | # get the default sms gateway 51 | self.push_gateway = get_settings('PUSH::DEFAULT_GATEWAY') if push_gateway is None else push_gateway 52 | # get the sms gateway definition 53 | NOTIFIER_PUSH_GATEWAY = get_settings('PUSH')["GATEWAYS"][self.push_gateway] 54 | # get the sms client to be used 55 | NOTIFIER_PUSH_CLIENT = NOTIFIER_PUSH_GATEWAY['CLIENT'] 56 | # load the sms client 57 | module_name, class_name = NOTIFIER_PUSH_CLIENT.rsplit(".", 1) 58 | module = importlib.import_module(module_name) 59 | assert hasattr(module, class_name), "class {} is not in {}".format(class_name, module_name) 60 | self.client_class = getattr(module, class_name) 61 | self.push_class_options = NOTIFIER_PUSH_GATEWAY 62 | 63 | def send(self): 64 | if self.threaded: 65 | t = Thread(target=self._send) 66 | t.setDaemon(True) 67 | t.start() 68 | else: 69 | return self._send() 70 | 71 | def _send(self): 72 | try: 73 | client = self.client_class() 74 | 75 | for user in self.receivers: 76 | logger.debug(f"sending push notification to {user}") 77 | if self.final_notification: 78 | notification = self.final_notification 79 | else: 80 | ctx = self.context.copy() 81 | ctx["user"] = user 82 | push_content = render_to_string(f"notifier/{self.template}/push.json", ctx) 83 | 84 | event: dict = json.loads(push_content) 85 | event['type'] = 'notification' 86 | 87 | not_builder = ( 88 | NotificationBuilder(event["subject"]) 89 | .text(event['text']) 90 | .type(event["type"], event["sub_type"]) 91 | .link(event["link"]) 92 | .mode(event["mode"]) 93 | .data(event["data"]) 94 | .actions(event["actions"]) 95 | .user(user) 96 | .inited_by(self.inited_by) 97 | ) 98 | 99 | if self.image: 100 | not_builder.image(self.image) 101 | 102 | notification = not_builder.save() 103 | 104 | client.send(user, notification, self.push_class_options, 105 | remove_notification_fields=self.remove_notification_fields) 106 | # event['id'] = res.id 107 | # 108 | # # return 109 | # channel_layer = get_channel_layer() 110 | # async_to_sync(channel_layer.group_send)( 111 | # f"user-{user.id}", 112 | # event 113 | # ) 114 | 115 | return notification 116 | except Exception as e: 117 | logger.error(traceback.format_exc()) 118 | 119 | 120 | if __name__ == "__main__": 121 | pass 122 | -------------------------------------------------------------------------------- /magic_notifier/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework.serializers import ModelSerializer 2 | 3 | from magic_notifier.models import Notification 4 | 5 | 6 | class NotificationSerializer(ModelSerializer): 7 | class Meta: 8 | model = Notification 9 | fields = ( 10 | 'id', 11 | 'subject', 12 | "text", 13 | "type", 14 | "sub_type", 15 | "link", 16 | "mode", 17 | "image", 18 | "actions", 19 | "data", 20 | "read", 21 | "sent", 22 | ) 23 | -------------------------------------------------------------------------------- /magic_notifier/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | AVAILABLE_MODES = [("user", "User"), ("admin", "Admin")] 4 | NOTIFIER_SETTINGS = getattr(settings, "NOTIFIER", {}) 5 | 6 | EMAIL_ACTIVE = NOTIFIER_SETTINGS.get('EMAIL_ACTIVE', True) 7 | SMS_ACTIVE = NOTIFIER_SETTINGS.get('SMS_ACTIVE', False) 8 | PUSH_ACTIVE = NOTIFIER_SETTINGS.get('PUSH_ACTIVE', False) 9 | 10 | NOTIFIER_AVAILABLE_MODES = NOTIFIER_SETTINGS.get('DEFAULT_MODES', AVAILABLE_MODES) 11 | NOTIFIER_DEFAULT_MODE = NOTIFIER_SETTINGS.get("DEFAULT_MODE", "user") 12 | 13 | NOTIFIER_THREADED = NOTIFIER_SETTINGS.get('THREADED', False) 14 | 15 | NOTIFIER_EMAIL = NOTIFIER_SETTINGS.get('EMAIL', {}) 16 | NOTIFIER_EMAIL_DEFAULT_GATEWAY = NOTIFIER_EMAIL.get('DEFAULT_GATEWAY', 'default') 17 | 18 | if NOTIFIER_EMAIL_DEFAULT_GATEWAY == "default": 19 | if "default" not in NOTIFIER_EMAIL: 20 | # we build the default dict from django standard smtp settings 21 | assert settings.EMAIL_HOST, "You have not defined any DEFAULT EMAIL settings and no django email settings detected." 22 | NOTIFIER_EMAIL["default"] = { 23 | "HOST": settings.EMAIL_HOST, 24 | "PORT": settings.EMAIL_PORT, 25 | "USER": settings.EMAIL_HOST_USER, 26 | "FROM": settings.DEFAULT_FROM_EMAIL, 27 | "PASSWORD": settings.EMAIL_HOST_PASSWORD, 28 | "USE_SSL": getattr(settings, "EMAIL_USE_SSL", False), 29 | "USE_TLS": getattr(settings, "EMAIL_USE_TLS", False), 30 | "CLIENT": "magic_notifier.email_clients.django_email.DjangoEmailClient" 31 | } 32 | 33 | -------------------------------------------------------------------------------- /magic_notifier/sms_clients/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jefcolbi/django-magic-notifier/4a6cf469dd2e8720b7664d5709faff8c1606f2b2/magic_notifier/sms_clients/__init__.py -------------------------------------------------------------------------------- /magic_notifier/sms_clients/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | """ 3 | 4 | 5 | class BaseSmsClient: 6 | 7 | @classmethod 8 | def send(cls, number: str, text: str, **kwargs): 9 | raise NotImplementedError 10 | -------------------------------------------------------------------------------- /magic_notifier/sms_clients/cgsms_client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import requests 4 | from django.conf import settings 5 | 6 | from .base import BaseSmsClient 7 | 8 | logger = logging.getLogger("notifier") 9 | 10 | 11 | class CGSmsClient(BaseSmsClient): 12 | 13 | @classmethod 14 | def send(cls, number: str, text: str, **kwargs): 15 | sub_account = settings.NOTIFIER["SMS"]["GATEWAYS"]["CGS"]["SUB_ACCOUNT"] 16 | sub_account_pass = settings.NOTIFIER["SMS"]["GATEWAYS"]["CGS"]["SUB_ACCOUNT_PASSWORD"] 17 | params = { 18 | "sub_account": sub_account, 19 | "sub_account_pass": sub_account_pass, 20 | "action": "send_sms", 21 | "message": text, 22 | "recipients": number, 23 | } 24 | res = requests.get("http://cheapglobalsms.com/api_v1", params=params) 25 | return res 26 | -------------------------------------------------------------------------------- /magic_notifier/sms_clients/nexa_client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import requests 4 | from django.conf import settings 5 | 6 | from .base import BaseSmsClient 7 | 8 | logger = logging.getLogger("notifier") 9 | 10 | 11 | class NexaSmsClient(BaseSmsClient): 12 | 13 | @classmethod 14 | def send(cls, number: str, text: str, **kwargs): 15 | if not kwargs: 16 | default_settings = settings.NOTIFIER["SMS"]["GATEWAYS"]["NEXA"] 17 | else: 18 | default_settings = kwargs 19 | sub_account = default_settings["EMAIL"] 20 | sub_account_pass = default_settings["PASSWORD"] 21 | senderid = default_settings["SENDERID"] 22 | params = { 23 | "user": sub_account, 24 | "password": sub_account_pass, 25 | "senderid": senderid, 26 | "sms": text, 27 | "mobiles": number.replace('+', ''), 28 | } 29 | logger.info(f"Sending sms with data {params}") 30 | res = requests.post("https://smsvas.com/bulk/public/index.php/api/v1/sendsms", json=params) 31 | if(res.status_code != 200): 32 | logger.error(f"Failed to send the sms: {res.content}") 33 | else: 34 | logger.info(res.content) 35 | return res 36 | -------------------------------------------------------------------------------- /magic_notifier/sms_clients/twilio_client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.conf import settings 4 | from twilio.rest import Client 5 | 6 | from .base import BaseSmsClient 7 | 8 | logger = logging.getLogger("notifier") 9 | 10 | 11 | class TwilioClient(BaseSmsClient): 12 | 13 | @classmethod 14 | def send(cls, number: str, text: str, **kwargs): 15 | account = kwargs["ACCOUNT"] 16 | token = kwargs["TOKEN"] 17 | from_number = kwargs["FROM_NUMBER"] 18 | client = Client(account, token) 19 | res = client.messages.create(from_=from_number, to=number, body=text) 20 | return res 21 | -------------------------------------------------------------------------------- /magic_notifier/smser.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import logging 3 | import traceback 4 | from threading import Thread 5 | from typing import Optional 6 | 7 | from django.conf import settings 8 | from django.template.loader import render_to_string 9 | 10 | from magic_notifier.utils import get_settings, import_attribute 11 | 12 | logger = logging.getLogger("notifier") 13 | 14 | 15 | class ExternalSMS: 16 | 17 | def __init__(self, receivers: list, context: dict, template: Optional[str] = None, 18 | final_message: Optional[str] = None, sms_gateway: Optional[str] = None, **kwargs): 19 | """This class is reponsible of sending a notification via sms. 20 | 21 | :param receivers: list of User 22 | :param template: the name of the template to user. Default None 23 | :param context: the context to be passed to template. Default None 24 | :param final_message: the final message to be sent as the notification content, must be sent if template is None, template is ignored if it is sent. Default None 25 | :param sms_gateway: the sms gateway to use. Default to None 26 | :param kwargs: 27 | """ 28 | self.receivers: list = receivers 29 | self.template: Optional[str] = template 30 | self.context: dict = context 31 | self.threaded: bool = kwargs.get("threaded", False) 32 | self.final_message: Optional[str] = final_message 33 | # get the default sms gateway 34 | self.sms_gateway = get_settings('SMS::DEFAULT_GATEWAY') if sms_gateway is None else sms_gateway 35 | # get the sms gateway definition 36 | NOTIFIER_SMS_GATEWAY = get_settings('SMS')["GATEWAYS"][self.sms_gateway] 37 | # get the sms client to be used 38 | NOTIFIER_SMS_CLIENT = NOTIFIER_SMS_GATEWAY['CLIENT'] 39 | # load the sms client 40 | module_name, class_name = NOTIFIER_SMS_CLIENT.rsplit(".", 1) 41 | module = importlib.import_module(module_name) 42 | assert hasattr(module, class_name), "class {} is not in {}".format(class_name, module_name) 43 | self.client_class = getattr(module, class_name) 44 | self.sms_class_options = NOTIFIER_SMS_GATEWAY 45 | 46 | def send(self): 47 | if self.threaded: 48 | t = Thread(target=self._send) 49 | t.setDaemon(True) 50 | t.start() 51 | else: 52 | self._send() 53 | 54 | def _send(self): 55 | get_user_number = import_attribute(get_settings("GET_USER_NUMBER")) 56 | 57 | try: 58 | for rec in self.receivers: 59 | ctx = self.context.copy() 60 | ctx["user"] = rec 61 | number = get_user_number(rec) 62 | if not number: 63 | logger.warning(f"Can't find a number for {rec}, ignoring.") 64 | 65 | if self.final_message: 66 | sms_content = self.final_message 67 | else: 68 | sms_content = render_to_string("notifier/{}/sms.txt".format(self.template), ctx) 69 | 70 | self.client_class.send(number, sms_content, **self.sms_class_options) 71 | except: 72 | logger.error(traceback.format_exc()) 73 | -------------------------------------------------------------------------------- /magic_notifier/tasks.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | import logging 4 | import os 5 | import traceback 6 | from urllib.parse import quote 7 | 8 | import requests 9 | from celery import shared_task 10 | from django.conf import settings 11 | from django.shortcuts import reverse 12 | -------------------------------------------------------------------------------- /magic_notifier/telegram_clients/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jefcolbi/django-magic-notifier/4a6cf469dd2e8720b7664d5709faff8c1606f2b2/magic_notifier/telegram_clients/__init__.py -------------------------------------------------------------------------------- /magic_notifier/telegram_clients/telethon.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from telethon import TelegramClient 4 | from telethon.tl.functions.contacts import ImportContactsRequest 5 | from telethon.tl.types import InputPhoneContact 6 | from django.conf import settings 7 | import time 8 | 9 | 10 | class TelethonClient: 11 | 12 | running_clients = {} 13 | 14 | @classmethod 15 | def get_client(cls, gateway:str, **kwargs) -> TelegramClient: 16 | if gateway in cls.running_clients: 17 | return cls.running_clients[gateway] 18 | 19 | api_id = kwargs["API_ID"] 20 | api_hash = kwargs["API_HASH"] 21 | 22 | client = TelegramClient(gateway, api_id, api_hash) 23 | client.start() 24 | cls.running_clients[gateway] = client 25 | return client 26 | @classmethod 27 | def send(cls, number:str, first_name:str, last_name:str, text:str, 28 | gateway:str, **kwargs): 29 | try: 30 | loop = asyncio.get_event_loop() 31 | except RuntimeError as e: 32 | if 'There is no current event loop' in str(e): 33 | loop = asyncio.new_event_loop() 34 | asyncio.set_event_loop(loop) 35 | else: 36 | raise 37 | 38 | client = cls.get_client(gateway, **kwargs) 39 | client.loop.run_until_complete(cls.async_send(client, number, first_name, 40 | last_name, text)) 41 | 42 | 43 | @classmethod 44 | async def async_send(cls, client, number:str, first_name:str, last_name:str, text:str): 45 | contact = InputPhoneContact(client_id=0, phone=number, 46 | first_name=first_name, last_name=last_name) 47 | result = await client(ImportContactsRequest([contact])) 48 | print(result) 49 | result = await client.send_message(number, text) 50 | print(result) 51 | -------------------------------------------------------------------------------- /magic_notifier/telegramer.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import logging 3 | import traceback 4 | from threading import Thread 5 | from typing import Optional 6 | 7 | from django.conf import settings 8 | from django.template import TemplateDoesNotExist 9 | from django.template.loader import render_to_string 10 | 11 | from magic_notifier.utils import get_settings, import_attribute 12 | 13 | logger = logging.getLogger("notifier") 14 | 15 | 16 | class Telegramer: 17 | 18 | def __init__(self, receivers: list, context: dict, template: Optional[str] = None, 19 | final_message: Optional[str] = None, telegram_gateway: Optional[str] = None, **kwargs): 20 | """This class is reponsible of sending a notification via telegram. 21 | 22 | :param receivers: list of User 23 | :param template: the name of the template to user. Default None 24 | :param context: the context to be passed to template. Default None 25 | :param final_message: the final message to be sent as the notification content, must be sent if template is None, template is ignored if it is sent. Default None 26 | :param telegram_gateway: the telegram gateway to use. Default to None 27 | :param kwargs: 28 | """ 29 | self.receivers: list = receivers 30 | self.template: Optional[str] = template 31 | self.context: dict = context 32 | self.threaded: bool = kwargs.get("threaded", False) 33 | self.final_message: Optional[str] = final_message 34 | # get the default telegram gateway 35 | self.telegram_gateway = get_settings('TELEGRAM::DEFAULT_GATEWAY') if telegram_gateway is None else telegram_gateway 36 | # get the telegram gateway definition 37 | NOTIFIER_TELEGRAM_GATEWAY = get_settings('TELEGRAM')["GATEWAYS"][self.telegram_gateway] 38 | # get the telegram client to be used 39 | NOTIFIER_TELEGRAM_CLIENT = NOTIFIER_TELEGRAM_GATEWAY.get('CLIENT', 40 | 'magic_notifier.telegram_clients.telethon.TelethonClient') 41 | # load the telegram client 42 | module_name, class_name = NOTIFIER_TELEGRAM_CLIENT.rsplit(".", 1) 43 | module = importlib.import_module(module_name) 44 | assert hasattr(module, class_name), "class {} is not in {}".format(class_name, module_name) 45 | self.client_class = getattr(module, class_name) 46 | self.telegram_class_options = NOTIFIER_TELEGRAM_GATEWAY 47 | 48 | def send(self): 49 | if self.threaded: 50 | t = Thread(target=self._send) 51 | t.setDaemon(True) 52 | t.start() 53 | else: 54 | self._send() 55 | 56 | def _send(self): 57 | get_user_number = import_attribute(get_settings("GET_USER_NUMBER")) 58 | 59 | try: 60 | for rec in self.receivers: 61 | ctx = self.context.copy() 62 | ctx["user"] = rec 63 | number = get_user_number(rec) 64 | if not number: 65 | logger.warning(f"Can't find a number for {rec}, ignoring.") 66 | 67 | if self.final_message: 68 | telegram_content = self.final_message 69 | else: 70 | try: 71 | telegram_content = render_to_string("notifier/{}/telegram.txt".format(self.template), ctx) 72 | except TemplateDoesNotExist: 73 | telegram_content = render_to_string("notifier/{}/sms.txt".format(self.template), ctx) 74 | 75 | self.client_class.send(number, rec.first_name, rec.last_name, 76 | telegram_content, self.telegram_gateway, **self.telegram_class_options) 77 | except: 78 | logger.error(traceback.format_exc()) 79 | -------------------------------------------------------------------------------- /magic_notifier/templates/base_notifier/email.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 46 | 56 | 61 | 62 | 63 | 66 | 67 | 80 | 91 | 93 | 94 | 95 | 96 |
97 | 98 |
99 | 100 | 101 | 102 | 125 | 126 | 127 |
103 | 104 |
105 | 106 | 107 | 108 | 111 | 112 | 113 | 119 | 120 | 121 |
109 |
{% block product_name %}{% trans "Product Name" %}{% endblock %}
110 |
114 |

115 |

116 | 118 |
122 |
123 | 124 |
128 |
129 | 130 |
131 | 132 | 133 | 134 | 149 | 150 | 151 |
135 | 136 |
137 | 138 | 139 | 140 | 143 | 144 | 145 |
141 |
{% block h1_title %}{% trans "H1 title" %}{% endblock %}
142 |
146 |
147 | 148 |
152 |
153 | 154 |
155 | 156 | 157 | 158 | 171 | 172 | 173 |
159 | 160 |
161 | 162 | 163 | {% block content %} 164 | 165 | {% endblock %} 166 | 167 |
168 |
169 | 170 |
174 |
175 | 176 |
177 | 178 | 179 | 180 | 219 | 220 | 221 |
181 | 182 |
183 | 184 | 185 | 186 | 189 | 190 | 191 |
187 |
{% block footer_unsuscribe %}{% trans "Unsuscribe here" %}{% endblock %}
188 |
192 |
193 | 194 |
195 | 196 | 197 | 198 | 201 | 202 | 203 |
199 |
{% block footer_product_name %}Product Name{% endblock %}
200 |
204 |
205 | 206 |
207 | 208 | 209 | 210 | 213 | 214 | 215 |
211 |
{% block footer_company_name %}Company Name{% endblock %}
212 |
216 |
217 | 218 |
222 |
223 | 224 |
225 | 226 | 227 | 228 | -------------------------------------------------------------------------------- /magic_notifier/templates/base_notifier/email.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% block content %} 3 | {% endblock %} 4 | 5 | {% block footer_unsuscribe %}{% trans "If you do not wish to receive any further emails from us, please" %} {% trans "Unsubscribe" %}{% endblock %} 6 | -------------------------------------------------------------------------------- /magic_notifier/templates/base_notifier/push.json: -------------------------------------------------------------------------------- 1 | { 2 | "subject": "{% block subject %}{{ subject }}{% endblock %}", 3 | "text": "{% block text %}{{ text }}{% endblock %}", 4 | "type": "{% block type %}{{ type|default:'default' }}{% endblock %}", 5 | "sub_type": "{% block sub_type %}{{ sub_type }}{% endblock %}", 6 | "link": "{% block link %}{{ link|default:'http://blank' }}{% endblock %}", 7 | "image": "{% block image %}{{ image }}{% endblock %}", 8 | "icon": "{% block icon %}{{ icon }}{% endblock %}", 9 | "mode": "{% block mode %}{{ mode }}{% endblock %}", 10 | "actions": [{% block actions %} 11 | {% for action in actions %} 12 | {"text": "{{ action.text}}", 13 | "method": "{{ action.method }}", 14 | "url": "{{ action.url }}", 15 | "icon": "{{ action.icon|default_if_none:'' }}" 16 | }{% if not forloop.last %},{% endif %} 17 | {% endfor %} 18 | {% endblock %}], 19 | "data": {{% block data %} 20 | {% for key, value in data.items %} 21 | "{{ key }}": "{{ value }}"{% if not forloop.last %},{% endif %} 22 | {% endfor %} 23 | {% endblock %}} 24 | } 25 | -------------------------------------------------------------------------------- /magic_notifier/templates/base_notifier/sms.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% block content %} 3 | {% endblock %} 4 | -------------------------------------------------------------------------------- /magic_notifier/utils.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import logging 3 | import traceback 4 | from typing import Any, Optional, Union 5 | 6 | from django.conf import settings 7 | from django.contrib.auth import get_user_model 8 | from django.utils import timezone 9 | from datetime import datetime, timedelta 10 | 11 | from magic_notifier.models import Notification, NotifyProfile 12 | 13 | logger = logging.getLogger('notifier') 14 | 15 | User = get_user_model() 16 | 17 | 18 | class NotificationBuilder: 19 | def __init__(self, subject): 20 | self.__text = None 21 | self.__subject = subject 22 | self.__user = None 23 | self.__inited_by = None 24 | self.__type = None 25 | self.__sub_type = None 26 | self.__mode = None 27 | self.__actions = [] 28 | self.__link = None 29 | self.__data = {} 30 | self.__image = None 31 | self.__is_visible = True 32 | self.__is_encrypted = False 33 | self.__expires = None 34 | 35 | def text(self, text=None): 36 | if text is None: 37 | return self.__text 38 | 39 | if not isinstance(text, str): 40 | raise ValueError("text should be a string") 41 | 42 | self.__text = text 43 | return self 44 | 45 | def subject(self, subject=None): 46 | if subject is None: 47 | return self.__subject 48 | 49 | if not isinstance(subject, str): 50 | raise ValueError("text should be a string") 51 | 52 | self.__subject = subject 53 | return self 54 | 55 | def link(self, link=None): 56 | if link is None: 57 | return self.__link 58 | 59 | if not isinstance(link, str): 60 | raise ValueError("link should be a string") 61 | 62 | self.__link = link 63 | return self 64 | 65 | def mode(self, mode=None): 66 | if mode is None: 67 | return self.__mode 68 | 69 | if not isinstance(mode, str): 70 | raise ValueError("mode should be a string") 71 | 72 | self.__mode = mode 73 | return self 74 | 75 | def type(self, type: str = None, sub_stype: str = None): 76 | if type is None: 77 | return self.__type, self.__sub_type 78 | 79 | self.__type = type 80 | self.__sub_type = sub_stype 81 | return self 82 | 83 | def action(self, index: int = None, text=None, method: str = None, 84 | url: str = None, params: dict = None, json_data: dict = None): 85 | if index is not None: 86 | return self.__actions[index] 87 | 88 | action = {"method": method, "url": url, "text": text, "params": params, 89 | "json_data": json_data} 90 | self.__actions.append(action) 91 | return self 92 | 93 | def actions(self, actions: list = None): 94 | if actions is None: 95 | return self.__actions 96 | 97 | if not isinstance(actions, list): 98 | raise ValueError("actions should be a list") 99 | 100 | self.__actions.extend(actions) 101 | return self 102 | 103 | def user(self, user: User = None): 104 | if not user: 105 | return self.__user 106 | 107 | if isinstance(user, User): 108 | self.__user = user 109 | else: 110 | raise ValueError("Sender should be User or Reach instance") 111 | 112 | return self 113 | 114 | def inited_by(self, inited_by: User = None): 115 | if not inited_by: 116 | return self.__inited_by 117 | 118 | if isinstance(inited_by, User): 119 | self.__inited_by = inited_by 120 | else: 121 | raise ValueError("Sender should be User or Reach instance") 122 | 123 | return self 124 | 125 | def data(self, data: dict = None): 126 | if data is None: 127 | return self.__data 128 | 129 | if isinstance(data, dict): 130 | self.__data.update(data) 131 | return self 132 | 133 | raise ValueError("data should be a dict") 134 | 135 | def image(self, image=None): 136 | if image is None: 137 | return self.__image 138 | 139 | self.__image = image 140 | return self 141 | 142 | def is_encrypted(self, is_encrypted: bool = None): 143 | if is_encrypted is None: 144 | return self.__is_encrypted 145 | 146 | self.__is_encrypted = is_encrypted 147 | return self 148 | 149 | def is_visible(self, is_visible: bool = None): 150 | if is_visible is None: 151 | return self.__is_visible 152 | 153 | self.__is_visible = is_visible 154 | return self 155 | 156 | def expires(self, expiry_dt: Union[datetime, timedelta] = None): 157 | if expiry_dt is None: 158 | return self.__expires 159 | 160 | if not isinstance(expiry_dt, (datetime, timedelta)): 161 | raise ValueError("expiry_dt should be a datetime or timedelta") 162 | 163 | if isinstance(expiry_dt, datetime): 164 | self.__expires = expiry_dt 165 | else: 166 | self.__expires = timezone.now() + expiry_dt 167 | 168 | return self 169 | 170 | def save(self): 171 | return Notification.objects.create( 172 | subject=self.__subject, 173 | text=self.__text, 174 | link=self.__link, 175 | user=self.__user, 176 | inited_by=self.__inited_by, 177 | data=self.__data, 178 | actions=self.__actions, 179 | image=self.__image, 180 | type=self.__type, 181 | sub_type=self.__sub_type, 182 | is_encrypted=self.__is_encrypted, 183 | is_visible=self.__is_visible, 184 | expires=self.__expires 185 | ) 186 | 187 | def show(self): 188 | return ( 189 | f"(text={self.__text}, link={self.__link}, user={self.__user}, " 190 | f"type={self.__type}, sub_stype={self.__sub_type}, mode={self.__mode}, " 191 | f"data={self.__data}, actions={self.__actions}, image={self.__image}, " 192 | f"is_encrypted={self.__is_encrypted}, is_visible={self.__is_visible}, " 193 | f"expires={self.__expires})" 194 | ) 195 | 196 | 197 | def import_attribute(class_path:str) -> Any: 198 | module_name, class_name = class_path.rsplit(".", 1) 199 | module = importlib.import_module(module_name) 200 | assert hasattr(module, class_name), "class {} is not in {}".format(class_name, module_name) 201 | logger.debug('reading class {} from module {}'.format(class_name, module_name)) 202 | attribute = getattr(module, class_name) 203 | return attribute 204 | 205 | 206 | def get_user_number(user:User) -> Optional[str]: 207 | not_profile:NotifyProfile = NotifyProfile.objects.filter(user=user).first() 208 | if not_profile: 209 | return not_profile.phone_number # type: ignore 210 | 211 | return None 212 | 213 | 214 | def get_settings(name:str) -> Any: 215 | res = settings.NOTIFIER 216 | for key in name.split('::'): 217 | res = res[key] 218 | return res 219 | 220 | 221 | def get_user_from_ws_token(token: str) -> User: 222 | from rest_framework.authtoken.models import Token 223 | return Token.objects.get(key=token).user 224 | 225 | def get_fcm_token_from_user(user: User) -> list: 226 | return [] 227 | -------------------------------------------------------------------------------- /magic_notifier/views.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /magic_notifier/whatsapp_clients/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jefcolbi/django-magic-notifier/4a6cf469dd2e8720b7664d5709faff8c1606f2b2/magic_notifier/whatsapp_clients/__init__.py -------------------------------------------------------------------------------- /magic_notifier/whatsapp_clients/waha_client.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from django.conf import settings 3 | import logging 4 | import time 5 | from random import randint 6 | 7 | logger = logging.getLogger("notifier") 8 | 9 | class WahaClient: 10 | 11 | @classmethod 12 | def send(cls, number: str, text: str, **kwargs): 13 | session = requests.Session() 14 | 15 | wa_number = number.replace('+', '') + "@c.us" 16 | 17 | base_url = kwargs["BASE_URL"] 18 | url_check_number = f"{base_url}/api/contacts/check-exists" 19 | url_send_typing = f"{base_url}/api/startTyping" 20 | url_stop_typing = f"{base_url}/api/stopTyping" 21 | url_send_message = f"{base_url}/api/sendText" 22 | 23 | logger.info(f"Checking if {wa_number} exists") 24 | resp = session.get(url_check_number, params={'phone': wa_number, 'session': 'default'}) 25 | logger.info(f"{resp.content = }") 26 | res = resp.json() 27 | if not res['numberExists']: 28 | logger.info(f"Number {number} doesn't exist aborting") 29 | return 30 | 31 | wa_number = res['chatId'] 32 | 33 | logger.info(f"Start typing to {wa_number}") 34 | resp = session.post(url_send_typing, json={'chatId': wa_number, 'session': 'default'}) 35 | logger.info(f"{resp.content = }") 36 | time.sleep(randint(5, 10)) 37 | 38 | logger.info(f"Stop typing to {wa_number}") 39 | resp = session.post(url_stop_typing, json={'chatId': wa_number, 'session': 'default'}) 40 | logger.info(f"{resp.content = }") 41 | 42 | logger.info(f"Send message to {wa_number}") 43 | resp = session.post(url_send_message, json={'chatId': wa_number, 'session': 'default', 44 | 'text': text}) 45 | logger.info(f"{resp.content = }") 46 | -------------------------------------------------------------------------------- /magic_notifier/whatsapper.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import logging 3 | import traceback 4 | from threading import Thread 5 | from typing import Optional 6 | 7 | from django.conf import settings 8 | from django.template import TemplateDoesNotExist 9 | from django.template.loader import render_to_string 10 | 11 | from magic_notifier.utils import get_settings, import_attribute 12 | 13 | logger = logging.getLogger("notifier") 14 | 15 | 16 | class Whatsapper: 17 | 18 | def __init__(self, receivers: list, context: dict, template: Optional[str] = None, 19 | final_message: Optional[str] = None, whatsapp_gateway: Optional[str] = None, **kwargs): 20 | """This class is reponsible of sending a notification via whatsapp. 21 | 22 | :param receivers: list of User 23 | :param template: the name of the template to user. Default None 24 | :param context: the context to be passed to template. Default None 25 | :param final_message: the final message to be sent as the notification content, must be sent if template is None, template is ignored if it is sent. Default None 26 | :param whatsapp_gateway: the whatsapp gateway to use. Default to None 27 | :param kwargs: 28 | """ 29 | self.receivers: list = receivers 30 | self.template: Optional[str] = template 31 | self.context: dict = context 32 | self.threaded: bool = kwargs.get("threaded", False) 33 | self.final_message: Optional[str] = final_message 34 | # get the default whatsapp gateway 35 | self.whatsapp_gateway = get_settings('WHATSAPP::DEFAULT_GATEWAY') if whatsapp_gateway is None else whatsapp_gateway 36 | # get the whatsapp gateway definition 37 | NOTIFIER_WHATSAPP_GATEWAY = get_settings('WHATSAPP')["GATEWAYS"][self.whatsapp_gateway] 38 | # get the whatsapp client to be used 39 | NOTIFIER_WHATSAPP_CLIENT = NOTIFIER_WHATSAPP_GATEWAY.get('CLIENT', 40 | 'magic_notifier.whatsapp_clients.waha_client.WahaClient') 41 | # load the whatsapp client 42 | module_name, class_name = NOTIFIER_WHATSAPP_CLIENT.rsplit(".", 1) 43 | module = importlib.import_module(module_name) 44 | assert hasattr(module, class_name), "class {} is not in {}".format(class_name, module_name) 45 | self.client_class = getattr(module, class_name) 46 | self.whatsapp_class_options = NOTIFIER_WHATSAPP_GATEWAY 47 | 48 | def send(self): 49 | if self.threaded: 50 | t = Thread(target=self._send) 51 | t.setDaemon(True) 52 | t.start() 53 | else: 54 | self._send() 55 | 56 | def _send(self): 57 | get_user_number = import_attribute(get_settings("GET_USER_NUMBER")) 58 | 59 | try: 60 | for rec in self.receivers: 61 | ctx = self.context.copy() 62 | ctx["user"] = rec 63 | number = get_user_number(rec) 64 | if not number: 65 | logger.warning(f"Can't find a number for {rec}, ignoring.") 66 | 67 | if self.final_message: 68 | whatsapp_content = self.final_message 69 | else: 70 | try: 71 | whatsapp_content = render_to_string("notifier/{}/whatsapp.txt".format(self.template), ctx) 72 | except TemplateDoesNotExist: 73 | whatsapp_content = render_to_string("notifier/{}/sms.txt".format(self.template), ctx) 74 | 75 | self.client_class.send(number, whatsapp_content, **self.whatsapp_class_options) 76 | except: 77 | logger.error(traceback.format_exc()) 78 | -------------------------------------------------------------------------------- /requirements/base.txt: -------------------------------------------------------------------------------- 1 | Django>=2.0 2 | djangorestframework 3 | pillow 4 | -------------------------------------------------------------------------------- /requirements/deploy.txt: -------------------------------------------------------------------------------- 1 | wheel 2 | zest.releaser -------------------------------------------------------------------------------- /requirements/docs.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinx-rtd-theme 3 | sphinx-autoapi 4 | -------------------------------------------------------------------------------- /requirements/test.txt: -------------------------------------------------------------------------------- 1 | twilio 2 | isort 3 | coveralls 4 | channels 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Note: To use the 'upload' functionality of this file, you must: 5 | # $ pipenv install twine --dev 6 | 7 | import io 8 | import os 9 | import sys 10 | from shutil import rmtree 11 | 12 | from setuptools import find_packages, setup, Command 13 | 14 | # Package meta-data. 15 | NAME = 'django-magic-notifier' 16 | DESCRIPTION = 'A notifications library for Djangonauts that support email, sms and push.' 17 | URL = 'https://github.com/jefcolbi/django-magic-notifier' 18 | EMAIL = 'jefcolbi@gmail.com' 19 | AUTHOR = 'jefcolbi' 20 | REQUIRES_PYTHON = '>=3.6.0' 21 | VERSION = '0.3.8' 22 | 23 | 24 | # What packages are required for this module to be executed? 25 | REQUIRED = [ 26 | 'django>=2.2', 27 | 'djangorestframework', 28 | 'requests', 29 | 'mjml-python' 30 | ] 31 | 32 | # What packages are optional? 33 | EXTRAS = { 34 | 'twilio': ['twilio'], 35 | 'push': ['channels', 'channels-redis'], 36 | 'telegram': ['telethon'], 37 | 'fcm': ['pyfcm'], 38 | 'amazon_ses': ['django-ses'] 39 | } 40 | 41 | # The rest you shouldn't have to touch too much :) 42 | # ------------------------------------------------ 43 | # Except, perhaps the License and Trove Classifiers! 44 | # If you do change the License, remember to change the Trove Classifier for that! 45 | 46 | here = os.path.abspath(os.path.dirname(__file__)) 47 | 48 | # Import the README and use it as the long-description. 49 | # Note: this will only work if 'README.md' is present in your MANIFEST.in file! 50 | try: 51 | with io.open(os.path.join(here, 'README.md'), encoding='utf-8') as f: 52 | long_description = '\n' + f.read() 53 | except FileNotFoundError: 54 | long_description = DESCRIPTION 55 | 56 | # Load the package's __version__.py module as a dictionary. 57 | about = {} 58 | if not VERSION: 59 | project_slug = NAME.lower().replace("-", "_").replace(" ", "_") 60 | with open(os.path.join(here, project_slug, '__version__.py')) as f: 61 | exec(f.read(), about) 62 | else: 63 | about['__version__'] = VERSION 64 | 65 | 66 | class UploadCommand(Command): 67 | """Support setup.py upload.""" 68 | 69 | description = 'Build and publish the package.' 70 | user_options = [] 71 | 72 | @staticmethod 73 | def status(s): 74 | """Prints things in bold.""" 75 | print('\033[1m{0}\033[0m'.format(s)) 76 | 77 | def initialize_options(self): 78 | pass 79 | 80 | def finalize_options(self): 81 | pass 82 | 83 | def run(self): 84 | try: 85 | self.status('Removing previous builds…') 86 | rmtree(os.path.join(here, 'dist')) 87 | except OSError: 88 | pass 89 | 90 | self.status('Building Source and Wheel (universal) distribution…') 91 | os.system('{0} setup.py sdist bdist_wheel --universal'.format(sys.executable)) 92 | 93 | self.status('Uploading the package to PyPI via Twine…') 94 | os.system('twine upload dist/*') 95 | 96 | self.status('Pushing git tags…') 97 | os.system('git tag v{0}'.format(about['__version__'])) 98 | os.system('git push --tags') 99 | 100 | sys.exit() 101 | 102 | 103 | # Where the magic happens: 104 | setup( 105 | name=NAME, 106 | version=about['__version__'], 107 | description=DESCRIPTION, 108 | long_description=long_description, 109 | long_description_content_type='text/markdown', 110 | author=AUTHOR, 111 | author_email=EMAIL, 112 | python_requires=REQUIRES_PYTHON, 113 | url=URL, 114 | packages=find_packages(exclude=["tests", "*.tests", "*.tests.*", "tests.*"]), 115 | # If your package is a single module, use this instead of 'packages': 116 | # py_modules=['mypackage'], 117 | 118 | # entry_points={ 119 | # 'console_scripts': ['mycli=mymodule:cli'], 120 | # }, 121 | install_requires=REQUIRED, 122 | extras_require=EXTRAS, 123 | setup_requires=[], 124 | include_package_data=True, 125 | license='MIT', 126 | classifiers=[ 127 | # Trove classifiers 128 | # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers 129 | 'Natural Language :: English', 130 | 'Natural Language :: French', 131 | 'Development Status :: 5 - Production/Stable', 132 | 'Programming Language :: Python', 133 | 'Programming Language :: Python :: 3.6', 134 | 'Programming Language :: Python :: 3.7', 135 | 'Programming Language :: Python :: 3.8', 136 | 'Programming Language :: Python :: 3.9', 137 | 'Programming Language :: Python :: 3.10', 138 | 'Programming Language :: Python :: 3.11', 139 | 'Programming Language :: Python :: 3.12', 140 | 'Programming Language :: Python :: 3.13', 141 | 'Framework :: Django :: 2.2', 142 | 'Framework :: Django :: 3.0', 143 | 'Framework :: Django :: 3.1', 144 | 'Framework :: Django :: 3.2', 145 | 'Framework :: Django :: 4.0', 146 | 'Framework :: Django :: 4.1', 147 | 'Framework :: Django :: 4.2', 148 | 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', 149 | ], 150 | # $ setup.py publish support. 151 | cmdclass={ 152 | 'upload': UploadCommand, 153 | }, 154 | ) 155 | -------------------------------------------------------------------------------- /tests/connect.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import websockets 3 | import json 4 | 5 | async def hello(): 6 | async with websockets.connect("ws://localhost:8000/ws/notifications/72dcdc3dd3a292d1846ad30f88befcb6b7691edd/") as websocket: 7 | data = await websocket.recv() 8 | print(data) 9 | notif = json.loads(data) 10 | await websocket.send(json.dumps({'type':'markread', 'notification': notif['id']})) 11 | await websocket.recv() 12 | 13 | all_notifs = await websocket.send(json.dumps({'type': 'unread'})) 14 | print(await websocket.recv()) 15 | 16 | asyncio.run(hello()) -------------------------------------------------------------------------------- /tests/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jefcolbi/django-magic-notifier/4a6cf469dd2e8720b7664d5709faff8c1606f2b2/tests/core/__init__.py -------------------------------------------------------------------------------- /tests/core/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /tests/core/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CoreConfig(AppConfig): 5 | name = 'core' 6 | -------------------------------------------------------------------------------- /tests/core/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jefcolbi/django-magic-notifier/4a6cf469dd2e8720b7664d5709faff8c1606f2b2/tests/core/migrations/__init__.py -------------------------------------------------------------------------------- /tests/core/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /tests/core/templates/notifier/base/email.html: -------------------------------------------------------------------------------- 1 | {% extends "base_notifier/email.html" %} 2 | -------------------------------------------------------------------------------- /tests/core/templates/notifier/base/email.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Hello World 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /tests/core/templates/notifier/base/email.txt: -------------------------------------------------------------------------------- 1 | {% extends "base_notifier/email.txt" %} 2 | -------------------------------------------------------------------------------- /tests/core/templates/notifier/base/push.json: -------------------------------------------------------------------------------- 1 | { 2 | "subject": "{% block subject %}{{ subject }}{% endblock %}", 3 | "text": "{% block text %}{{ text }}{% endblock %}", 4 | "type": "{% block type %}{{ type|default:'notification' }}{% endblock %}", 5 | "sub_type": "{% block sub_type %}{{ sub_type }}{% endblock %}", 6 | "link": "{% block link %}{{ link|default:'http://blank' }}{% endblock %}", 7 | "image": "{% block image %}{{ image }}{% endblock %}", 8 | "icon": "{% block icon %}{{ icon }}{% endblock %}", 9 | "mode": "{% block mode %}{{ mode }}{% endblock %}", 10 | "actions": [{% block actions %} 11 | {% for action in actions %} 12 | {"text": "{{ action.text}}", 13 | "method": "{{ action.method }}", 14 | "url": "{{ action.url }}", 15 | "icon": "{{ action.icon|default_if_none:'' }}" 16 | }{% if not forloop.last %},{% endif %} 17 | {% endfor %} 18 | {% endblock %}], 19 | "data": {{% block data %} 20 | {% for key, value in data.items %} 21 | "{{ key }}": "{{ value }}"{% if not forloop.last %},{% endif %} 22 | {% endfor %} 23 | {% endblock %}} 24 | } 25 | -------------------------------------------------------------------------------- /tests/core/templates/notifier/base/sms.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% block content %} 3 | {% endblock %} 4 | 5 | Yaknema - {% trans "The best E-Commerce platform" %} 6 | -------------------------------------------------------------------------------- /tests/core/templates/notifier/hello/email.txt: -------------------------------------------------------------------------------- 1 | {% extends "notifier/base/email.txt" %} 2 | 3 | {% block content %} 4 | Hello 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /tests/core/templates/notifier/testfcm/push.json: -------------------------------------------------------------------------------- 1 | { 2 | "subject": "Consult receivev", 3 | "text": "", 4 | "type": "consult", 5 | "sub_type": "asked", 6 | "link": "", 7 | "image": "{% block image %}{{ image }}{% endblock %}", 8 | "icon": "{% block icon %}{{ icon }}{% endblock %}", 9 | "mode": "{% block mode %}{{ mode }}{% endblock %}", 10 | "actions": [], 11 | "data": { 12 | "consult_id": "10" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/core/tests.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import time 4 | from pathlib import Path 5 | from unittest import mock 6 | from unittest.mock import patch 7 | 8 | import requests 9 | from django.contrib.auth import get_user_model 10 | from django.core import mail 11 | from django.core.management import call_command 12 | from django.template.loader import render_to_string 13 | from django.test import TestCase, override_settings, LiveServerTestCase 14 | 15 | from magic_notifier.models import NotifyProfile, Notification 16 | from magic_notifier.notifier import notify 17 | from magic_notifier.pusher import Pusher 18 | from magic_notifier.telegramer import Telegramer 19 | from magic_notifier.telegram_clients.telethon import TelethonClient 20 | from magic_notifier.utils import NotificationBuilder 21 | from magic_notifier.serializers import NotificationSerializer 22 | from magic_notifier.whatsapp_clients.waha_client import WahaClient 23 | from magic_notifier.whatsapper import Whatsapper 24 | User = get_user_model() 25 | 26 | 27 | class EmailTestCase(TestCase): 28 | """Class to test emails sending""" 29 | 30 | @classmethod 31 | def setUpClass(cls): 32 | User.objects.create(username="user1", email="user1@localhost") 33 | User.objects.create(username="user2", email="user2@localhost") 34 | User.objects.create(username="user3", email="user3@localhost") 35 | 36 | User.objects.create(username="user4", email="user4@localhost", is_staff=True) 37 | User.objects.create(username="user5", email="user5@localhost", is_staff=True) 38 | 39 | User.objects.create(username="user6", email="user6@localhost", 40 | is_superuser=True, is_staff=True) 41 | return super().setUpClass() 42 | 43 | @classmethod 44 | def tearDownClass(cls): 45 | User.objects.all().delete() 46 | return super().tearDownClass() 47 | 48 | def test_simple_with_user(self): 49 | user = User(email="testuser@localhost", username="testuser") 50 | 51 | subject = "Test magic notifier" 52 | notify(["email"], subject, [user], final_message="Nice if you get this") 53 | 54 | self.assertGreater(len(mail.outbox), 0) # type: ignore 55 | first_message = mail.outbox[0] # type: ignore 56 | self.assertEqual(first_message.to, [user.email]) 57 | self.assertEqual(first_message.subject, subject) 58 | 59 | def test_simple_with_user_threaded(self): 60 | user = User(email="testuser@localhost", username="testuser") 61 | 62 | subject = "Test magic notifier" 63 | notify(["email"], subject, [user], final_message="Nice if you get this", 64 | threaded=True) 65 | time.sleep(2) 66 | 67 | self.assertGreater(len(mail.outbox), 0) # type: ignore 68 | first_message = mail.outbox[0] # type: ignore 69 | self.assertEqual(first_message.to, [user.email]) 70 | self.assertEqual(first_message.subject, subject) 71 | 72 | def test_simple_direct_email(self): 73 | subject = "Test magic notifier" 74 | notify(["email"], subject, ["testuser@localhost"], final_message="Nice if you get this") 75 | 76 | self.assertGreater(len(mail.outbox), 0) # type: ignore 77 | first_message = mail.outbox[0] # type: ignore 78 | self.assertEqual(first_message.to, ["testuser@localhost"]) 79 | self.assertEqual(first_message.subject, subject) 80 | 81 | def test_template_html_txt_with_user(self): 82 | user = User(email="testuser@localhost", username="testuser") 83 | 84 | subject = "Test magic notifier" 85 | notify(["email"], subject, [user], template='base') 86 | 87 | self.assertGreater(len(mail.outbox), 0) # type: ignore 88 | first_message = mail.outbox[0] # type: ignore 89 | self.assertEqual(first_message.to, [user.email]) 90 | self.assertEqual(first_message.subject, subject) 91 | self.assertEqual(len(first_message.alternatives), 1) 92 | 93 | def test_template_txt_with_user(self): 94 | user = User(email="testuser@localhost", username="testuser") 95 | 96 | subject = "Test magic notifier" 97 | notify(["email"], subject, [user], template='hello') 98 | 99 | self.assertGreater(len(mail.outbox), 0) # type: ignore 100 | first_message = mail.outbox[0] # type: ignore 101 | self.assertEqual(first_message.to, [user.email]) 102 | self.assertEqual(first_message.subject, subject) 103 | self.assertEqual(len(first_message.alternatives), 0) 104 | 105 | def test_template_not_exist_with_user(self): 106 | user = User(email="testuser@localhost", username="testuser") 107 | 108 | subject = "Test magic notifier" 109 | notify(["email"], subject, [user], template='notexist') 110 | 111 | self.assertEqual(len(mail.outbox), 0) # type: ignore 112 | 113 | def test_command_test_email_template(self): 114 | call_command('test_email_template', 'hello', 'testuser@localhost') 115 | 116 | self.assertGreater(len(mail.outbox), 0) # type: ignore 117 | first_message = mail.outbox[0] # type: ignore 118 | self.assertEqual(first_message.to, ['testuser@localhost']) 119 | self.assertEqual(first_message.subject, 'Testing email template') 120 | self.assertEqual(len(first_message.alternatives), 0) 121 | 122 | def test_template_txt_with_user_with_files_filename(self): 123 | user = User(email="testuser@localhost", username="testuser") 124 | 125 | subject = "Test magic notifier" 126 | notify(["email"], subject, [user], template='hello', 127 | files=[str(Path(__file__).parent / "models.py")]) 128 | 129 | self.assertGreater(len(mail.outbox), 0) # type: ignore 130 | first_message = mail.outbox[0] # type: ignore 131 | self.assertEqual(first_message.to, [user.email]) 132 | self.assertEqual(first_message.subject, subject) 133 | self.assertEqual(len(first_message.alternatives), 0) 134 | self.assertGreater(len(first_message.attachments), 0) 135 | 136 | def test_template_txt_with_user_with_files_tuple_string(self): 137 | user = User(email="testuser@localhost", username="testuser") 138 | 139 | subject = "Test magic notifier" 140 | notify(["email"], subject, [user], template='hello', 141 | files=[("test.txt", "")]) 142 | 143 | self.assertGreater(len(mail.outbox), 0) # type: ignore 144 | first_message = mail.outbox[0] # type: ignore 145 | self.assertEqual(first_message.to, [user.email]) 146 | self.assertEqual(first_message.subject, subject) 147 | self.assertEqual(len(first_message.alternatives), 0) 148 | self.assertEqual(len(first_message.attachments), 0) # it failed 149 | 150 | def test_template_txt_with_user_with_files_tuple_filelike(self): 151 | user = User(email="testuser@localhost", username="testuser") 152 | 153 | subject = "Test magic notifier" 154 | with open(str(Path(__file__).parent / "models.py")) as fp: 155 | notify(["email"], subject, [user], template='hello', 156 | files=[("models.py", fp)]) 157 | 158 | self.assertGreater(len(mail.outbox), 0) # type: ignore 159 | first_message = mail.outbox[0] # type: ignore 160 | self.assertEqual(first_message.to, [user.email]) 161 | self.assertEqual(first_message.subject, subject) 162 | self.assertEqual(len(first_message.alternatives), 0) 163 | self.assertGreater(len(first_message.attachments), 0) 164 | 165 | def test_template_txt_with_user_with_files_bad(self): 166 | user = User(email="testuser@localhost", username="testuser") 167 | 168 | subject = "Test magic notifier" 169 | with open(str(Path(__file__).parent / "models.py")) as fp: 170 | notify(["email"], subject, [user], template='hello', 171 | files=[1]) 172 | 173 | self.assertGreater(len(mail.outbox), 0) # type: ignore 174 | first_message = mail.outbox[0] # type: ignore 175 | self.assertEqual(first_message.to, [user.email]) 176 | self.assertEqual(first_message.subject, subject) 177 | self.assertEqual(len(first_message.alternatives), 0) 178 | self.assertEqual(len(first_message.attachments), 0) 179 | 180 | def test_template_txt_with_user_with_files_filelike(self): 181 | user = User(email="testuser@localhost", username="testuser") 182 | 183 | subject = "Test magic notifier" 184 | with open(str(Path(__file__).parent / "models.py")) as fp: 185 | notify(["email"], subject, [user], template='hello', 186 | files=[fp]) 187 | 188 | self.assertGreater(len(mail.outbox), 0) # type: ignore 189 | first_message = mail.outbox[0] # type: ignore 190 | self.assertEqual(first_message.to, [user.email]) 191 | self.assertEqual(first_message.subject, subject) 192 | self.assertEqual(len(first_message.alternatives), 0) 193 | self.assertGreater(len(first_message.attachments), 0) 194 | 195 | def test_simple_with_string_all(self): 196 | subject = "Test magic notifier" 197 | notify(["email"], subject, "all", final_message="Nice if you get this") 198 | 199 | self.assertEqual(len(mail.outbox), 6) # type: ignore 200 | 201 | def test_simple_with_string_staff(self): 202 | subject = "Test magic notifier" 203 | notify(["email"], subject, "staff", final_message="Nice if you get this") 204 | 205 | self.assertEqual(len(mail.outbox), 3) # type: ignore 206 | 207 | def test_simple_with_string_admins(self): 208 | subject = "Test magic notifier" 209 | notify(["email"], subject, "admins", final_message="Nice if you get this") 210 | 211 | self.assertEqual(len(mail.outbox), 1) # type: ignore 212 | 213 | def test_simple_with_string_all_minus_admins(self): 214 | subject = "Test magic notifier" 215 | notify(["email"], subject, "all-admins", final_message="Nice if you get this") 216 | 217 | self.assertEqual(len(mail.outbox), 5) # type: ignore 218 | 219 | def test_simple_with_string_all_minius_staff(self): 220 | subject = "Test magic notifier" 221 | notify(["email"], subject, "all-staff", final_message="Nice if you get this") 222 | 223 | self.assertEqual(len(mail.outbox), 3) # type: ignore 224 | 225 | def test_simple_with_string_unknown(self): 226 | subject = "Test magic notifier" 227 | self.assertRaises(ValueError, notify, ["email"], subject, "unknown", final_message="Nice if you get this") 228 | 229 | def test_unknown_method(self): 230 | subject = "Test magic notifier" 231 | notify(["unknown"], subject, "all-staff", final_message="Nice if you get this") 232 | self.assertEqual(len(mail.outbox), 0) # type: ignore 233 | 234 | def test_amazon_ses(self): 235 | user = User(email=os.environ['TEST_EMAIL'], username="testuser") 236 | 237 | subject = "Test magic notifier" 238 | notify(["email"], subject, [user], final_message="Nice if you get this") 239 | 240 | self.assertGreater(len(mail.outbox), 0) # type: ignore 241 | first_message = mail.outbox[0] # type: ignore 242 | self.assertEqual(first_message.to, [user.email]) 243 | self.assertEqual(first_message.subject, subject) 244 | 245 | 246 | sms_outbox = [] 247 | 248 | class Sms: 249 | 250 | def __init__(self, number, message): 251 | self.number = number 252 | self.message = message 253 | 254 | def send_to_sms_outbox(*args, **kwargs): 255 | print(*args) 256 | try: 257 | url = args[-1] 258 | except: 259 | url = None 260 | params = kwargs.get('params') 261 | data = kwargs.get('data') 262 | if url: 263 | if url == 'https://smsvas.com/bulk/public/index.php/api/v1/sendsms': 264 | data = kwargs['json'] 265 | sms_outbox.append(Sms(data['mobiles'], data['sms'])) 266 | res = requests.Response() 267 | res.status_code = 200 268 | return res 269 | elif url == 'http://cheapglobalsms.com/api_v1': 270 | params = kwargs['params'] 271 | sms_outbox.append(Sms(params['recipients'], params['message'])) 272 | else: 273 | sms_outbox.append(Sms(data['To'], data['Body'])) 274 | from twilio.http.response import Response 275 | 276 | return mock.MagicMock(spec=Response, status_code=200, 277 | text=json.dumps({ 278 | "account_sid": "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", 279 | "api_version": "2010-04-01", 280 | "body": "Hi there", 281 | "date_created": "Thu, 30 Jul 2015 20:12:31 +0000", 282 | "date_sent": "Thu, 30 Jul 2015 20:12:33 +0000", 283 | "date_updated": "Thu, 30 Jul 2015 20:12:33 +0000", 284 | "direction": "outbound-api", 285 | "error_code": None, 286 | "error_message": None, 287 | "from": "+15017122661", 288 | "messaging_service_sid": "MGXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", 289 | "num_media": "0", 290 | "num_segments": "1", 291 | "price": None, 292 | "price_unit": None, 293 | "sid": "SMXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", 294 | "status": "sent", 295 | "subresource_uris": { 296 | "media": "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Messages/SMXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Media.json" 297 | }, 298 | "to": "+15558675310", 299 | "uri": "/2010-04-01/Accounts/ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/Messages/SMXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.json" 300 | })) 301 | 302 | 303 | def notifier_settings_for_global_cheap(*args, **kwargs): 304 | if args[0] == "SMS::DEFAULT_GATEWAY": 305 | return "CGS" 306 | elif args[0] == "SMS": 307 | return { 308 | "GATEWAYS": { 309 | "CGS": { 310 | "CLIENT": "magic_notifier.sms_clients.cgsms_client.CGSmsClient", 311 | "SUB_ACCOUNT": "sub_account", 312 | "SUB_ACCOUNT_PASSWORD": "sub_account_password" 313 | } 314 | } 315 | } 316 | elif args[0] == "GET_USER_NUMBER": 317 | return "magic_notifier.utils.get_user_number" 318 | 319 | 320 | def notifier_settings_for_nexa(*args, **kwargs): 321 | if args[0] == "SMS::DEFAULT_GATEWAY": 322 | return "NEXA" 323 | elif args[0] == "SMS": 324 | return { 325 | "GATEWAYS": { 326 | "NEXA": { 327 | "CLIENT": "magic_notifier.sms_clients.nexa_client.NexaSmsClient", 328 | "EMAIL": "sub_account", 329 | "PASSWORD": "sub_account_password", 330 | "SENDERID": "senderid" 331 | } 332 | } 333 | } 334 | elif args[0] == "GET_USER_NUMBER": 335 | return "magic_notifier.utils.get_user_number" 336 | 337 | 338 | def notifier_settings_for_twilio(*args, **kwargs): 339 | if args[0] == "SMS::DEFAULT_GATEWAY": 340 | return "TWILIO" 341 | elif args[0] == "SMS": 342 | return { 343 | "GATEWAYS": { 344 | "TWILIO": { 345 | "CLIENT": "magic_notifier.sms_clients.twilio_client.TwilioClient", 346 | "ACCOUNT": "sub_account", 347 | "TOKEN": "token", 348 | "FROM_NUMBER": "from_number" 349 | } 350 | } 351 | } 352 | elif args[0] == "GET_USER_NUMBER": 353 | return "magic_notifier.utils.get_user_number" 354 | 355 | 356 | class SmsTestCase(TestCase): 357 | 358 | @patch('magic_notifier.smser.get_settings', side_effect=notifier_settings_for_global_cheap) 359 | @patch('magic_notifier.sms_clients.cgsms_client.requests.get', side_effect=send_to_sms_outbox) 360 | def test_global_cheap_sms_client(self, mock_get_request, mock_get_settings): 361 | NOTIFIER = { 362 | "SMS":{ 363 | "GATEWAYS": { 364 | "CGS": { 365 | "CLIENT": "magic_notifier.sms_clients.cgsms_client.CGSmsClient", 366 | "SUB_ACCOUNT": "sub_account", 367 | "SUB_ACCOUNT_PASSWORD": "sub_account_password" 368 | } 369 | }, 370 | "DEFAULT_GATEWAY": "CGS" 371 | } 372 | } 373 | 374 | with self.settings(NOTIFIER=NOTIFIER): 375 | user = User.objects.create(email="testuser@localhost", username="testuser") 376 | not_profile = NotifyProfile.objects.create(phone_number="+237600000000", 377 | user=user) 378 | 379 | subject = "Test magic notifier" 380 | notify(["sms"], subject, [user], final_message="Nice if you get this") 381 | 382 | self.assertGreater(len(sms_outbox), 0) # type: ignore 383 | first_message = sms_outbox[0] # type: ignore 384 | self.assertEqual(first_message.number, not_profile.phone_number) 385 | self.assertEqual(first_message.message, "Nice if you get this") 386 | 387 | @patch('magic_notifier.smser.get_settings', side_effect=notifier_settings_for_twilio) 388 | @patch('twilio.http.http_client.TwilioHttpClient.request', side_effect=send_to_sms_outbox) 389 | def test_twilio_sms_client(self, mock_get_request, mock_get_settings): 390 | NOTIFIER = { 391 | "SMS": { 392 | "GATEWAYS": { 393 | "TWILIO": { 394 | "CLIENT": "magic_notifier.sms_clients.twilio_client.TwilioClient", 395 | "ACCOUNT": "sub_account", 396 | "TOKEN": "token", 397 | "FROM_NUMBER": "from_number" 398 | } 399 | }, 400 | "DEFAULT_GATEWAY": "TWILIO" 401 | } 402 | } 403 | 404 | with self.settings(NOTIFIER=NOTIFIER): 405 | user = User.objects.create(email="testuser@localhost", username="testuser") 406 | not_profile = NotifyProfile.objects.create(phone_number="+237600000000", 407 | user=user) 408 | 409 | subject = "Test magic notifier" 410 | notify(["sms"], subject, [user], final_message="Nice if you get this") 411 | 412 | self.assertGreater(len(sms_outbox), 0) # type: ignore 413 | first_message = sms_outbox[0] # type: ignore 414 | self.assertEqual(first_message.number, not_profile.phone_number) 415 | self.assertEqual(first_message.message, "Nice if you get this") 416 | 417 | @patch('magic_notifier.smser.get_settings', side_effect=notifier_settings_for_nexa) 418 | @patch('magic_notifier.sms_clients.cgsms_client.requests.post', side_effect=send_to_sms_outbox) 419 | def test_nexa_sms_client(self, mock_get_request, mock_get_settings): 420 | NOTIFIER = { 421 | "SMS": { 422 | "GATEWAYS": { 423 | "NEXA": { 424 | "CLIENT": "magic_notifier.sms_clients.nexa_client.NexaSmsClient", 425 | "EMAIL": "sub_account", 426 | "PASSWORD": "sub_account_password", 427 | "SENDERID": "senderid" 428 | } 429 | }, 430 | "DEFAULT_GATEWAY": "NEXA" 431 | } 432 | } 433 | 434 | with self.settings(NOTIFIER=NOTIFIER): 435 | user = User.objects.create(email="testuser@localhost", username="testuser") 436 | not_profile = NotifyProfile.objects.create(phone_number="+237600000000", 437 | user=user) 438 | 439 | subject = "Test magic notifier" 440 | notify(["sms"], subject, [user], final_message="Nice if you get this") 441 | 442 | self.assertGreater(len(sms_outbox), 0) # type: ignore 443 | first_message = sms_outbox[0] # type: ignore 444 | self.assertEqual(first_message.number, not_profile.phone_number) 445 | self.assertEqual(first_message.message, "Nice if you get this") 446 | 447 | 448 | class PushNotificationTestCase(TestCase): 449 | 450 | def test_load_json(self): 451 | ctx = { 452 | 'text': 'yes', 453 | 'actions': [ 454 | { 455 | 'text': 'wow', 456 | 'url': 'accept', 457 | 'method': 'post' 458 | }, 459 | { 460 | 'text': 'meow', 461 | 'url': 'deny', 462 | 'method': 'get' 463 | } 464 | ], 465 | 'data': { 466 | 'love': 'you', 467 | 'hate': 'no-one' 468 | } 469 | } 470 | push_content = render_to_string(f"notifier/base/push.json", ctx) 471 | print(push_content) 472 | self.assertIsInstance(json.loads(push_content), dict) 473 | 474 | def test_notification_builder_class(self): 475 | notif = NotificationBuilder("just a test").text("This is just a test").type('test', 'test_sub')\ 476 | .link("http://lol").save() 477 | self.assertIsInstance(notif, Notification) 478 | seria = NotificationSerializer(instance=notif) 479 | print(notif) 480 | print(seria.data) 481 | 482 | def test_send_push_via_fcm(self): 483 | user = User.objects.create_user('testuser') 484 | notify(['push'], "Super cool", [user], template="testfcm", remove_notification_fields=['action', 'link', 485 | 'is_visible', 'is_encrypted']) 486 | 487 | 488 | class LivePushNotificationTestCase(LiveServerTestCase): 489 | port = 8001 490 | 491 | def test_pusher_class(self): 492 | user = User.objects.create_user('testuser') 493 | NotifyProfile.objects.create(user=user) 494 | pusher = Pusher("just a test", [user], 'base', {'data': {'love': 'you', 'hate': 'no-one'}, 495 | 'actions': [{'text':'accept', 'method':'post', 'url': 'http://'}]}) 496 | notif = pusher.send() 497 | self.assertIsInstance(notif, Notification) 498 | seria = NotificationSerializer(instance=notif) 499 | print(notif) 500 | print(seria.data) 501 | 502 | 503 | class WhatsappNotificationTestCase(LiveServerTestCase): 504 | 505 | def test_waha_client(self): 506 | WahaClient.send("+237693138363", "Bonjour Jeff") 507 | 508 | def test_whatsapper(self): 509 | user = User.objects.create(email="testuser@localhost", username="testuser", 510 | first_name="Jeff", last_name="Matt") 511 | not_profile = NotifyProfile.objects.create(phone_number="+237693138363", 512 | user=user) 513 | whatsapper = Whatsapper([user], {}, 514 | final_message="Bonjour Jeff Matt. Votre code est XXXX") 515 | whatsapper.send() 516 | 517 | 518 | class TelegramNotificationTestCase(LiveServerTestCase): 519 | 520 | def test_telethon_client(self): 521 | TelethonClient.send("+237693138363", "Jeff", "Matt", 522 | "Bonjour Jeff. Voici ton code XXXX.", "default") 523 | 524 | def test_telegramer(self): 525 | user = User.objects.create(email="testuser@localhost", username="testuser", 526 | first_name="Jeff", last_name="Matt") 527 | not_profile = NotifyProfile.objects.create(phone_number="+237693138363", 528 | user=user) 529 | telegramer = Telegramer([user], {}, 530 | final_message="Bonjour Jeff Matt") 531 | telegramer.send() 532 | 533 | 534 | class AllNotificationTestCase(LiveServerTestCase): 535 | 536 | def test_01_send_to_sms_whatsapp_telegram(self): 537 | user = User.objects.create(email="testuser@localhost", username="testuser", 538 | first_name="Jeff", last_name="Matt") 539 | not_profile = NotifyProfile.objects.create(phone_number="+237698948836", 540 | user=user) 541 | notify(['sms', 'whatsapp', 'telegram'], "Code",[user], 542 | final_message="Salut Fedim Stephane. Ceci est un test d'envoi de code") 543 | -------------------------------------------------------------------------------- /tests/core/utils.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | def push_tokens_for_user(user): 4 | return ["ExponentPushToken[pjgrL3PY1hutUEar64ttSY]"] -------------------------------------------------------------------------------- /tests/core/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /tests/example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jefcolbi/django-magic-notifier/4a6cf469dd2e8720b7664d5709faff8c1606f2b2/tests/example/__init__.py -------------------------------------------------------------------------------- /tests/example/asgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from channels.auth import AuthMiddlewareStack 4 | from channels.routing import ProtocolTypeRouter, URLRouter 5 | from channels.security.websocket import AllowedHostsOriginValidator 6 | from django.core.asgi import get_asgi_application 7 | from django.urls import path 8 | 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 10 | # Initialize Django ASGI application early to ensure the AppRegistry 11 | # is populated before importing code that may import ORM models. 12 | django_asgi_app = get_asgi_application() 13 | 14 | from magic_notifier.consumers import PushNotifConsumer 15 | 16 | application = ProtocolTypeRouter({ 17 | # Django's ASGI application to handle traditional HTTP requests 18 | "http": django_asgi_app, 19 | 20 | # WebSocket chat handler 21 | "websocket": URLRouter([ 22 | path("ws/notifications//", PushNotifConsumer.as_asgi()), 23 | ]) 24 | } 25 | ) -------------------------------------------------------------------------------- /tests/example/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.2.17. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.2/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'm#*@g4n)s5zt6ug!4(16x_j613i1(ft+jkq#r8*c@74k@f^iw9' 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 | 'channels', 35 | 'django.contrib.admin', 36 | 'django.contrib.auth', 37 | 'django.contrib.contenttypes', 38 | 'django.contrib.sessions', 39 | 'django.contrib.messages', 40 | 'django.contrib.staticfiles', 41 | 'core', 42 | 'magic_notifier', 43 | 'rest_framework', 44 | 'rest_framework.authtoken', 45 | ] 46 | 47 | MIDDLEWARE = [ 48 | 'django.middleware.security.SecurityMiddleware', 49 | 'django.contrib.sessions.middleware.SessionMiddleware', 50 | 'django.middleware.common.CommonMiddleware', 51 | 'django.middleware.csrf.CsrfViewMiddleware', 52 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 53 | 'django.contrib.messages.middleware.MessageMiddleware', 54 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 55 | ] 56 | 57 | ROOT_URLCONF = 'example.urls' 58 | 59 | TEMPLATES = [ 60 | { 61 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 62 | 'DIRS': [], 63 | 'APP_DIRS': True, 64 | 'OPTIONS': { 65 | 'context_processors': [ 66 | 'django.template.context_processors.debug', 67 | 'django.template.context_processors.request', 68 | 'django.contrib.auth.context_processors.auth', 69 | 'django.contrib.messages.context_processors.messages', 70 | ], 71 | }, 72 | }, 73 | ] 74 | 75 | WSGI_APPLICATION = 'example.wsgi.application' 76 | 77 | 78 | # Database 79 | # https://docs.djangoproject.com/en/2.2/ref/settings/#databases 80 | 81 | DATABASES = { 82 | 'default': { 83 | 'ENGINE': 'django.db.backends.sqlite3', 84 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 85 | } 86 | } 87 | 88 | 89 | # Password validation 90 | # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators 91 | 92 | AUTH_PASSWORD_VALIDATORS = [ 93 | { 94 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 95 | }, 96 | { 97 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 98 | }, 99 | { 100 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 101 | }, 102 | { 103 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 104 | }, 105 | ] 106 | 107 | 108 | # Internationalization 109 | # https://docs.djangoproject.com/en/2.2/topics/i18n/ 110 | 111 | LANGUAGE_CODE = 'en-us' 112 | 113 | TIME_ZONE = 'UTC' 114 | 115 | USE_I18N = True 116 | 117 | USE_L10N = True 118 | 119 | USE_TZ = True 120 | 121 | 122 | # Static files (CSS, JavaScript, Images) 123 | # https://docs.djangoproject.com/en/2.2/howto/static-files/ 124 | 125 | STATIC_URL = '/static/' 126 | 127 | NOTIFIER = { 128 | "SMTP": { 129 | "default": { 130 | "HOST": "localhost", 131 | "PORT": 587, 132 | "USE_TLS": True, 133 | "USE_SSL": False, 134 | "USER": "root@localhost", 135 | "FROM": "Root ", 136 | "PASSWORD": "********", 137 | "CLIENT": "magic_notifier.email_clients.django_email.DjangoEmailClient", 138 | } 139 | }, 140 | "USER_FROM_WS_TOKEN_FUNCTION": 'magic_notifier.utils.get_user_from_ws_token', 141 | "WHATSAPP": { 142 | "DEFAULT_GATEWAY": "waha", 143 | "GATEWAYS": { 144 | "waha": { 145 | "BASE_URL": "http://localhost:3000" 146 | } 147 | } 148 | }, 149 | "TELEGRAM": { 150 | "DEFAULT_GATEWAY": "default", 151 | "GATEWAYS": { 152 | "default": { 153 | "API_ID": "28514867", 154 | "API_HASH": "0ebf4917ea2d32b672f30566481c6d80" 155 | } 156 | } 157 | }, 158 | "GET_USER_NUMBER": "magic_notifier.utils.get_user_number" 159 | } 160 | 161 | ASGI_APPLICATION = 'example.asgi.application' 162 | 163 | LOGS_FORMAT = ( 164 | "[%(asctime)s] %(levelname)s[%(name)s] %(message)s - %(pathname)s#lines-%(lineno)s" 165 | ) 166 | 167 | LOGGING = { 168 | "version": 1, 169 | "disable_existing_loggers": False, 170 | "formatters": { 171 | "standard": { 172 | "format": LOGS_FORMAT, 173 | "datefmt": "%d/%b/%Y %H:%M:%S", 174 | }, 175 | }, 176 | "handlers": { 177 | "default": { 178 | "level": "DEBUG" if DEBUG else "INFO", 179 | "class": "logging.StreamHandler", 180 | "formatter": "standard", 181 | }, 182 | }, 183 | "loggers": { 184 | "django": { 185 | "handlers": ["default"], 186 | "level": "ERROR", 187 | "propagate": True, 188 | }, 189 | "": { 190 | "handlers": ["default"], # Logstash disabled 191 | "level": "DEBUG", 192 | "propagate": True, 193 | }, 194 | #"django.request": {"handlers": ["default"], "level": "DEBUG"}, 195 | }, 196 | } 197 | 198 | CHANNEL_LAYERS = { 199 | "default": { 200 | "BACKEND": "channels.layers.InMemoryChannelLayer" 201 | } 202 | } -------------------------------------------------------------------------------- /tests/example/urls.py: -------------------------------------------------------------------------------- 1 | """example URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.2/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 | -------------------------------------------------------------------------------- /tests/example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example 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/2.2/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', 'example.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /tests/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 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example.settings') 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == '__main__': 21 | main() 22 | --------------------------------------------------------------------------------