├── .coveragerc ├── .github ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ └── tests.yml ├── .gitignore ├── .travis.yml ├── CHANGELOG.txt ├── LICENSE ├── MANIFEST.in ├── README.rst ├── TODO.md ├── requirements.txt ├── requirements ├── requirements-base.txt ├── requirements-codestyle.txt └── requirements-testing.txt ├── run_isort ├── runtests.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── celery.py ├── conftest.py ├── test_api.py ├── test_backends.py ├── test_base.py ├── test_data │ └── certificate.pem ├── test_emails.py ├── test_models.py ├── test_sms.py ├── test_sms_amazonsns.py ├── test_sms_twilio.py ├── test_utils.py ├── urls.py └── user_conf.py ├── tox.ini └── universal_notifications ├── __init__.py ├── admin.py ├── api.py ├── api_urls.py ├── backends ├── __init__.py ├── emails │ ├── __init__.py │ ├── models.py │ ├── urls.py │ └── views.py ├── push │ ├── __init__.py │ ├── apns.py │ ├── fcm.py │ ├── gcm.py │ └── utils.py ├── sms │ ├── __init__.py │ ├── abstract.py │ ├── base.py │ ├── engines │ │ ├── __init__.py │ │ ├── amazonsns.py │ │ └── twilio.py │ ├── signals.py │ └── utils.py ├── twilio │ ├── __init__.py │ ├── api.py │ └── fields.py └── websockets.py ├── docs.py ├── management ├── __init__.py └── commands │ ├── __init__.py │ ├── check_twilio_proxy.py │ ├── parse_pending_sms.py │ └── run_twilio_proxy.py ├── migrations ├── 0001_initial.py ├── 0002_device.py ├── 0003_auto_20170112_0609.py ├── 0004_auto_20170124_0731.py ├── 0005_auto_20170316_1814.py ├── 0006_auto_20170323_0634.py ├── 0007_auto_20170323_0649.py ├── 0008_auto_20170704_0810.py └── __init__.py ├── models.py ├── notifications.py ├── serializers.py ├── signals.py ├── tasks.py ├── templates └── emails │ ├── email_test.html │ ├── email_test_empty.html │ └── fake.html └── urls.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [paths] 2 | source = 3 | universal_notifications/ 4 | 5 | [report] 6 | omit = 7 | universal_notifications/docs.py 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 15 8 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "Code scanning - action" 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 23 * * 6' 8 | 9 | jobs: 10 | CodeQL-Build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v2 17 | with: 18 | # We must fetch at least the immediate parents so that if this is 19 | # a pull request then we can checkout the head. 20 | fetch-depth: 2 21 | 22 | # If this run was triggered by a pull request event, then checkout 23 | # the head of the pull request instead of the merge commit. 24 | - run: git checkout HEAD^2 25 | if: ${{ github.event_name == 'pull_request' }} 26 | 27 | # Initializes the CodeQL tools for scanning. 28 | - name: Initialize CodeQL 29 | uses: github/codeql-action/init@v1 30 | # Override language selection by uncommenting this and choosing your languages 31 | # with: 32 | # languages: go, javascript, csharp, python, cpp, java 33 | 34 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 35 | # If this step fails, then you should remove it and run the build manually (see below) 36 | - name: Autobuild 37 | uses: github/codeql-action/autobuild@v1 38 | 39 | # ℹ️ Command-line programs to run using the OS shell. 40 | # 📚 https://git.io/JvXDl 41 | 42 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 43 | # and modify them (or add more) to build your code if your project 44 | # uses a compiled language 45 | 46 | #- run: | 47 | # make bootstrap 48 | # make release 49 | 50 | - name: Perform CodeQL Analysis 51 | uses: github/codeql-action/analyze@v1 52 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | - pull_request 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ["3.9", "3.10", "3.11"] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install tox tox-gh-actions 23 | - name: Test with tox 24 | run: tox 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # editors 10 | *.sublime* 11 | settings.json 12 | 13 | # Distribution / packaging 14 | .Python 15 | env/ 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *,cover 50 | .hypothesis/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | manage.py 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # IPython Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | 93 | # Rope project settings 94 | .ropeproject 95 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: python 4 | 5 | python: 6 | - "3.6" 7 | - "3.7" 8 | - "3.8" 9 | - "3.9" 10 | 11 | install: 12 | - pip install tox-travis 13 | 14 | script: 15 | - tox 16 | 17 | after_success: 18 | - pip install codecov 19 | - codecov 20 | -------------------------------------------------------------------------------- /CHANGELOG.txt: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | ##[1.6.0] 5 | ### Changed 6 | - Added support for Python 3.11 and Django 4.2 7 | 8 | ##[1.5.0] 9 | ### Changed 10 | - Updated the twilio-python dependency to allow >=6.0.0 and <8.0.0 11 | 12 | ##[1.4.0] 13 | ### Removed 14 | - Removed chaining support (it was not documented) 15 | ### Added 16 | - Added support for python 3.7-3.9 17 | - Added support for Django 3.0 and 3. 18 | 19 | ## [1.3.0] - 2019-10-11 20 | ### Fixed 21 | - issue at twilio engine when receiver already exists for a given number 22 | (note: this is backward incompatibility change as we now store only the newest 23 | `PhoneReceiver` for a given number - previously trying to parse would just fail) 24 | 25 | ## [1.2.2] - 2019-10-01 26 | ### Fixed 27 | - regexp in url 28 | 29 | ## [1.2.1] - 2018-11-18 30 | ### Fixed 31 | - "coreapi" requirements 32 | 33 | ## [1.2.0] - 2018-11-18 34 | ### Added 35 | - Support for disabling premailer per email 36 | ### Changed 37 | - Owner - now ro.co 38 | - Dropped support for Python2 and 3.4-3.5 39 | 40 | ## [1.1.0] - 2018-11-13 41 | ### Added 42 | - EmailNotification.sendgrid_asm to pass unsubscribe groups to django-sendgrid-v5 43 | 44 | ## [1.0.0] - 2018-11-02 45 | ### Added 46 | - support for Django 2.0 and 2.1 47 | 48 | ## [0.17.2] - 2018-10-24 49 | ### Fixed 50 | - Discover Celery 51 | 52 | ## [0.17.1] - 2018-10-16 53 | ### Fixed 54 | - SMS notifications 55 | 56 | ## [0.17.0] - 2018-10-16 57 | ### Added 58 | - receiver object to SMS notifications' context 59 | ### Changed 60 | - SMSNotification.prepare_receivers now returns a list of receiver objects - not a list of phone numbers 61 | 62 | ## [0.16.2] - 2018-10-08 63 | ### Fixed 64 | - duplicating devices when added via API 65 | 66 | ## [0.16.1] - 2018-09-28 67 | ### Added 68 | - missing "id" field to DeviceCreateAPI 69 | 70 | ## [0.16.0] - 2018-09-27 71 | ### Added 72 | - endpoint to delete devices 73 | 74 | ## [0.15.0] - 2018-09-26 75 | ### Added 76 | - support for description in push notifications for APNS 77 | ### Changed 78 | - PushNotification.message was renamed to "title" 79 | 80 | ## [0.14.4] - 2018-09-26 81 | ### Fixed 82 | - previous release 83 | 84 | ## [0.14.3] - 2018-09-26 85 | ### Fixed 86 | - sending notifications through APNS 87 | 88 | ## [0.14.2] - 2018-09-14 89 | ### Fixed 90 | - missing receiver in email context 91 | 92 | ## [0.14.0] - 2018-09-12 93 | ### Changed 94 | - EmailNotification class is now more expandable and configurable 95 | - context param is not optional (defaults to {}) 96 | ### Added 97 | - Taking email subject from template's tags if email_subject is not provided 98 | ### Removed 99 | - universal_notifications.backends.emails.send.send_email function (moved to EmailNotification class) 100 | 101 | ## [0.13.1] - 2018-03-05 102 | ### Fixed 103 | - fixed 500 in notifications docs when serializers_class is added on __init__ to WSNotification 104 | 105 | ## [0.13.0] - 2018-03-05 106 | ### Added 107 | - support for categories in django-sendgrid-v5 108 | 109 | ## [0.12.0] - 2018-02-19 110 | ### Added 111 | - support for Django 2.0 112 | ### Removed 113 | - support for Django 1.8, 1.9, 1.10 114 | 115 | ## [0.10.2] - 2017-09-08 116 | ### Added 117 | - improved formatting of email addresses 118 | 119 | ## [0.10.1] - 2017-09-08 120 | ### Added 121 | - improved formatting of email addresses 122 | 123 | ## [0.11.0] - 2017-11-22 124 | ### Added 125 | - applying attachments to EmailNotifications 126 | 127 | ## [0.10.0] - 2017-08-29 128 | ### Added 129 | - setting to disable notification history 130 | 131 | ## [0.9.1] - 2017-08-17 132 | ### Fixed 133 | - sending SMS synchronously 134 | 135 | ## [0.9.0] - 2017-08-14 136 | ### Added 137 | - sending SMS as sync or async is now configurable 138 | 139 | ## [0.8.5] - 2017-07-04 140 | ### Added 141 | - source as generic relation to NotificationHistory 142 | 143 | ## [0.8.4] - 2017-06-19 144 | ### Fixed 145 | - workaround for a bug in Django 1.11 146 | 147 | ## [0.8.3] - 2017-06-12 148 | ### Changed 149 | - Settings to disable premailer 150 | 151 | ## [0.8.2] - 2017-05-29 152 | ### Changed 153 | - Ignore cssutils logging errors 154 | 155 | ## [0.8.1] - 2017-05-26 156 | ### Changed 157 | - Replace static CACHE files to local (fix compress) 158 | 159 | 160 | ## [0.8.0] - 2017-05-25 161 | ### Changed 162 | - moved universal_notifications.backends.emails to universal_notifications.backends.emails.send 163 | ### Added 164 | - FakeEmailSend view 165 | 166 | ## [0.7.11] - 2017-04-18 167 | ### Fixed 168 | - Frozen django-push-notification 169 | 170 | ## [0.7.10] - 2017-04-07 171 | ### Added 172 | - sender (optional) to EmailNotification 173 | 174 | ## [0.7.9] - 2017-04-05 175 | ### Added 176 | - support for Django 1.11 177 | 178 | ### Fixed 179 | - Twilio lib 6.x support 180 | 181 | ## [0.7.8] - 2017-03-25 182 | ### Fixed 183 | - context passed properly to email 184 | 185 | ## [0.7.7] - 2017-03-23 186 | ### Fixed 187 | - removed doubled sms_id field in PhoneReceiver 188 | - altered sms_id in PhoneSent to match this in PhoneReceiver 189 | 190 | ## [0.7.6] - 2017-03-23 191 | ### Fixed 192 | - number in PhoneReceiver should be unique 193 | 194 | ## [0.7.5] - 2017-03-17 195 | ### Added 196 | - Amazon SNS SMS support 197 | 198 | ## [0.7.4] - 2017-02-22 199 | ### Fixed 200 | - distribution fixed 201 | 202 | ## [0.7.3] - 2017-02-22 203 | ### Changed 204 | - WS Receiver now emits "ws_received" signal (through celery task) 205 | 206 | ## [0.7.2] - 2017-02-21 207 | ### Fixed 208 | - Twilio Celery tasks 209 | 210 | ## [0.7.1] - 2017-02-20 211 | ### Added 212 | - changelog 213 | - notifications docstrings and categories are now visible in notifications-docs 214 | 215 | ## [0.7.0] - 2017-02-14 216 | ### Added 217 | - generic unsubscription API 218 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Arabella 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | include requirements/* 4 | recursive-exclude * __pycache__ 5 | recursive-exclude * *.py[co] 6 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | UNMAINTAINED 2 | ============ 3 | 4 | **PLEASE NOTE**: this library is not actively maintained and receives 5 | only minimal updates necessary. Most of it is no longer in active use 6 | at Ro. 7 | 8 | 9 | universal\_notifications 10 | ======================== 11 | |travis|_ |pypi|_ |codecov|_ 12 | 13 | **High-level framework for notifications** 14 | 15 | This project is intended to provide a convenient way to send notifications using multiple 16 | notification backends (e.g., e-mail, SMS, push). 17 | 18 | -------------- 19 | 20 | Setting up 21 | ---------- 22 | 23 | To start using **universal\_notifications** please add ``universal_notifications`` to 24 | ``INSTALLED_APPS`` in your Django project, and then migrate the app: 25 | ``./manage.py migrate universal_notifications``. 26 | 27 | If you intend to use any other type of notification than WS, then UNIVERSAL_NOTIFICATIONS_CATEGORIES 28 | must be defined (see `Unsubscriber`_) 29 | 30 | Basic usage 31 | ----------- 32 | - `WebSocket notifications`_ 33 | - `E-mail notifications`_ 34 | - `SMS notifications`_ 35 | - `Push notifications`_ 36 | - `Unsubscriber`_ 37 | - `Unsubscriber API`_ 38 | - `FakeEmailSend view`_ 39 | - `Notification history`_ 40 | 41 | WebSocket notifications 42 | ~~~~~~~~~~~~~~~~~~~~~~~ 43 | 44 | To have Universal Notifications receive WS notifications (ie. to mark notification as received) 45 | add to your settings.py: 46 | 47 | :: 48 | 49 | WS4REDIS_SUBSCRIBER = 'universal_notifications.backends.websockets.RedisSignalSubscriber' 50 | 51 | Upon receiving a WS, "ws_received" signal will be emitted with json data received in the message, and all emails 52 | subscribed to that channel. Sample usage: 53 | 54 | .. code:: python 55 | 56 | from universal_notifications.signals import ws_received 57 | 58 | def your_handler(sender, message_data, channel_emails, **kwargs): 59 | pass 60 | ws_received.connect(your_handler) 61 | 62 | Simple example of using WS notifications: 63 | 64 | .. code:: python 65 | 66 | class OrderShippedWS(WSNotification): 67 | message = 'order_shipped' 68 | serializer_class = OrderSerializer 69 | 70 | # ... somewhere in a view 71 | OrderShippedWS(item=order, receivers=[user], context={}).send() 72 | 73 | E-mail notifications 74 | ~~~~~~~~~~~~~~~~~~~~ 75 | 76 | .. code:: python 77 | 78 | class OrderShippedEmail(EmailNotification): 79 | email_name = 'order_shipped' 80 | email_subject = _('Order no. {{item.pk}} has been shipped.') 81 | categories = ["newsletter"] 82 | sendgrid_asm = { 83 | "group_id": 1 84 | } 85 | use_premailer = False # disable Premailer for this email 86 | 87 | # ... somewhere in a view 88 | OrderShippedEmail(item=order, receivers=[user], context={}, attachments=[ 89 | ("invoice.pdf", open("invoice.pdf").read(), "application/pdf") 90 | ]).send() 91 | 92 | Attachements parameter has to be a list of `(filename, content, mime_type)` triples. 93 | **categories**, **sendgrid_asm**, **use_premailer** fields are optional, they can be used with `django-sendgrid `_ to enable metrics by category and unsubscribe groups. 94 | 95 | Email subject will be taken from the `` tags in the template if it is not set in notification class. 96 | 97 | Settings 98 | * UNIVERSAL_NOTIFICATIONS_IS_SECURE (bool, default: False) - set https protocol and `is_secure` variable 99 | * UNIVERSAL_NOTIFICATIONS_USE_PREMAILER (bool, default: True) - use premailer to append CSS styles inline (speedup tests a lot when False) 100 | 101 | 102 | SMS notifications 103 | ~~~~~~~~~~~~~~~~~ 104 | 105 | Supported platforms: 106 | * `Twilio `_ - default engine 107 | * `AmazonSNS `_ 108 | 109 | Settings 110 | * UNIVERSAL_NOTIFICATIONS_SMS_ENGINE - set engine 111 | * UNIVERSAL_NOTIFICATIONS_VALIDATE_MOBILE (bool) 112 | * UNIVERSAL_NOTIFICATIONS_SMS_SEND_IN_TASK (bool, default True) 113 | 114 | Engine settinsgs: 115 | * Twilio 116 | * UNIVERSAL_NOTIFICATIONS_TWILIO_API_ENABLED (bool) 117 | * UNIVERSAL_NOTIFICATIONS_TWILIO_ENABLE_PROXY (bool) 118 | * UNIVERSAL_NOTIFICATIONS_TWILIO_ACCOUNT (string) 119 | * UNIVERSAL_NOTIFICATIONS_TWILIO_TOKEN (string) 120 | * UNIVERSAL_NOTIFICATIONS_TWILIO_REPORT_ERRORS (list of integers) 121 | * Amazon SNS 122 | * UNIVERSAL_NOTIFICATIONS_AMAZON_SNS_API_ENABLED (bool) 123 | * AWS_ACCESS_KEY_ID (string) 124 | * AWS_SECRET_ACCESS_KEY (string) 125 | * AWS_DEFAULT_REGION (string) - default us-east-1 126 | 127 | 128 | Simple example of use: 129 | 130 | .. code:: python 131 | 132 | class OrderShippedSMS(SMSNotification): 133 | message = _('{{receiver.first_name}}, order no. {{item.pk}} has been shipped.') 134 | 135 | def prepare_receivers(self): 136 | return {x.shipping_address.phone for x in self.receivers} 137 | 138 | class SyncOrderShippedSMS(OrderShippedSMS): 139 | send_async = False # by default taken from UNIVERSAL_NOTIFICATIONS_SMS_SEND_IN_TASK 140 | 141 | # ... somewhere in a view 142 | OrderShippedSMS(item=order, receivers=[user], context={}).send( 143 | 144 | Push notifications 145 | ~~~~~~~~~~~~~~~~~~ 146 | 147 | First of all, to use push notifications, you must provide a list of available **devices** linked to users. 148 | For more information, please check out 149 | `sources `_. 150 | 151 | Supported platforms: 152 | * `FCM `_ - Android, iOS, Web 153 | * `GCM `_ - Android, iOS, Web 154 | * `APNS `_ - iOS 155 | 156 | To make push notifications work on all supported platforms, a few properties need to be set: 157 | * UNIVERSAL_NOTIFICATIONS_MOBILE_APPS[app_id] 158 | * APNS_CERTIFICATE - APNS certificate file (.pem) 159 | * FCM_API_KEY - Firebase API key 160 | * GCM_API_KEY - Google Cloud Messaging API key 161 | * GCM_POST_URL - Google Cloud Messaging post url 162 | 163 | Settings related to Apple Push Notification service: 164 | * APNS_HOST 165 | * APNS_PORT 166 | * APNS_FEEDBACK_HOST 167 | * APNS_FEEDBACK_PORT 168 | * APNS_ERROR_TIMEOUT 169 | * APNS_MAX_NOTIFICATION_SIZE 170 | 171 | Simple example of use: 172 | 173 | .. code:: python 174 | 175 | class OrderShippedPush(PushNotification): 176 | title = _('Order no. {{item.pk}} has been shipped.') 177 | description = _('This can also use {{item.pk}}') # optional 178 | 179 | # ... somewhere in a view 180 | OrderShippedPush(item=order, receivers=[user], context={}).send() 181 | 182 | .. _WebSocket notifications: #websocket-notifications 183 | .. _E-mail notifications: #e-mail-notifications 184 | .. _SMS notifications: #sms-notifications 185 | .. _Push notifications: #push-notifications 186 | .. _SMSAPI: https://github.com/smsapi/smsapi-python-client 187 | 188 | .. |travis| image:: https://secure.travis-ci.org/HealthByRo/universal_notifications.svg?branch=master 189 | .. _travis: http://travis-ci.org/HealthByRo/universal_notifications?branch=master 190 | 191 | .. |pypi| image:: https://img.shields.io/pypi/v/universal_notifications.svg 192 | .. _pypi: https://pypi.python.org/pypi/universal_notifications 193 | 194 | .. |codecov| image:: https://img.shields.io/codecov/c/github/HealthByRo/universal_notifications/master.svg 195 | .. _codecov: http://codecov.io/github/HealthByRo/universal_notifications?branch=master 196 | 197 | Unsubscriber 198 | ~~~~~~~~~~~~ 199 | 200 | This section refers to all notifications except WebSockets, which by default are not prone to unsubscriptions 201 | (however this can be changed by setting check_subscription to True). 202 | 203 | Each category for each type must be explicitly declared in config (with label). If it is not there, exception 204 | will be raised on attempt to send such notification. This requirement is to prevent situation, that notification 205 | of given type is send to user who would not wish to receive it, but cannot unsubscribe from it (since it is not 206 | present in the config). 207 | 208 | Since categories can be changed with configuration, labels should be specified for them, since they can't be 209 | hardcoded in client's app. 210 | 211 | There is one special category: "system". This category should not be declared in configuration, and notification 212 | with such category will always pass. 213 | 214 | Sample configuration: 215 | 216 | .. code:: python 217 | 218 | UNIVERSAL_NOTIFICATIONS_CATEGORIES={ 219 | "push": { 220 | "default": _("This is a label for default category you'll send to FE"), 221 | "chat": _('Category for chat messages'), 222 | "promotions": _('Promotions',) 223 | }, 224 | "email": { 225 | "default": _("This is a label for default category you'll send to FE"), 226 | "chat": _('Category for chat messages'), 227 | "newsletter": _('Newsletter',) 228 | }, 229 | "sms": { 230 | "default": _("This is a label for default category you'll send to FE"), 231 | "chat": _('Category for chat messages'), 232 | "newsletter": _('Newsletter',) 233 | }, 234 | "test": { 235 | "default": _("This is a label for default category you'll send to FE"), 236 | }, 237 | }, 238 | 239 | If you want to allow different types of users to have different categories of notifications, you can 240 | do it with configuration: 241 | 242 | .. code:: python 243 | 244 | # not required. If defined, specific types of users will only get notifications from allowed categories. 245 | # requires a bit more configuration - helper function to check if notification category is allowed for user 246 | UNIVERSAL_NOTIFICATIONS_USER_CATEGORIES_MAPPING={ 247 | "for_admin": { 248 | "push": ["default", "chat", "promotions"], 249 | "email": ["default", "chat", "newsletter"], 250 | "sms": ["default", "chat", "newsletter"] 251 | }, 252 | "for_user": { 253 | "push": ["default", "chat", "promotions"], 254 | "email": ["default", "newsletter"], # chat skipped 255 | "sms": ["default", "chat", "newsletter"] 256 | } 257 | }, 258 | # path to the file we will import user definitions for UNIVERSAL_NOTIFICATIONS_USER_CATEGORIES_MAPPING 259 | UNIVERSAL_NOTIFICATIONS_USER_DEFINITIONS_FILE='tests.user_conf' 260 | 261 | # from file: tests/user_conf.py 262 | def for_admin(user): 263 | return user.is_superuser 264 | 265 | def for_user(user): 266 | return not user.is_superuser 267 | 268 | In the example above, functions "for_admin" & "for_user" should be defined in file tests/user_conf.py. Each 269 | function takes user as a parameter, and should return either True or False. 270 | 271 | If given notification type is not present for given user, user will neither be able to receive it nor unsubscribe it. 272 | 273 | Unsubscriber API 274 | ~~~~~~~~~~~~~~~~ 275 | 276 | The current subscriptions can be obtained with a API described below. Please note, that API does not provide label for "unsubscribe_from_all", since is always present and can be hardcoded in FE module. Categories however may vary, that's why labels for them must be returned from BE. 277 | 278 | .. code:: python 279 | 280 | # GET /subscriptions 281 | 282 | return { 283 | "unsubscribe_from_all": bool, # False by default 284 | "each_type_for_given_user": { 285 | "each_category_for_given_type_for_given_user": bool, # True(default) if subscribed, False if unsubscribed 286 | "unsubscribe_from_all": bool # False by default 287 | } 288 | "labels": { 289 | "each_type_for_given_user": { 290 | "each_category_for_given_type_for_given_user": string, 291 | } 292 | } 293 | } 294 | 295 | Unsubscriptions may be edited using following API: 296 | 297 | .. code:: python 298 | 299 | # PUT /subscriptions 300 | 301 | data = { 302 | "unsubscribe_from_all": bool, # False by default 303 | "each_type_for_given_user": { 304 | "each_category_for_given_type_for_given_user": bool, # True(default) if subscribed, False if unsubscribed 305 | "unsubscribe_from_all": bool # False by default 306 | } 307 | } 308 | 309 | Please note, that if any type/category for type is ommited, it is reseted to default value. 310 | 311 | FakeEmailSend view 312 | ~~~~~~~~~~~~~~~~~~ 313 | **universal_notifications.backends.emails.views.FakeEmailSend** is a view that helps testing email templates. 314 | To start using it, add ``url(r'^emails/', include('universal_notifications.backends.emails.urls'))`` 315 | to your urls.py, and specify receiver email address using ``UNIVERSAL_NOTIFICATIONS_FAKE_EMAIL_TO``. 316 | 317 | After that you can make a request to the new url with **template** parameter, for instance: 318 | ``http://localhost:8000/emails/?template=reset_password``, which will send an email using 319 | ``emails/email_reset_password.html`` as the template. 320 | 321 | 322 | Notification history 323 | ~~~~~~~~~~~~~~~~~~~~ 324 | By default all notifications that have been sent are stored in the **NotificationHistory** object in the database, but 325 | this behavior can be changed, and therefore the database will not be used to store notification history (but you will 326 | still receive notification history in your app log, on the **info** level). 327 | 328 | To disable using database, set ``UNIVERSAL_NOTIFICATIONS_HISTORY_USE_DATABASE`` to **False** (default: **True**), 329 | and to disable any history tracking, set ``UNIVERSAL_NOTIFICATIONS_HISTORY`` to **False** (default: **True**). 330 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | see notifications.py 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements/requirements-base.txt 2 | -r requirements/requirements-testing.txt 3 | -r requirements/requirements-codestyle.txt 4 | -------------------------------------------------------------------------------- /requirements/requirements-base.txt: -------------------------------------------------------------------------------- 1 | djangorestframework>=3.7 2 | django-rest-swagger>=2.1.2 3 | django-websocket-redis 4 | redis 5 | premailer>=3.1.0 6 | six>=1.10.0 7 | coreapi>=2.3.1,<3.0.0 8 | 9 | # push notifications 10 | django-push-notifications>=1.4.1,<1.5.0 # need new django (1.10+) rq.filter: >=1.4.0,<1.5.0 11 | pyfcm 12 | 13 | # sms/phone 14 | phonenumbers>=7.7.3 15 | twilio>=6.0.0,<8.0.0 16 | boto3>1.4.0,<2.0.0 17 | -------------------------------------------------------------------------------- /requirements/requirements-codestyle.txt: -------------------------------------------------------------------------------- 1 | flake8>=3.6.0 2 | pycodestyle>=2.4.0 3 | isort>=4.2.15 4 | -------------------------------------------------------------------------------- /requirements/requirements-testing.txt: -------------------------------------------------------------------------------- 1 | pytest>=3.0.4 2 | pytest-django>=3.1.2 3 | pytest-cov>=2.4.0 4 | celery>=4.0.1 5 | -------------------------------------------------------------------------------- /run_isort: -------------------------------------------------------------------------------- 1 | isort --recursive -p tests universal_notifications -sd THIRDPARTY -m 0 -w 120 -y 2 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # This script is taken from Django Rest Framework 3 | # (https://github.com/tomchristie/django-rest-framework/blob/master/runtests.py) 4 | from __future__ import print_function 5 | 6 | import os 7 | import subprocess 8 | import sys 9 | 10 | import pytest 11 | 12 | PYTEST_ARGS = { 13 | "default": ["tests", "--tb=short", "-s", "-rw"], 14 | "fast": ["tests", "--tb=short", "-q", "-s", "-rw"], 15 | } 16 | 17 | FLAKE8_ARGS = ["universal_notifications", "tests", "--ignore=E501"] 18 | 19 | ISORT_ARGS = ["--recursive", "--check-only", "-p", "tests", "universal_notifications", "-sd", "THIRDPARTY", "-m", "0", 20 | "-w", "120", "-s", "venv", "-s", ".tox",] 21 | 22 | sys.path.append(os.path.dirname(__file__)) 23 | 24 | 25 | def exit_on_failure(ret, message=None): 26 | if ret: 27 | sys.exit(ret) 28 | 29 | 30 | def flake8_main(args): 31 | print("Running flake8 code linting") 32 | ret = subprocess.call(["flake8"] + args) 33 | print("flake8 failed" if ret else "flake8 passed") 34 | return ret 35 | 36 | 37 | def isort_main(args): 38 | print("Running isort code checking") 39 | ret = subprocess.call(["isort"] + args) 40 | 41 | if ret: 42 | print("isort failed: Some modules have incorrectly ordered imports. Fix by running `isort --recursive .`") 43 | else: 44 | print("isort passed") 45 | 46 | return ret 47 | 48 | 49 | def split_class_and_function(string): 50 | class_string, function_string = string.split(".", 1) 51 | return "{} and {}".format(class_string, function_string) 52 | 53 | 54 | def is_function(string): 55 | # `True` if it looks like a test function is included in the string. 56 | return string.startswith("test_") or ".test_" in string 57 | 58 | 59 | def is_class(string): 60 | # `True` if first character is uppercase - assume it's a class name. 61 | return string[0] == string[0].upper() 62 | 63 | 64 | if __name__ == "__main__": 65 | """ test runner - to be used by tox """ 66 | try: 67 | sys.argv.remove("--nolint") 68 | except ValueError: 69 | run_flake8 = True 70 | run_isort = True 71 | else: 72 | run_flake8 = False 73 | run_isort = False 74 | 75 | try: 76 | sys.argv.remove("--lintonly") 77 | except ValueError: 78 | run_tests = True 79 | else: 80 | run_tests = False 81 | 82 | try: 83 | sys.argv.remove("--fast") 84 | except ValueError: 85 | style = "default" 86 | else: 87 | style = "fast" 88 | run_flake8 = False 89 | run_isort = False 90 | 91 | if len(sys.argv) > 1: 92 | pytest_args = sys.argv[1:] 93 | first_arg = pytest_args[0] 94 | 95 | try: 96 | pytest_args.remove("--coverage") 97 | except ValueError: 98 | pass 99 | else: 100 | pytest_args = [ 101 | "--cov-report", 102 | "xml", 103 | "--cov=universal_notifications", 104 | "--cov-config", 105 | ".coveragerc"] + pytest_args 106 | 107 | if first_arg.startswith("-"): 108 | # `runtests.py [flags]` 109 | pytest_args = ["tests"] + pytest_args 110 | elif is_class(first_arg) and is_function(first_arg): 111 | # `runtests.py TestCase.test_function [flags]` 112 | expression = split_class_and_function(first_arg) 113 | pytest_args = ["tests", "-k", expression] + pytest_args[1:] 114 | elif is_class(first_arg) or is_function(first_arg): 115 | # `runtests.py TestCase [flags]` 116 | # `runtests.py test_function [flags]` 117 | pytest_args = ["tests", "-k", pytest_args[0]] + pytest_args[1:] 118 | else: 119 | pytest_args = PYTEST_ARGS[style] 120 | 121 | if run_tests: 122 | exit_on_failure(pytest.main(pytest_args)) 123 | 124 | if run_flake8: 125 | exit_on_failure(flake8_main(FLAKE8_ARGS)) 126 | 127 | if run_isort: 128 | exit_on_failure(isort_main(ISORT_ARGS)) 129 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.rst 3 | 4 | [bdist_wheel] 5 | universal=1 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import io 4 | import os 5 | import re 6 | 7 | from setuptools import setup 8 | 9 | 10 | def local_open(fname): 11 | return open(os.path.join(os.path.dirname(__file__), fname)) 12 | 13 | 14 | def read_file(f): 15 | return io.open(f, "r", encoding="utf-8").read() 16 | 17 | 18 | def get_version(package): 19 | """ 20 | Return package version as listed in `__version__` in `init.py`. 21 | """ 22 | init_py = open(os.path.join(package, '__init__.py')).read() 23 | return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) 24 | 25 | 26 | def get_packages(package): 27 | """ 28 | Return root package and all sub-packages. 29 | """ 30 | return [dirpath 31 | for dirpath, dirnames, filenames in os.walk(package) 32 | if os.path.exists(os.path.join(dirpath, "__init__.py"))] 33 | 34 | 35 | def get_package_data(package): 36 | """ 37 | Return all files under the root package, that are not in a 38 | package themselves. 39 | """ 40 | walk = [(dirpath.replace(package + os.sep, "", 1), filenames) 41 | for dirpath, dirnames, filenames in os.walk(package) 42 | if not os.path.exists(os.path.join(dirpath, "__init__.py"))] 43 | 44 | filepaths = [] 45 | for base, filenames in walk: 46 | filepaths.extend([os.path.join(base, filename) 47 | for filename in filenames]) 48 | return {package: filepaths} 49 | 50 | 51 | version = get_version("universal_notifications") 52 | 53 | 54 | requirements = local_open("requirements/requirements-base.txt") 55 | required_to_install = [dist.strip() for dist in requirements.readlines()] 56 | 57 | 58 | setup( 59 | name="universal_notifications", 60 | version=version, 61 | url="https://github.com/HealthByRo/universal_notifications", 62 | license="MIT", 63 | description="High-level framework for notifications", 64 | long_description=read_file("README.rst"), 65 | author="Pawel Krzyzaniak", 66 | author_email="pawelk@ro.co", 67 | packages=get_packages("universal_notifications"), 68 | package_data=get_package_data("universal_notifications"), 69 | zip_safe=False, 70 | install_requires=required_to_install, 71 | classifiers=[ 72 | "Development Status :: 5 - Production/Stable", 73 | "Environment :: Web Environment", 74 | "Framework :: Django", 75 | "Intended Audience :: Developers", 76 | "Operating System :: OS Independent", 77 | "Programming Language :: Python", 78 | "Programming Language :: Python :: 3.6", 79 | "Programming Language :: Python :: 3.7", 80 | "Programming Language :: Python :: 3.8", 81 | "Programming Language :: Python :: 3.9", 82 | "Topic :: Internet :: WWW/HTTP", 83 | ] 84 | ) 85 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HealthByRo/universal_notifications/67552a42ff7625971e0b2aa7ee42984db8be159c/tests/__init__.py -------------------------------------------------------------------------------- /tests/celery.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from celery import Celery 4 | from django.conf import settings 5 | 6 | app = Celery("app.celery") 7 | app.config_from_object("django.conf:settings", namespace="CELERY") 8 | app.autodiscover_tasks(settings.INSTALLED_APPS, related_name="tasks") 9 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | def pytest_configure(): 3 | from django.conf import settings 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | MIDDLEWARE = ( 7 | "django.middleware.common.CommonMiddleware", 8 | "django.contrib.sessions.middleware.SessionMiddleware", 9 | "django.contrib.auth.middleware.AuthenticationMiddleware", 10 | "django.contrib.messages.middleware.MessageMiddleware", 11 | ) 12 | 13 | settings.configure( 14 | ADMINS=("foo@foo.com",), 15 | DEBUG_PROPAGATE_EXCEPTIONS=True, 16 | DATABASES={ 17 | "default": { 18 | "ENGINE": "django.db.backends.sqlite3", 19 | "NAME": ":memory:" 20 | } 21 | }, 22 | SITE_ID=1, 23 | SECRET_KEY="not very secret in tests", 24 | USE_I18N=True, 25 | USE_L10N=True, 26 | STATIC_URL="/static/", 27 | ROOT_URLCONF="tests.urls", 28 | TEMPLATES=[ 29 | { 30 | "BACKEND": "django.template.backends.django.DjangoTemplates", 31 | "APP_DIRS": True, 32 | }, 33 | ], 34 | MIDDLEWARE=MIDDLEWARE, 35 | MIDDLEWARE_CLASSES=MIDDLEWARE, 36 | INSTALLED_APPS=( 37 | "django.contrib.auth", 38 | "django.contrib.contenttypes", 39 | "django.contrib.sessions", 40 | "django.contrib.sites", 41 | "django.contrib.staticfiles", 42 | "django.contrib.admin", 43 | "universal_notifications", 44 | "rest_framework", 45 | "rest_framework.authtoken", 46 | "tests", 47 | ), 48 | PASSWORD_HASHERS=( 49 | "django.contrib.auth.hashers.MD5PasswordHasher", 50 | ), 51 | REST_FRAMEWORK={ 52 | "DEFAULT_VERSION": "1", 53 | "DEFAULT_PERMISSION_CLASSES": [ 54 | "rest_framework.permissions.IsAuthenticated", 55 | ], 56 | "DEFAULT_AUTHENTICATION_CLASSES": ( 57 | "rest_framework.authentication.TokenAuthentication", 58 | ) 59 | 60 | }, 61 | CELERY_APP_PATH="tests.celery.app", 62 | CELERY_TASK_ALWAYS_EAGER=True, 63 | TESTING=True, 64 | 65 | UNIVERSAL_NOTIFICATIONS_TWILIO_ACCOUNT="fake", 66 | # categories for notifications 67 | UNIVERSAL_NOTIFICATIONS_CATEGORIES={ 68 | "push": { 69 | "default": _("This is a label for default category you'll send to FE"), 70 | "chat": _("Category for chat messages"), 71 | "promotions": _("Promotions",) 72 | }, 73 | "email": { 74 | "default": _("This is a label for default category you'll send to FE"), 75 | "chat": _("Category for chat messages"), 76 | "newsletter": _("Newsletter",) 77 | }, 78 | "sms": { 79 | "default": _("This is a label for default category you'll send to FE"), 80 | "chat": _("Category for chat messages"), 81 | "newsletter": _("Newsletter",) 82 | }, 83 | "test": { 84 | "default": _("This is a label for default category you'll send to FE"), 85 | }, 86 | }, 87 | # not required. If defined, specific types of users will only get notifications from allowed categories. 88 | # requires a bit more configuration - helper function to check if notification category is allowed for user 89 | UNIVERSAL_NOTIFICATIONS_USER_CATEGORIES_MAPPING={ 90 | "for_admin": { 91 | "push": ["default", "chat", "promotions"], 92 | "email": ["default", "chat", "newsletter"], 93 | "sms": ["default", "chat", "newsletter"] 94 | }, 95 | "for_user": { 96 | "push": ["default", "chat", "promotions"], 97 | "email": ["default", "newsletter"], # chat skipped 98 | "sms": ["default", "chat", "newsletter"] 99 | } 100 | }, 101 | # path to the file we will import user definitions for UNIVERSAL_NOTIFICATIONS_USER_CATEGORIES_MAPPING 102 | UNIVERSAL_NOTIFICATIONS_USER_DEFINITIONS_FILE="tests.user_conf", 103 | UNIVERSAL_NOTIFICATIONS_FAKE_EMAIL_TO="test@example.com" 104 | ) 105 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.utils.translation import gettext_lazy as _ 3 | from rest_framework.reverse import reverse 4 | from tests.test_utils import APIBaseTestCase 5 | from universal_notifications.models import Device 6 | 7 | 8 | class NotificationApiTestCase(APIBaseTestCase): 9 | 10 | def test_device_api(self): 11 | self._create_user() 12 | url = reverse("notifications-devices") 13 | 14 | response = self.client.post(url, {}) 15 | self.assertEqual(response.status_code, 401) # login required - 403 worked 16 | 17 | self._login() 18 | response = self.client.post(url, {}) 19 | self.assertEqual(response.status_code, 400) 20 | self.assertIn("platform", response.data) 21 | self.assertIn("notification_token", response.data) 22 | self.assertIn("device_id", response.data) 23 | self.assertIn("app_id", response.data) 24 | 25 | data = { 26 | "platform": "wrong", 27 | "notification_token": "foo", 28 | "device_id": "bar", 29 | "app_id": "foo" 30 | } 31 | response = self.client.post(url, data) 32 | self.assertEqual(response.status_code, 400) 33 | self.assertEqual(Device.objects.count(), 0) 34 | 35 | data["platform"] = "ios" 36 | response = self.client.post(url, data) 37 | self.assertEqual(response.status_code, 201) 38 | first_device_id = response.data["id"] 39 | devices = Device.objects.all() 40 | self.assertEqual(devices.count(), 1) 41 | self.assertEqual(devices[0].user, self.user) 42 | self.assertEqual(devices[0].platform, "ios") 43 | self.assertEqual(devices[0].notification_token, "foo") 44 | self.assertEqual(devices[0].device_id, "bar") 45 | self.assertTrue(devices[0].is_active) 46 | 47 | # make sure that adding the same device will not duplicate devices 48 | response = self.client.post(url, data) 49 | self.assertEqual(response.status_code, 200) 50 | self.assertEqual(devices.count(), 1) 51 | self.assertEqual(response.data["id"], first_device_id) 52 | 53 | def test_notifications_categories_api(self): 54 | self._create_user() 55 | url = reverse("notifications-subscriptions") 56 | 57 | # must be authenticated 58 | response = self.client.get(url) 59 | self.assertEqual(response.status_code, 401) 60 | 61 | # labels 62 | labels_dict = { 63 | "push": { 64 | "default": _("This is a label for default category you'll send to FE"), 65 | "chat": _("Category for chat messages"), 66 | "promotions": _("Promotions") 67 | }, 68 | "email": { 69 | "default": _("This is a label for default category you'll send to FE"), 70 | "newsletter": _("Newsletter") 71 | }, 72 | "sms": { 73 | "default": _("This is a label for default category you'll send to FE"), 74 | "chat": _("Category for chat messages"), 75 | "newsletter": _("Newsletter") 76 | } 77 | } 78 | 79 | # get 80 | self._login() 81 | response = self.client.get(url) 82 | self.assertEqual(response.status_code, 200) 83 | self.assertFalse(response.data["unsubscribed_from_all"]) 84 | self.assertEqual(response.data["push"], { 85 | "default": True, 86 | "chat": True, 87 | "promotions": True, 88 | "unsubscribed_from_all": False 89 | }) 90 | self.assertEqual(response.data["email"], { 91 | "default": True, 92 | "newsletter": True, 93 | "unsubscribed_from_all": False 94 | }) 95 | self.assertEqual(response.data["sms"], { 96 | "default": True, 97 | "chat": True, 98 | "newsletter": True, 99 | "unsubscribed_from_all": False 100 | }) 101 | self.assertEqual(response.data["labels"], labels_dict) 102 | 103 | # patch is disabled 104 | response = self.client.patch(url, {}, format="json") 105 | self.assertEqual(response.status_code, 405) 106 | 107 | # put 108 | data = { 109 | "push": {"default": False}, 110 | "email": {"unsubscribed_from_all": True}, 111 | "sms": { 112 | "default": False, 113 | "chat": True, 114 | "newsletter": True, 115 | "unsubscribed_from_all": False 116 | } 117 | } 118 | response = self.client.put(url, data, format="json") 119 | self.assertEqual(response.status_code, 200) 120 | self.assertFalse(response.data["unsubscribed_from_all"]) 121 | self.assertEqual(response.data["push"], { 122 | "default": False, 123 | "chat": True, 124 | "promotions": True, 125 | "unsubscribed_from_all": False 126 | }) 127 | self.assertEqual(response.data["email"], { 128 | "default": True, 129 | "newsletter": True, 130 | "unsubscribed_from_all": True 131 | }) 132 | self.assertEqual(response.data["sms"], { 133 | "default": False, 134 | "chat": True, 135 | "newsletter": True, 136 | "unsubscribed_from_all": False 137 | }) 138 | 139 | data = { 140 | "email": {"unsubscribed_from_all": True}, 141 | "sms": { 142 | "default": False, 143 | "chat": True, 144 | "newsletter": True, 145 | "unsubscribed_from_all": False 146 | }, 147 | "unsubscribed_from_all": True 148 | } 149 | response = self.client.put(url, data, format="json") 150 | self.assertEqual(response.status_code, 200) 151 | self.assertTrue(response.data["unsubscribed_from_all"]) 152 | self.assertEqual(response.data["push"], { 153 | "default": True, 154 | "chat": True, 155 | "promotions": True, 156 | "unsubscribed_from_all": False 157 | }) 158 | self.assertEqual(response.data["email"], { 159 | "default": True, 160 | "newsletter": True, 161 | "unsubscribed_from_all": True 162 | }) 163 | self.assertEqual(response.data["sms"], { 164 | "default": False, 165 | "chat": True, 166 | "newsletter": True, 167 | "unsubscribed_from_all": False 168 | }) 169 | 170 | 171 | class DeviceDetailsAPITestCase(APIBaseTestCase): 172 | def setUp(self): 173 | self.user = self._create_user(i=34) 174 | self.second_user = self._create_user(i=2, set_self=False) 175 | self.first_device = Device.objects.create(user=self.user, platform=Device.PLATFORM_IOS, 176 | notification_token="abc", device_id="iphone5,2", app_id="com.abc") 177 | self.second_device = Device.objects.create(user=self.second_user, platform=Device.PLATFORM_IOS, 178 | notification_token="abc", device_id="iphone5,2", app_id="com.abc") 179 | 180 | def test_api(self): 181 | # try deleting other user's device 182 | url = reverse("device-details", args=[self.second_device.id]) 183 | self._login(self.user) 184 | response = self.client.delete(url) 185 | self.assertEqual(response.status_code, 404) 186 | 187 | url = reverse("device-details", args=[self.first_device.id]) 188 | response = self.client.delete(url) 189 | self.assertEqual(response.status_code, 204) 190 | -------------------------------------------------------------------------------- /tests/test_backends.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from unittest import mock 5 | from django.contrib.auth.models import User 6 | from django.core.exceptions import ImproperlyConfigured 7 | from django.test.utils import override_settings 8 | from push_notifications.settings import PUSH_NOTIFICATIONS_SETTINGS 9 | from rest_framework.renderers import JSONRenderer 10 | from rest_framework.test import APITestCase 11 | from universal_notifications.backends.push.apns import APNSDataOverflow, apns_send_message 12 | from universal_notifications.backends.push.fcm import fcm_send_message 13 | from universal_notifications.backends.push.gcm import gcm_send_message 14 | from universal_notifications.backends.websockets import publish 15 | from universal_notifications.models import Device 16 | 17 | try: 18 | from urllib.parse import urlencode 19 | except ImportError: 20 | # Python 2 support 21 | from urllib import urlencode 22 | 23 | 24 | class SampleUser(object): 25 | def __init__(self, email): 26 | self.email = email 27 | 28 | 29 | class SampleItem(object): 30 | def __init__(self, foo="foo"): 31 | self.foo = foo 32 | 33 | def as_dict(self): 34 | return { 35 | "foo": self.foo 36 | } 37 | 38 | 39 | class WSTests(APITestCase): 40 | def setUp(self): 41 | self.user = SampleUser("user@example.com") 42 | self.item = SampleItem() 43 | 44 | def test_publish(self): 45 | with mock.patch("universal_notifications.backends.websockets.RedisMessage") as mocked_message: 46 | # test without extra arguments 47 | publish(self.user) 48 | mocked_message.assert_called_with(JSONRenderer().render({})) 49 | 50 | mocked_message.reset_mock() 51 | 52 | # test with single item 53 | publish(self.user, self.item) 54 | mocked_message.assert_called_with(JSONRenderer().render(self.item.as_dict())) 55 | 56 | mocked_message.reset_mock() 57 | 58 | # test with additional_data 59 | additional_data = {"additional": True} 60 | publish(self.user, self.item, additional_data) 61 | result = self.item.as_dict() 62 | result.update(additional_data) 63 | mocked_message.assert_called_with(JSONRenderer().render(result)) 64 | 65 | 66 | class PushTests(APITestCase): 67 | test_settings = { 68 | "app1": { 69 | "FCM_API_KEY": "secret", 70 | "GCM_API_KEY": "secret", 71 | "APNS_CERTIFICATE": os.path.join(os.path.dirname(__file__), "test_data", "certificate.pem") 72 | } 73 | } 74 | 75 | def setUp(self): 76 | self.user = User.objects.create_user( 77 | username="user", email="user@example.com", password="1234") 78 | 79 | self.fcm_device = Device(user=self.user, app_id="app1", platform=Device.PLATFORM_FCM) 80 | self.gcm_device = Device(user=self.user, app_id="app1", platform=Device.PLATFORM_GCM) 81 | self.apns_device = Device(user=self.user, app_id="app1", platform=Device.PLATFORM_IOS) 82 | 83 | @override_settings(UNIVERSAL_NOTIFICATIONS_MOBILE_APPS=test_settings) 84 | def test_fcm(self): 85 | with mock.patch("universal_notifications.backends.push.fcm.FCMNotification." 86 | "notify_single_device") as mocked_notify: 87 | message = {"device": self.fcm_device, "message": "msg", "data": {"stuff": "foo"}} 88 | 89 | with override_settings(UNIVERSAL_NOTIFICATIONS_MOBILE_APPS={"app1": {}}): 90 | fcm_send_message(**message) 91 | mocked_notify.assert_not_called() 92 | 93 | mocked_notify.reset_mock() 94 | 95 | fcm_send_message(**message) 96 | mocked_notify.assert_called_with(registration_id=message["device"].notification_token, 97 | message_body=message["message"], data_message=message["data"]) 98 | 99 | @mock.patch("universal_notifications.backends.push.gcm.urlopen") 100 | def test_gcm(self, mocked_urlopen): 101 | message = { 102 | "device": self.fcm_device, 103 | "message": "msg", 104 | "collapse_key": "key", 105 | "delay_while_idle": 1, 106 | "time_to_live": "1", 107 | "data": {"info": "foo"} 108 | } 109 | with override_settings(UNIVERSAL_NOTIFICATIONS_MOBILE_APPS={"app1": {}}): 110 | # test sending without API key set 111 | self.assertRaises(ImproperlyConfigured, gcm_send_message, **message) 112 | mocked_urlopen.assert_not_called() 113 | 114 | mocked_urlopen.reset_mock() 115 | 116 | with override_settings(UNIVERSAL_NOTIFICATIONS_MOBILE_APPS=self.test_settings): 117 | # test regular use 118 | with mock.patch("universal_notifications.backends.push.gcm.Request") as mocked_request: 119 | mocked_urlopen.return_value.read.return_value = "mocked" 120 | request_data = { 121 | "registration_id": self.gcm_device.notification_token, 122 | "collapse_key": message["collapse_key"].encode("utf-8"), 123 | "delay_while_idle": message["delay_while_idle"], 124 | "time_to_live": message["time_to_live"].encode("utf-8"), 125 | "data.message": message["message"].encode("utf-8") 126 | } 127 | for k, v in message["data"].items(): 128 | request_data["data.{}".format(k)] = v.encode("utf-8") 129 | 130 | data = urlencode(sorted(request_data.items())).encode("utf-8") 131 | self.assertEqual(gcm_send_message(**message), mocked_urlopen.return_value.read.return_value) 132 | mocked_request.assert_called_with(PUSH_NOTIFICATIONS_SETTINGS["GCM_POST_URL"], data, { 133 | "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", 134 | "Authorization": "key={}".format(self.test_settings["app1"]["GCM_API_KEY"]), 135 | "Content-Length": str(len(data)), 136 | }) 137 | mocked_urlopen.assert_called() 138 | 139 | mocked_urlopen.reset_mock() 140 | 141 | # test fail 142 | mocked_urlopen.return_value.read.return_value = "Error=Fail" 143 | self.assertFalse(gcm_send_message(**message)) 144 | 145 | def test_apns_config(self): 146 | message = { 147 | "device": self.apns_device, 148 | "message": "msg", 149 | "data": {} 150 | } 151 | 152 | # test with no settings 153 | self.assertRaises(ImproperlyConfigured, apns_send_message, **message) 154 | 155 | # test without certificate set 156 | with override_settings(UNIVERSAL_NOTIFICATIONS_MOBILE_APPS={"app1": {"GCM_API_KEY": "key"}}): 157 | self.assertRaises(ImproperlyConfigured, apns_send_message, **message) 158 | 159 | # test unreadable certificate 160 | with override_settings(UNIVERSAL_NOTIFICATIONS_MOBILE_APPS={"app1": {"APNS_CERTIFICATE": "123d"}}): 161 | self.assertRaises(ImproperlyConfigured, apns_send_message, **message) 162 | 163 | def test_apns(self): 164 | message = { 165 | "device": self.apns_device, 166 | "message": "msg", 167 | "data": {} 168 | } 169 | 170 | with mock.patch("ssl.wrap_socket") as ws: 171 | with mock.patch("socket.socket") as socket: 172 | with override_settings(UNIVERSAL_NOTIFICATIONS_MOBILE_APPS=self.test_settings): 173 | socket.return_value = 123 174 | apns_send_message(**message) 175 | ws.assert_called_once_with( 176 | 123, certfile=self.test_settings["app1"]["APNS_CERTIFICATE"], ssl_version=3) 177 | 178 | @override_settings(UNIVERSAL_NOTIFICATIONS_MOBILE_APPS=test_settings) 179 | @mock.patch("universal_notifications.backends.push.apns._apns_pack_frame") 180 | def test_apns_payload(self, mock_pack_frame): 181 | message = { 182 | "device": self.apns_device, 183 | "message": "msg", 184 | "description": "desc", 185 | "data": { 186 | "category": "info", 187 | "content_available": True, 188 | "sound": "chime", 189 | "badge": 1, 190 | "socket": mock.MagicMock(), 191 | "identifier": 10, 192 | "expiration": 30, 193 | "priority": 20, 194 | "action_loc_key": "key", 195 | "loc_key": "TEST_LOCK_KEY", 196 | "loc_args": "args", 197 | "extra": {"custom_data": 12345} 198 | } 199 | } 200 | expected_payload = { 201 | "aps": { 202 | "alert": { 203 | "action-loc-key": "key", 204 | "body": "desc", 205 | "loc-args": "args", 206 | "loc-key": "TEST_LOCK_KEY", 207 | "title": "msg" 208 | }, 209 | "badge": 1, 210 | "category": "info", 211 | "content-available": 1, 212 | "sound": "chime" 213 | }, 214 | "custom_data": 12345 215 | } 216 | expected_payload = json.dumps(expected_payload, separators=(",", ":"), sort_keys=True).encode("utf-8") 217 | # test rich payload 218 | apns_send_message(**message) 219 | mock_pack_frame.assert_called_with( 220 | self.apns_device.notification_token, expected_payload, 221 | message["data"]["identifier"], message["data"]["expiration"], message["data"]["priority"] 222 | ) 223 | 224 | # test sending without description 225 | with mock.patch("universal_notifications.backends.push.apns._apns_send") as mocked_send: 226 | apns_send_message(self.apns_device, message="msg", description="") 227 | mocked_send.assert_called_with(self.apns_device.app_id, self.apns_device.notification_token, { 228 | "body": "msg" 229 | }) 230 | 231 | # test oversizing 232 | with self.assertRaises(APNSDataOverflow): 233 | apns_send_message(self.apns_device, "_" * 2049) 234 | -------------------------------------------------------------------------------- /tests/test_base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """base tests: 3 | 4 | - sending 5 | - receiver list preparation 6 | - message serialization 7 | - sending 8 | """ 9 | import json 10 | from random import randint 11 | 12 | from unittest import mock 13 | from django.conf import settings 14 | from django.contrib.auth.models import User 15 | from django.core import mail 16 | from django.core.exceptions import ImproperlyConfigured 17 | from django.db import models 18 | from django.test import override_settings 19 | from rest_framework import serializers 20 | from rest_framework.test import APITestCase 21 | from universal_notifications.models import Device, NotificationHistory, UnsubscribedUser 22 | from universal_notifications.notifications import (EmailNotification, PushNotification, SMSNotification, 23 | WSNotification) 24 | 25 | 26 | class SampleModel(models.Model): 27 | name = models.CharField(max_length=128) 28 | 29 | 30 | class SampleSerializer(serializers.ModelSerializer): 31 | class Meta: 32 | model = SampleModel 33 | fields = ("name",) 34 | 35 | 36 | class SampleD(WSNotification): 37 | message = "WebSocket" 38 | serializer_class = SampleSerializer 39 | 40 | 41 | class SampleE(SMSNotification): 42 | message = "{{receiver.email}}: {{item.name}}" 43 | 44 | 45 | class SyncSampleE(SampleE): 46 | send_async = False 47 | 48 | 49 | class SampleF(EmailNotification): 50 | email_name = "test" 51 | email_subject = "subject" 52 | categories = ["cars", "newsletter"] 53 | sendgrid_asm = { 54 | "group_id": 1, 55 | "groups_to_display": [1, 2] 56 | } 57 | 58 | 59 | class SampleG(PushNotification): 60 | title = "{{item.name}}" 61 | description = "desc" 62 | 63 | 64 | class SampleH(EmailNotification): 65 | email_name = "test" 66 | email_subject = "subject" 67 | category = "system" 68 | 69 | 70 | class SampleI(SampleH): 71 | category = "default" 72 | 73 | 74 | class SampleJ(EmailNotification): 75 | email_name = "test" 76 | email_subject = "subject" 77 | check_subscription = False 78 | 79 | 80 | class SampleNoCategory(EmailNotification): 81 | email_name = "test" 82 | email_subject = "subject" 83 | category = "" 84 | 85 | 86 | class SampleChatNotification(EmailNotification): 87 | email_name = "test" 88 | email_subject = "subject" 89 | category = "chat" 90 | 91 | 92 | class SampleNotExistingCategory(EmailNotification): 93 | email_name = "test" 94 | email_subject = "subject" 95 | category = "some_weird_one" 96 | 97 | 98 | class SampleReceiver(object): 99 | def __init__(self, email, phone, first_name="Foo", last_name="Bar", is_superuser=False): 100 | self.id = 100000 + randint(1, 100) 101 | self.email = email 102 | self.phone = phone 103 | self.first_name = first_name 104 | self.last_name = last_name 105 | self.is_superuser = is_superuser 106 | 107 | 108 | class BaseTest(APITestCase): 109 | def setUp(self): 110 | self.item = {"content": "whateva", "is_read": False} 111 | self.receivers = [" a ", "b "] 112 | 113 | self.object_item = SampleModel(name="sample") 114 | self.object_receiver = SampleReceiver("foo@bar.com", "123456789") 115 | self.object_second_receiver = SampleReceiver("foo@bar.com", "123456789", first_name="foo@bar.com") 116 | self.superuser_object_receiver = SampleReceiver("super_foo@bar.com", "123456789", is_superuser=True) 117 | 118 | self.regular_user = User.objects.create_user( 119 | username="barszcz", 120 | email="bar@sz.cz", 121 | password="1234" 122 | ) 123 | self.all_unsubscribed_receiver = User.objects.create_user( 124 | username="all_unsubscribed_user", 125 | email="bar@foo.com", 126 | password="1234") 127 | 128 | self.all_unsubscribed_user = UnsubscribedUser.objects.create( 129 | user=self.all_unsubscribed_receiver, 130 | unsubscribed_from_all=True 131 | ) 132 | 133 | self.unsubscribed_receiver = User.objects.create_user( 134 | username="user", 135 | email="joe@foo.com", 136 | password="1234") 137 | 138 | self.unsubscribed_user = UnsubscribedUser.objects.create( 139 | user=self.unsubscribed_receiver, 140 | unsubscribed={"email": ["default"]} 141 | ) 142 | 143 | self.push_device = Device.objects.create( 144 | user=self.regular_user, platform=Device.PLATFORM_FCM) 145 | 146 | def test_sending(self): 147 | # test WSNotifications 148 | with mock.patch("tests.test_base.SampleD.send_inner") as mocked_send_inner: 149 | SampleD(self.object_item, [self.object_receiver], {}).send() 150 | expected_message = { 151 | "message": SampleD.message, 152 | "data": { 153 | "name": self.object_item.name 154 | } 155 | } 156 | mocked_send_inner.assert_called_with({self.object_receiver}, expected_message) 157 | 158 | # test send_inner 159 | with mock.patch("universal_notifications.notifications.publish") as mocked_publish: 160 | SampleD(self.object_item, [self.object_receiver], {}).send() 161 | mocked_publish.assert_called_with(self.object_receiver, additional_data=expected_message) 162 | 163 | # test SMSNotifications 164 | sms_message = "{}: {}".format(self.object_receiver.email, self.object_item.name) 165 | with mock.patch("universal_notifications.notifications.send_sms") as mocked_send_sms: 166 | SampleE(self.object_item, [self.object_receiver], {}).send() 167 | mocked_send_sms.assert_called_with(self.object_receiver.phone, sms_message, send_async=True) 168 | mocked_send_inner.reset_mock() 169 | 170 | SyncSampleE(self.object_item, [self.object_receiver], {}).send() 171 | mocked_send_sms.assert_called_with(self.object_receiver.phone, sms_message, send_async=False) 172 | 173 | # test EmailNotifications 174 | with mock.patch("tests.test_base.SampleF.send_inner") as mocked_send_inner: 175 | SampleF(self.object_item, [self.object_receiver, self.all_unsubscribed_receiver], {"param": "val"}).send() 176 | mocked_send_inner.assert_called_with({self.object_receiver}, { 177 | "item": self.object_item, 178 | "param": "val" 179 | }) 180 | 181 | # test System EmailNotifications 182 | with mock.patch("tests.test_base.SampleH.send_inner") as mocked_send_inner: 183 | SampleH(self.object_item, [self.object_receiver, self.all_unsubscribed_receiver], {}).send() 184 | mocked_send_inner.assert_called_with({self.object_receiver, self.all_unsubscribed_receiver}, { 185 | "item": self.object_item, 186 | }) 187 | 188 | # test EmailNotifications with default disabled 189 | with mock.patch("tests.test_base.SampleI.send_inner") as mocked_send_inner: 190 | SampleI(self.object_item, [self.object_receiver, self.unsubscribed_receiver], {}).send() 191 | mocked_send_inner.assert_called_with({self.object_receiver}, { 192 | "item": self.object_item, 193 | }) 194 | 195 | # test w/o test subscription 196 | with mock.patch("tests.test_base.SampleJ.send_inner") as mocked_send_inner: 197 | SampleJ(self.object_item, [self.object_receiver, self.all_unsubscribed_receiver], {}).send() 198 | mocked_send_inner.assert_called_with({self.object_receiver, self.all_unsubscribed_receiver}, { 199 | "item": self.object_item, 200 | }) 201 | 202 | mail.outbox = [] 203 | SampleF(self.object_item, [self.object_second_receiver], {}).send() 204 | self.assertEqual(len(mail.outbox), 1) 205 | self.assertEqual(mail.outbox[0].subject, "subject") 206 | self.assertEqual(mail.outbox[0].to, ["{last_name} <{email}>".format(**self.object_second_receiver.__dict__)]) 207 | self.assertEqual(mail.outbox[0].categories, ["cars", "newsletter"]) 208 | self.assertEqual(mail.outbox[0].asm, { 209 | "group_id": 1, 210 | "groups_to_display": [1, 2] 211 | }) 212 | 213 | mail.outbox = [] 214 | notification = SampleF(self.object_item, [self.object_receiver], {}) 215 | notification.sender = "Overriden Sender " 216 | notification.send() 217 | self.assertEqual(len(mail.outbox), 1) 218 | self.assertEqual(mail.outbox[0].from_email, "Overriden Sender ") 219 | 220 | # test PushNotifications 221 | with mock.patch("tests.test_base.SampleG.send_inner") as mocked_send_inner: 222 | SampleG(self.object_item, [self.object_receiver], {}).send() 223 | expected_message = { 224 | "title": self.object_item.name, 225 | "description": SampleG.description, 226 | "data": {} 227 | } 228 | mocked_send_inner.assert_called_with({self.object_receiver}, expected_message) 229 | 230 | # test send_inner 231 | with mock.patch("tests.test_base.Device.send_message") as mocked_send_message: 232 | SampleG(self.object_item, [self.regular_user], {"item": self.object_item}).send() 233 | mocked_send_message.assert_called_with(self.object_item.name, SampleG.description) 234 | 235 | # test w/o category - should fail 236 | with mock.patch("tests.test_base.SampleNoCategory.send_inner") as mocked_send_inner: 237 | with self.assertRaises(ImproperlyConfigured): 238 | SampleNoCategory(self.object_item, [self.object_receiver, self.unsubscribed_receiver], {}).send() 239 | 240 | # chat category is not allowed for "user" 241 | with self.assertRaises(ImproperlyConfigured): 242 | SampleChatNotification(self.object_item, [self.object_receiver], {}).send() 243 | 244 | # but works for super user 245 | with mock.patch("tests.test_base.SampleChatNotification.send_inner") as mocked_send_inner: 246 | SampleChatNotification(self.object_item, [self.superuser_object_receiver], {}).send() 247 | mocked_send_inner.assert_called_with({self.superuser_object_receiver}, { 248 | "item": self.object_item, 249 | }) 250 | 251 | with mock.patch("tests.test_base.SampleNotExistingCategory.send_inner") as mocked_send_inner: 252 | with self.assertRaises(ImproperlyConfigured): 253 | SampleNotExistingCategory(self.object_item, [self.object_receiver], {}).send() 254 | 255 | def test_email_attachments(self): 256 | mail.outbox = [] 257 | attachments = [ 258 | ("first.txt", "first file", "text/plain"), 259 | ("second.txt", "second file", "text/plain") 260 | ] 261 | SampleF(self.object_item, [self.object_receiver], {}, attachments=attachments).send() 262 | self.assertEqual(len(mail.outbox), 1) 263 | last_mail = mail.outbox[0] 264 | self.assertEqual(last_mail.attachments, attachments) 265 | 266 | def test_email_categories(self): 267 | SampleF(self.object_item, [self.object_receiver], {}).send() 268 | self.assertEqual(len(mail.outbox), 1) 269 | self.assertEqual(mail.outbox[0].categories, ["cars", "newsletter"]) 270 | 271 | @override_settings() 272 | def test_mapping(self): 273 | del settings.UNIVERSAL_NOTIFICATIONS_USER_CATEGORIES_MAPPING 274 | result = SampleD.get_mapped_user_notifications_types_and_categories(self.regular_user) 275 | expected_result = {} 276 | for key in settings.UNIVERSAL_NOTIFICATIONS_CATEGORIES.keys(): 277 | expected_result[key] = settings.UNIVERSAL_NOTIFICATIONS_CATEGORIES[key].keys() 278 | self.assertDictEqual(result, expected_result) 279 | 280 | def test_history(self): 281 | print(NotificationHistory.objects.all()) 282 | self.assertEqual(NotificationHistory.objects.count(), 0) 283 | with mock.patch("universal_notifications.notifications.logger.info") as mocked_logger: 284 | SampleD(self.object_item, [self.object_receiver], {}).send() 285 | mocked_logger.assert_called() 286 | message = mocked_logger.call_args[0][0].replace("'", "\"") 287 | message_dict = json.loads(message.split("Notification sent: ")[1]) 288 | self.assertEqual(message_dict, { 289 | 'group': 'WebSocket', 'klass': 'SampleD', 'receiver': 'foo@bar.com', 290 | 'details': 'message: WebSocket, serializer: SampleSerializer', 'category': 'default' 291 | }) 292 | self.assertEqual(NotificationHistory.objects.count(), 1) 293 | 294 | def test_getting_subject_from_html(self): 295 | # when subject is not provided in notification definition, the subject is taken from tags 296 | notification = SampleF(self.object_item, [self.object_receiver], {}) 297 | notification.email_subject = None 298 | notification.send() 299 | self.assertEqual(len(mail.outbox), 1) 300 | self.assertEqual(mail.outbox[0].subject, "Email template used in tests") 301 | 302 | # should raise ImproperlyConfigured when the title tags cannot be found 303 | notification.email_name = "test_empty" 304 | notification.email_subject = None 305 | with self.assertRaises(ImproperlyConfigured): 306 | notification.send() 307 | self.assertEqual(len(mail.outbox), 1) 308 | -------------------------------------------------------------------------------- /tests/test_data/certificate.pem: -------------------------------------------------------------------------------- 1 | Bag Attributes 2 | friendlyName: Apple Development IOS Push Services: com.baseride.Magnitapp 3 | localKeyID: 38 EE 6A 28 9F 92 3A 56 3F 30 A4 06 13 94 4F 76 E7 BB 58 6C 4 | subject=/UID=com.baseride.Magnitapp/CN=Apple Development IOS Push Services: com.baseride.Magnitapp/OU=QAMD48Y2CA/C=US 5 | issuer=/C=US/O=Apple Inc./OU=Apple Worldwide Developer Relations/CN=Apple Worldwide Developer Relations Certification Authority 6 | -----BEGIN CERTIFICATE----- 7 | MIIFkTCCBHmgAwIBAgIIJjlosjhqgjswDQYJKoZIhvcNAQEFBQAwgZYxCzAJBgNV 8 | BAYTAlVTMRMwEQYDVQQKDApBcHBsZSBJbmMuMSwwKgYDVQQLDCNBcHBsZSBXb3Js 9 | ZHdpZGUgRGV2ZWxvcGVyIFJlbGF0aW9uczFEMEIGA1UEAww7QXBwbGUgV29ybGR3 10 | aWRlIERldmVsb3BlciBSZWxhdGlvbnMgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkw 11 | HhcNMTUxMTI0MTI0MTU3WhcNMTYxMTIzMTI0MTU3WjCBkDEmMCQGCgmSJomT8ixk 12 | AQEMFmNvbS5iYXNlcmlkZS5NYWduaXRhcHAxRDBCBgNVBAMMO0FwcGxlIERldmVs 13 | b3BtZW50IElPUyBQdXNoIFNlcnZpY2VzOiBjb20uYmFzZXJpZGUuTWFnbml0YXBw 14 | MRMwEQYDVQQLDApRQU1ENDhZMkNBMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcN 15 | AQEBBQADggEPADCCAQoCggEBANFtOjCZMuhVhoENiR1d88xsBeKB2DxThcAhCntp 16 | yYSGeQyqszMgpkM4DWGrF/BmAzywAviS9Y4uyZ6WaawS0ViqWdRG4lL1riOKgCTV 17 | 72H48lqKLwyRuQQTXCOSBsQgXhxxWuDc8nMaG5LgpJOqjHM+W7TzsM72KzDagdNg 18 | /hUqf16uXbqdfCXcRyL99wbPsAQ1TrI3wExTAmpEBpeo742IrhvM6/ApQeKlHoif 19 | u49mguTcyH4sFLPN+T2zmu2aS2rsgZryj1Mzq/yKE8q4l06O2z/ElVEcsCFPaYV0 20 | EF7nNEG4sw1kiUDEe7HTBFFpoRi1a9sJLoyThFXpJMwbIJ8CAwEAAaOCAeUwggHh 21 | MB0GA1UdDgQWBBQ47moon5I6Vj8wpAYTlE9257tYbDAJBgNVHRMEAjAAMB8GA1Ud 22 | IwQYMBaAFIgnFwmpthhgi+zruvZHWcVSVKO3MIIBDwYDVR0gBIIBBjCCAQIwgf8G 23 | CSqGSIb3Y2QFATCB8TCBwwYIKwYBBQUHAgIwgbYMgbNSZWxpYW5jZSBvbiB0aGlz 24 | IGNlcnRpZmljYXRlIGJ5IGFueSBwYXJ0eSBhc3N1bWVzIGFjY2VwdGFuY2Ugb2Yg 25 | dGhlIHRoZW4gYXBwbGljYWJsZSBzdGFuZGFyZCB0ZXJtcyBhbmQgY29uZGl0aW9u 26 | cyBvZiB1c2UsIGNlcnRpZmljYXRlIHBvbGljeSBhbmQgY2VydGlmaWNhdGlvbiBw 27 | cmFjdGljZSBzdGF0ZW1lbnRzLjApBggrBgEFBQcCARYdaHR0cDovL3d3dy5hcHBs 28 | ZS5jb20vYXBwbGVjYS8wTQYDVR0fBEYwRDBCoECgPoY8aHR0cDovL2RldmVsb3Bl 29 | ci5hcHBsZS5jb20vY2VydGlmaWNhdGlvbmF1dGhvcml0eS93d2RyY2EuY3JsMAsG 30 | A1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAjAQBgoqhkiG92NkBgMBBAIF 31 | ADANBgkqhkiG9w0BAQUFAAOCAQEAALUmZX306s/eEz7xeDA1WOP8iHDx7W3K4Pd7 32 | Aisw5ZYfoqZF8ZuX057j0myCz8BFdZeEeUjF/+DzrZ9MdZN/DmOgeekvSI9UlanE 33 | 6v9APYL0I9AoQE2uoRYXvFJqrZoFtcg/htxyILVah+p9JxQ9M6OkdU/zTGyIX+QG 34 | hlCAJBuqEvGPrme/SYkqLv2p1WKw4IJ8IKaza6QI7MQYI1UMIabbBVbq2GxaJuEO 35 | 0/lNjPFaNVfPcFNx74yHqCQVfz1hNUigjebqzxQ2A2qHzqIyU9FT1A1zE7HCYShe 36 | 7UqEW+7kihGAW1T0KOhcX5xtLVVFzCrJhsAsVQehTKmB0nR9+A== 37 | -----END CERTIFICATE----- 38 | Bag Attributes 39 | friendlyName: PushNotificationCloudBus 40 | localKeyID: 38 EE 6A 28 9F 92 3A 56 3F 30 A4 06 13 94 4F 76 E7 BB 58 6C 41 | Key Attributes: 42 | -----BEGIN RSA PRIVATE KEY----- 43 | MIIEogIBAAKCAQEA0W06MJky6FWGgQ2JHV3zzGwF4oHYPFOFwCEKe2nJhIZ5DKqz 44 | MyCmQzgNYasX8GYDPLAC+JL1ji7JnpZprBLRWKpZ1EbiUvWuI4qAJNXvYfjyWoov 45 | DJG5BBNcI5IGxCBeHHFa4NzycxobkuCkk6qMcz5btPOwzvYrMNqB02D+FSp/Xq5d 46 | up18JdxHIv33Bs+wBDVOsjfATFMCakQGl6jvjYiuG8zr8ClB4qUeiJ+7j2aC5NzI 47 | fiwUs835PbOa7ZpLauyBmvKPUzOr/IoTyriXTo7bP8SVURywIU9phXQQXuc0Qbiz 48 | DWSJQMR7sdMEUWmhGLVr2wkujJOEVekkzBsgnwIDAQABAoIBACOs06jLsBxb1VnO 49 | kHjsNEeybx4yuD8uiy47cqmrT6S/s4cw3O3steXleoIUvzM4bXy9DwSBJEtgNQBK 50 | 5x1k5zyPaFX87TjsmQl84m9j8i9iVQaPW4xslnPXSG7WxUhLqzx1IuIDQVnSLLhM 51 | hDyTZPGMwdqFWK0oyhq8Xjk/4IiCMcYG2M14jGVvEIsjMF246v+inAIpSUwZr1FD 52 | qzylj1FRnm0hTjXKIWrvumDiIodybFK5ruGbaKWlciokmyBaFXlt5JCzG1hrGetf 53 | wgg6gomjqSf7WuWILjWhHr6ZeNVKm8KdyOCs0csY1DSQj+CsLjUCF8fvE+59dN2k 54 | /u+qASECgYEA9Me6OcT6EhrbD1aqmDfg+DgFp6IOkP0We8Y2Z3Yg9fSzwRz0bZmE 55 | T9juuelhUxDph74fHvLmE0iMrueMIbWvzF8xGef0JIpvMVQmxvslzqRLFfPRclbA 56 | WoSWm8pzaI/X+tZetlQySoVVeS21HbzIEKnPdFBdkyC397xyV+iCQLsCgYEA2wao 57 | llTQ9TvQYDKad6T8RsgdCnk/BwLaq6EkLI+sNOBDRlzeSYbKkfqkwIPOhORe/ibg 58 | 2OO76P8QrmqLg76hQlYK4i6k/Fwz3pRajdfQ6KxS7sOLm0x5aqrFXHVhKVnCD5C9 59 | PldJ2mOAowAEe7HMPcNeYbX9bW6T1hcslTKkI20CgYAJxkP4dJYrzOi8dxB+3ZRd 60 | NRd8tyrvvTt9m8+mWAA+8hOPfZGBIuU2rwnxYJFjWMSKiBwEB10KnhYIEfT1j6TC 61 | e3ahezKzlteT17FotrSuyL661K6jazVpJ+w/sljjbwMH4DGOBFSxxxs/qISX+Gbg 62 | y3ceROtHqcHO4baLLhytawKBgC9wosVk+5mSahDcBQ8TIj1mjLu/BULMgHaaQY6R 63 | U/hj9s5fwRnl4yx5QIQeSHYKTPT5kMwJj6Lo1EEi/LL9cEpA/rx84+lxQx7bvT1p 64 | 2Gr9ID1tB2kMyGOtN3BOUEw3j8v1SrgdCfcOhEdJ8q6kFRvvnBrH42t3fvfpLxPl 65 | 0x2FAoGAbSkII3zPpc8mRcD3rtyOl2TlahBtXMuxfWSxsg/Zwf47crBfEbLD+5wz 66 | 7A9qnfwiDO98GJyE5/s+ynnL2PhIomCm0P13nkZuC4d9twYMtEvcD20mdQ+gsEhz 67 | Eg8ssRvYkO8DQwAFJKJVwVtVqMcnm/fkWu8GIfgqH6/fWNev6vs= 68 | -----END RSA PRIVATE KEY----- 69 | -------------------------------------------------------------------------------- /tests/test_emails.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth.models import User 3 | from django.core import mail 4 | from django.test import TestCase 5 | from django.urls import reverse_lazy 6 | 7 | 8 | class EmailTests(TestCase): 9 | def setUp(self): 10 | User.objects.create_superuser( 11 | username="admin", 12 | email="ad@m.in", 13 | password="1234" 14 | ) 15 | 16 | def test_fake_email_view(self): 17 | self.client.login(username="admin", password="1234") 18 | data = { 19 | "template": "test", 20 | "email": settings.UNIVERSAL_NOTIFICATIONS_FAKE_EMAIL_TO 21 | } 22 | response = self.client.get("{}?template={}".format( 23 | reverse_lazy("backends.emails.fake_view"), data["template"])) 24 | self.assertEqual(response.status_code, 200) 25 | self.assertEqual(len(mail.outbox), 1) 26 | self.assertEqual(mail.outbox[0].to, [data["email"]]) 27 | self.assertIn("Email template used in tests", mail.outbox[0].body) 28 | 29 | # test catching non existing templates 30 | data["template"] = "randomtemplate" 31 | response = self.client.get("{}?template={}".format( 32 | reverse_lazy("backends.emails.fake_view"), data["template"])) 33 | self.assertEqual(response.status_code, 200) 34 | self.assertEqual(response.context["template_does_not_exist"], True) 35 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from universal_notifications.models import Device, PhoneReceiver, PhoneSent 3 | 4 | from .test_utils import APIBaseTestCase 5 | 6 | 7 | class DeviceTest(APIBaseTestCase): 8 | def setUp(self): 9 | self.user = self._create_user() 10 | 11 | self.fcm_device = Device(user=self.user, app_id="app1", platform=Device.PLATFORM_FCM) 12 | self.gcm_device = Device(user=self.user, app_id="app1", platform=Device.PLATFORM_GCM) 13 | self.apns_device = Device(user=self.user, app_id="app1", platform=Device.PLATFORM_IOS) 14 | self.inactive_device = Device(user=self.user, app_id="app1", platform=Device.PLATFORM_FCM, is_active=False) 15 | self.unknown_device = Device(user=self.user, app_id="app1", platform="UNKNOWN") 16 | 17 | def test_send_message(self): 18 | message = {"message": "msg", "description": "desc", "field": "f1"} 19 | 20 | # test using inactive device 21 | self.assertFalse(self.inactive_device.send_message(**message)) 22 | 23 | # test using unknown device platform 24 | self.assertFalse(self.unknown_device.send_message(**message)) 25 | 26 | # test passing non-string message 27 | with mock.patch("universal_notifications.models.fcm_send_message") as mocked_send_message: 28 | self.fcm_device.send_message(message=1234, field="1") 29 | mocked_send_message.assert_called_with(self.fcm_device, "1234", {"field": "1"}) 30 | mocked_send_message.reset_mock() 31 | 32 | # test using fcm device 33 | self.fcm_device.send_message(**message) 34 | mocked_send_message.assert_called_with(self.fcm_device, message["message"], 35 | {"field": message["field"]}) 36 | 37 | # test using gcm device 38 | with mock.patch("universal_notifications.models.gcm_send_message") as mocked_send_message: 39 | self.gcm_device.send_message(**message) 40 | mocked_send_message.assert_called_with(self.gcm_device, message["message"], 41 | {"field": message["field"]}) 42 | 43 | # test using apns device 44 | with mock.patch("universal_notifications.models.apns_send_message") as mocked_send_message: 45 | self.apns_device.send_message(**message) 46 | mocked_send_message.assert_called_with(self.apns_device, message["message"], message["description"], 47 | {"field": message["field"]}) 48 | 49 | 50 | class PhoneSentTest(APIBaseTestCase): 51 | def setUp(self): 52 | self.receiver = PhoneReceiver.objects.create(number="+18023390056", service_number="+18023390056") 53 | 54 | def test_send(self): 55 | with mock.patch("universal_notifications.backends.sms.base.SMS") as mocked_sms: 56 | # test sending a message with status different than PENDING or QUEUED 57 | message = PhoneSent.objects.create(receiver=self.receiver, text="123", status=PhoneSent.STATUS_FAILED) 58 | message.send() 59 | mocked_sms.assert_not_called() 60 | 61 | mocked_sms.reset_mock() 62 | 63 | # test sending a queued message 64 | message = PhoneSent.objects.create(receiver=self.receiver, text="123", status=PhoneSent.STATUS_QUEUED) 65 | message.send() 66 | mocked_sms.return_value.send.assert_called_with(message) 67 | 68 | 69 | class PhoneReceiverTest(APIBaseTestCase): 70 | def setUp(self): 71 | self.receiver = PhoneReceiver.objects.create(number="+18023390056", service_number="+18023390056") 72 | 73 | def test_filter(self): 74 | # test filtering with an incorrect number 75 | with self.assertRaises(PhoneReceiver.DoesNotExist): 76 | PhoneReceiver.objects.filter(number="random") 77 | 78 | # test filter 79 | qs = PhoneReceiver.objects.filter(number=self.receiver.number) 80 | self.assertEqual(qs.count(), 1) 81 | self.assertEqual(qs.first().pk, self.receiver.pk) 82 | -------------------------------------------------------------------------------- /tests/test_sms.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.test.utils import override_settings 3 | from tests.test_utils import APIBaseTestCase 4 | from universal_notifications.backends.sms.abstract import SMSEngineAbtract 5 | from universal_notifications.models import PhonePendingMessages, PhoneReceivedRaw, PhoneSent 6 | 7 | 8 | class SNSTestsCase(APIBaseTestCase): 9 | 10 | def setUp(self): 11 | self.engine = SMSEngineAbtract() 12 | 13 | def test_get_service_number(self): 14 | self.assertEqual(self.engine.get_service_number(), "") 15 | 16 | def test_add_to_queue(self): 17 | with self.assertRaises(NotImplementedError): 18 | obj = PhonePendingMessages() 19 | self.engine.add_to_queue(obj) 20 | 21 | def test_send(self): 22 | with self.assertRaises(NotImplementedError): 23 | obj = PhoneSent() 24 | self.engine.send(obj) 25 | 26 | def test_parse_received(self): 27 | with self.assertRaises(NotImplementedError): 28 | obj = PhoneReceivedRaw() 29 | self.engine.parse_received(obj) 30 | 31 | def test_validate_mobile(self): 32 | # disabled so allow all 33 | with override_settings(UNIVERSAL_NOTIFICATIONS_VALIDATE_MOBILE=False): 34 | self.assertTrue(self.engine.validate_mobile("fooo")) 35 | 36 | with override_settings(UNIVERSAL_NOTIFICATIONS_VALIDATE_MOBILE=True): 37 | self.assertFalse(self.engine.validate_mobile("+1")) 38 | self.assertTrue(self.engine.validate_mobile("+18023390050")) 39 | -------------------------------------------------------------------------------- /tests/test_sms_amazonsns.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from unittest import mock 3 | from django.test.utils import override_settings 4 | from tests.test_utils import APIBaseTestCase 5 | from universal_notifications.backends.sms.utils import send_sms 6 | from universal_notifications.models import PhoneReceiver, PhoneSent 7 | 8 | 9 | @override_settings(UNIVERSAL_NOTIFICATIONS_SMS_ENGINE="amazonsns") 10 | class AmazonSNSTestsCase(APIBaseTestCase): 11 | 12 | @override_settings(UNIVERSAL_NOTIFICATIONS_AMAZON_SNS_API_ENABLED=True) 13 | def test_send(self): 14 | with mock.patch("universal_notifications.backends.sms.engines.amazonsns.get_sns_client") as call_mock: 15 | call_mock.return_value.publish.return_value = {"MessageId": "mid"} 16 | send_sms("+18023390056", u"foo😄") 17 | 18 | mocked_data = { 19 | "Message": "foo", 20 | "PhoneNumber": "+18023390056", 21 | } 22 | self.assertEqual(call_mock.return_value.publish.call_args[1], mocked_data) 23 | r = PhoneReceiver.objects.get(number="+18023390056") 24 | s = PhoneSent.objects.all() 25 | self.assertEqual(s.count(), 1) 26 | self.assertEqual(s[0].receiver, r) 27 | self.assertEqual(s[0].sms_id, "mid") 28 | self.assertEqual(s[0].status, PhoneSent.STATUS_SENT) 29 | self.assertEqual(s[0].text, "foo") # Strip emoji - hard to setup with mysql base settings 30 | -------------------------------------------------------------------------------- /tests/test_sms_twilio.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from unittest import mock 3 | from django.core import mail 4 | from django.core.management import call_command 5 | from django.test.utils import override_settings 6 | from tests.test_utils import APIBaseTestCase 7 | from universal_notifications.backends.sms.engines.twilio import Engine as SMS # NOQA 8 | from universal_notifications.backends.sms.utils import send_sms 9 | from universal_notifications.models import (Phone, PhonePendingMessages, PhoneReceived, PhoneReceivedRaw, PhoneReceiver, 10 | PhoneSent) 11 | 12 | try: 13 | from django.urls import reverse 14 | except ImportError: 15 | # Django < 2.0 16 | from django.core.urlresolvers import reverse 17 | 18 | 19 | class TwilioTestsCase(APIBaseTestCase): 20 | 21 | def setUp(self): 22 | super(TwilioTestsCase, self).setUp() 23 | self.twilio_callback_url = reverse("twilio-callback") 24 | 25 | def create_raw_data(self, text, **kwargs): 26 | data = { 27 | "AccountSid": "fake", 28 | "ApiVersion": "2010-04-01", 29 | "Body": text, 30 | "From": "+18023390056", 31 | "FromCity": "WILMINGTON", 32 | "FromCountry": "US", 33 | "FromState": "VT", 34 | "FromZip": "05363", 35 | "SmsMessageSid": "1", 36 | "SmsSid": "1", 37 | "SmsStatus": "received", 38 | "To": "+18023390057", 39 | "ToCity": "GLENCOE", 40 | "ToCountry": "US", 41 | "ToState": "MN", 42 | "ToZip": "55336", 43 | } 44 | data.update(kwargs) 45 | return data 46 | 47 | 48 | @override_settings(UNIVERSAL_NOTIFICATIONS_TWILIO_ENABLE_PROXY=True) 49 | class ProxyTests(TwilioTestsCase): 50 | 51 | def setUp(self): 52 | super(ProxyTests, self).setUp() 53 | self.phone = Phone.objects.create(number="+18023390050") 54 | 55 | def test_add_to_proxy(self): 56 | def new_message(i, priority, from_phone="+18023390050"): 57 | m = PhonePendingMessages.objects.all().order_by("-id") 58 | self.assertEqual(m.count(), i) 59 | self.assertEqual(m[0].from_phone, from_phone) 60 | self.assertEqual(m[0].priority, priority) 61 | self.assertEqual(redis_mock.call_count, i) 62 | 63 | with mock.patch("universal_notifications.backends.sms.engines.twilio.StrictRedis") as redis_mock: 64 | send_sms("+18023390051", "foo") 65 | new_message(1, 9999) 66 | 67 | send_sms("+18023390051", "foo", priority=1) 68 | new_message(2, 1) 69 | 70 | def test_check_queue(self): 71 | with mock.patch("universal_notifications.backends.sms.engines.twilio.StrictRedis"): 72 | PhonePendingMessages.objects.create(from_phone="802-339-0057") 73 | PhonePendingMessages.objects.create(from_phone="802-339-0057") 74 | PhonePendingMessages.objects.create(from_phone="802-339-0058") 75 | 76 | with mock.patch("universal_notifications.management.commands.check_twilio_proxy." 77 | "StrictRedis.publish") as redis_mock: 78 | call_command("check_twilio_proxy") 79 | self.assertEqual(redis_mock.call_count, 2) 80 | 81 | 82 | class ReceivedTests(TwilioTestsCase): 83 | 84 | @override_settings(ADMINS=(("Admin", "foo@bar.com"),)) 85 | def test_verification(self): 86 | data = self.create_raw_data("foo") 87 | r = self.client.post(self.twilio_callback_url, data=data) 88 | self.assertEqual(r.status_code, 202) 89 | self.assertEqual(PhoneReceived.objects.count(), 1) 90 | raw = PhoneReceivedRaw.objects.all() 91 | self.assertEqual(raw.count(), 1) 92 | self.assertEqual(raw[0].status, PhoneReceivedRaw.STATUS_PASS) 93 | self.assertEqual(len(mail.outbox), 0) 94 | 95 | # Wrong account id 96 | PhoneReceived.objects.all().delete() 97 | PhoneReceivedRaw.objects.all().delete() 98 | data["AccountSid"] = "bar" 99 | r = self.client.post(self.twilio_callback_url, data=data) 100 | self.assertEqual(r.status_code, 202) 101 | self.assertEqual(PhoneReceived.objects.count(), 0) 102 | raw = PhoneReceivedRaw.objects.all() 103 | self.assertEqual(raw.count(), 1) 104 | self.assertEqual(raw[0].status, PhoneReceivedRaw.STATUS_REJECTED) 105 | self.assertEqual(len(mail.outbox), 1) 106 | 107 | def test_call(self): 108 | # TODO 109 | pass 110 | # data = self.create_raw_data("foo", Direction="inbound", CallStatus="ringing") 111 | # r = self.client.post(self.twilio_callback_url, data=data) 112 | # self.assertEqual(r.status_code, 200) 113 | # self.assertTrue("Hello, thanks for calling" in r.content) 114 | # self.assertEqual(PhoneReceiver.objects.count(), 0) 115 | 116 | # # Recording 117 | # self._create_user(is_superuser=True) 118 | # data = self.create_raw_data("foo", Direction="inbound", CallStatus="completed", RecordingUrl="http://foo.com") 119 | # r = self.client.post(self.twilio_callback_url, data=data) 120 | # self.assertEqual(r.status_code, 202) 121 | # self.assertEqual(len(mail.outbox), 1) 122 | 123 | def test_parse(self): 124 | data = self.create_raw_data(u"yes😄") 125 | r = self.client.post(self.twilio_callback_url, data=data) 126 | self.assertEqual(r.status_code, 202) 127 | self.assertEqual(PhoneReceived.objects.count(), 1) 128 | self.assertEqual(PhoneReceived.objects.first().text, "yes") # Strip emoji - hard to setup with mysql 129 | self.assertEqual(PhoneReceivedRaw.objects.count(), 1) 130 | self.assertEqual(PhoneReceivedRaw.objects.first().status, PhoneReceivedRaw.STATUS_PASS) 131 | self.assertEqual(PhoneReceiver.objects.count(), 1) 132 | self.assertFalse(PhoneReceiver.objects.first().is_blocked) 133 | 134 | # Do not add the same twice 135 | r = self.client.post(self.twilio_callback_url, data=data, format="multipart") 136 | self.assertEqual(r.status_code, 202) 137 | self.assertEqual(PhoneReceived.objects.count(), 1) 138 | self.assertEqual(PhoneReceivedRaw.objects.count(), 2) 139 | 140 | def test_parse_same_number_and_different_service_number(self): 141 | pr = PhoneReceiver.objects.create(number="+11111111111", service_number="+22222222222") 142 | data = self.create_raw_data(u"yes😄", From="+11111111111", To="+33333333333") 143 | r = self.client.post(self.twilio_callback_url, data=data) 144 | self.assertEqual(r.status_code, 202) 145 | 146 | raw = PhoneReceivedRaw.objects.get() 147 | assert raw.status == PhoneReceivedRaw.STATUS_PASS 148 | 149 | self.assertEqual(PhoneReceiver.objects.count(), 1) 150 | pr = PhoneReceiver.objects.get() 151 | self.assertEqual(pr.number, "+11111111111") 152 | self.assertEqual(pr.service_number, "+33333333333") 153 | 154 | def test_special_words(self): 155 | # stop 156 | data = self.create_raw_data(u"QuiT") 157 | r = self.client.post(self.twilio_callback_url, data=data, format="multipart") 158 | self.assertEqual(r.status_code, 202) 159 | self.assertEqual(PhoneReceiver.objects.count(), 1) 160 | self.assertTrue(PhoneReceiver.objects.first().is_blocked) 161 | 162 | # start 163 | data = self.create_raw_data(u"StarT", SmsMessageSid="2") 164 | r = self.client.post(self.twilio_callback_url, data=data, format="multipart") 165 | self.assertEqual(r.status_code, 202) 166 | self.assertEqual(PhoneReceiver.objects.count(), 1) 167 | self.assertFalse(PhoneReceiver.objects.first().is_blocked) 168 | 169 | 170 | class SentTests(TwilioTestsCase): 171 | 172 | def setUp(self): 173 | super(SentTests, self).setUp() 174 | self.phone = Phone.objects.create(number="+18023390050") 175 | 176 | @override_settings(UNIVERSAL_NOTIFICATIONS_TWILIO_API_ENABLED=True) 177 | def test_send(self): 178 | with mock.patch("universal_notifications.backends.sms.engines.twilio.get_twilio_client") as call_mock: 179 | call_mock.return_value.messages.create.return_value.sid = 123 180 | send_sms("+18023390056", u"foo😄") 181 | 182 | mocked_data = { 183 | "body": "foo", 184 | "to": "+18023390056", 185 | "from_": "+18023390050", 186 | } 187 | self.assertEqual(call_mock.return_value.messages.create.call_args[1], mocked_data) 188 | r = PhoneReceiver.objects.get(number="+18023390056") 189 | s = PhoneSent.objects.all() 190 | self.assertEqual(s.count(), 1) 191 | self.assertEqual(s[0].receiver, r) 192 | self.assertEqual(s[0].status, PhoneSent.STATUS_SENT) 193 | self.assertEqual(s[0].text, "foo") # Strip emoji - hard to setup with mysql base settings 194 | 195 | @override_settings(UNIVERSAL_NOTIFICATIONS_TWILIO_API_ENABLED=True) 196 | def test_send_blocked(self): 197 | r = PhoneReceiver.objects.create(number="+18023390056", service_number="+18023390056", is_blocked=True) 198 | with mock.patch("universal_notifications.backends.sms.engines.twilio.get_twilio_client") as call_mock: 199 | call_mock.return_value.messages.create.return_value.sid = 123 200 | send_sms("+18023390056", u"foo😄") 201 | 202 | self.assertEqual(call_mock.call_count, 0) 203 | s = PhoneSent.objects.all() 204 | self.assertEqual(s.count(), 1) 205 | self.assertEqual(s[0].receiver, r) 206 | self.assertEqual(s[0].status, PhoneSent.STATUS_FAILED) 207 | 208 | 209 | class UtilsTests(TwilioTestsCase): 210 | 211 | @override_settings(UNIVERSAL_NOTIFICATIONS_VALIDATE_MOBILE=True) 212 | def test_validate_mobile(self): 213 | sms = SMS() 214 | 215 | self.assertFalse(sms.validate_mobile("+1")) 216 | with mock.patch("universal_notifications.backends.sms.engines.twilio.get_twilio_client") as twilio_mock: 217 | twilio_mock.return_value.phone_numbers.get.return_value.carrier = {"type": "foo"} 218 | self.assertFalse(sms.validate_mobile("+18023390050")) 219 | self.assertEqual(twilio_mock.return_value.phone_numbers.get.call_args[0], ("+18023390050",)) 220 | self.assertEqual(twilio_mock.return_value.phone_numbers.get.call_args[1], {"include_carrier_info": True}) 221 | 222 | # twilio_mock.return_value.phone_numbers.get.return_value.carrier.type = "mobile" 223 | twilio_mock.return_value.phone_numbers.get.return_value.carrier = {"type": "mobile"} 224 | self.assertTrue(sms.validate_mobile("+18023390050")) 225 | 226 | twilio_mock.return_value.phone_numbers.get.return_value.carrier = {"type": "voip"} 227 | self.assertTrue(sms.validate_mobile("+18023390050")) 228 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from collections import OrderedDict 3 | 4 | from django.contrib.auth import get_user_model 5 | from django.http import HttpRequest 6 | from django.test import TestCase 7 | from rest_framework.authtoken.models import Token 8 | from rest_framework.test import APITestCase 9 | 10 | UserModel = get_user_model() 11 | 12 | 13 | class BaseTestCase(TestCase): 14 | maxDiff = None 15 | password = "dump-password" 16 | 17 | def toDict(self, obj): 18 | """ 19 | Helper function to make errors in tests more readable - use to parse answer before compare 20 | """ 21 | if not isinstance(obj, (dict, list, OrderedDict)): 22 | return obj 23 | if isinstance(obj, OrderedDict): 24 | obj = dict(obj) 25 | for k, v in obj.items(): 26 | new_v = v 27 | if isinstance(v, list): 28 | new_v = [] 29 | for v2 in v: 30 | v2 = self.toDict(v2) 31 | new_v.append(v2) 32 | elif isinstance(v, OrderedDict): 33 | new_v = dict(v) 34 | obj[k] = new_v 35 | return obj 36 | 37 | def get_context(self, user=None): 38 | if user is None: 39 | user = self.user 40 | request = HttpRequest() 41 | request.user = user 42 | context = { 43 | "request": request 44 | } 45 | return context 46 | 47 | def _login(self, user=None): 48 | email = "joe+1@doe.com" 49 | if user: 50 | email = user.email 51 | 52 | self.client.login(email=email, password=self.password) 53 | 54 | def _new_user(self, i, is_active=True, **kwargs): 55 | user = UserModel( 56 | first_name="Joe%i" % i, 57 | last_name="Doe", 58 | email="joe+%s@doe.com" % i, 59 | username="joe%i" % i, 60 | is_active=is_active, 61 | **kwargs 62 | ) 63 | user.set_password(self.password) 64 | user.save() 65 | return user 66 | 67 | def _create_user(self, i=1, set_self=True, **kwargs): 68 | user = self._new_user(i, **kwargs) 69 | if set_self: 70 | self.user = user 71 | 72 | return user 73 | 74 | 75 | class APIBaseTestCase(BaseTestCase, APITestCase): 76 | 77 | def _login(self, user=None): 78 | if not user: 79 | user = UserModel.objects.get(email="joe+1@doe.com") 80 | 81 | token = Token.objects.create(user=user) 82 | self.client.credentials(HTTP_AUTHORIZATION="Token " + token.key) 83 | return self.client.login(email=user.email, password=self.password) 84 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.urls import include, re_path 3 | from django.contrib import admin 4 | 5 | admin.autodiscover() 6 | 7 | urlpatterns = [ 8 | re_path(r"", include("universal_notifications.urls")), 9 | re_path(r"^admin/", admin.site.urls), 10 | ] 11 | -------------------------------------------------------------------------------- /tests/user_conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Helper file. Contains user configuration for user categories mapping. 4 | Usage: settings.UNIVERSAL_NOTIFICATIONS_USER_DEFINITIONS_FILE 5 | """ 6 | 7 | 8 | def for_admin(user): 9 | return user.is_superuser 10 | 11 | 12 | def for_user(user): 13 | return not user.is_superuser 14 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts=--tb=short 3 | 4 | [tox] 5 | envlist = 6 | py{311}-lint 7 | py{39,310,311}-django{32,42} 8 | 9 | 10 | [testenv] 11 | commands = python runtests.py --fast {posargs} --coverage -rw 12 | setenv = 13 | PYTHONDONTWRITEBYTECODE=1 14 | PYTHONWARNINGS=once 15 | deps = 16 | django32: Django>=3.2,<3.3 17 | django42: Django>=4.2,<4.3 18 | -rrequirements/requirements-base.txt 19 | -rrequirements/requirements-testing.txt 20 | 21 | [testenv:py38-lint] 22 | commands = python runtests.py --lintonly 23 | deps = 24 | -rrequirements/requirements-codestyle.txt 25 | -rrequirements/requirements-testing.txt 26 | -------------------------------------------------------------------------------- /universal_notifications/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __title__ = "Universal Notifications" 3 | __version__ = "1.6.0" 4 | __author__ = "Pawel Krzyzaniak" 5 | __license__ = "MIT" 6 | __copyright__ = "Copyright 2017-2018 Arabella; 2018+ Ro" 7 | 8 | # Version synonym 9 | VERSION = __version__ 10 | -------------------------------------------------------------------------------- /universal_notifications/admin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.contrib import admin 3 | 4 | from universal_notifications.models import (NotificationHistory, Phone, PhoneReceived, PhoneReceivedRaw, PhoneReceiver, 5 | PhoneSent, UnsubscribedUser) 6 | 7 | # TODO: think about adding django-safedelete 8 | 9 | 10 | class NotificationHistoryAdmin(admin.ModelAdmin): 11 | list_display = ("receiver", "created", "group", "klass", "details") 12 | readonly_fields = ("receiver", "created", "group", "klass", "details") 13 | 14 | 15 | class PhoneSentAdmin(admin.ModelAdmin): 16 | list_display = ("created", "sms_id", "status", "updated", "error_message") 17 | raw_id_fields = ("receiver",) 18 | search_fields = ("receiver__number",) 19 | 20 | 21 | class PhoneReceivedAdmin(admin.ModelAdmin): 22 | list_display = ("sms_id", "created", "updated") 23 | raw_id_fields = ("receiver", "raw") 24 | 25 | 26 | class PhoneReceivedRawAdmin(admin.ModelAdmin): 27 | list_display = ("created", "status",) 28 | 29 | 30 | class PhoneReceiverAdmin(admin.ModelAdmin): 31 | list_display = ("number", "service_number", "is_blocked") 32 | 33 | 34 | class PhoneAdmin(admin.ModelAdmin): 35 | list_display = ("number", "used_count") 36 | readonly_fields = ("used_count",) 37 | 38 | 39 | admin.site.register(PhoneSent, PhoneSentAdmin) 40 | admin.site.register(PhoneReceived, PhoneReceivedAdmin) 41 | admin.site.register(PhoneReceivedRaw, PhoneReceivedRawAdmin) 42 | admin.site.register(PhoneReceiver, PhoneReceiverAdmin) 43 | admin.site.register(Phone, PhoneAdmin) 44 | admin.site.register(UnsubscribedUser) 45 | admin.site.register(NotificationHistory, NotificationHistoryAdmin) 46 | -------------------------------------------------------------------------------- /universal_notifications/api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import coreapi 3 | from rest_framework import status 4 | from rest_framework.generics import CreateAPIView, DestroyAPIView, GenericAPIView 5 | from rest_framework.mixins import RetrieveModelMixin, UpdateModelMixin 6 | from rest_framework.permissions import AllowAny, IsAuthenticated 7 | from rest_framework.response import Response 8 | from rest_framework.views import APIView 9 | from rest_framework_swagger.renderers import OpenAPIRenderer 10 | 11 | from universal_notifications.docs import NotificationsDocs 12 | from universal_notifications.models import Device, UnsubscribedUser 13 | from universal_notifications.serializers import DeviceSerializer, UnsubscribedSerializer 14 | 15 | 16 | class DevicesAPI(CreateAPIView): 17 | serializer_class = DeviceSerializer 18 | 19 | def create(self, request, *args, **kwargs): 20 | response = super(DevicesAPI, self).create(request, *args, **kwargs) 21 | if getattr(self, "_matching_device", None): 22 | response.status_code = status.HTTP_200_OK 23 | 24 | return response 25 | 26 | 27 | class DeviceDetailsAPI(DestroyAPIView): 28 | queryset = Device.objects.all() 29 | serializer_class = DeviceSerializer 30 | 31 | def get_queryset(self): 32 | return super(DeviceDetailsAPI, self).get_queryset().filter(user=self.request.user) 33 | 34 | 35 | class SubscriptionsAPI(RetrieveModelMixin, UpdateModelMixin, GenericAPIView): # we don't want patch here 36 | serializer_class = UnsubscribedSerializer 37 | queryset = UnsubscribedUser.objects.all() 38 | permission_classes = (IsAuthenticated,) 39 | 40 | def get_object(self): 41 | obj, created = UnsubscribedUser.objects.get_or_create(user=self.request.user) 42 | return obj 43 | 44 | def get(self, request, *args, **kwargs): 45 | return self.retrieve(request, *args, **kwargs) 46 | 47 | def put(self, request, *args, **kwargs): 48 | return self.update(request, *args, **kwargs) 49 | 50 | 51 | class UniversalNotificationsDocsView(APIView): 52 | permission_classes = [AllowAny] 53 | _ignore_model_permissions = True 54 | exclude_from_schema = True 55 | renderer_classes = [ 56 | OpenAPIRenderer 57 | ] 58 | 59 | def get(self, request): 60 | links = {} 61 | for path in NotificationsDocs.get_types(): 62 | links.update(NotificationsDocs.generate_notifications_docs(path)) 63 | 64 | schema = coreapi.Document(url=self.request.build_absolute_uri(), title="Notifications Docs", content=links) 65 | return Response(schema) 66 | -------------------------------------------------------------------------------- /universal_notifications/api_urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.urls import re_path 3 | from rest_framework.routers import DefaultRouter 4 | 5 | from universal_notifications.api import DeviceDetailsAPI, DevicesAPI, SubscriptionsAPI, UniversalNotificationsDocsView 6 | from universal_notifications.backends.twilio.api import TwilioAPI 7 | 8 | urlpatterns = [ 9 | re_path(r"^devices$", DevicesAPI.as_view(), name="notifications-devices"), 10 | re_path(r"^devices/(?P\d+)$", DeviceDetailsAPI.as_view(), name="device-details"), 11 | re_path(r"^twilio$", TwilioAPI.as_view(), name="twilio-callback"), 12 | re_path(r"^subscriptions$", SubscriptionsAPI.as_view(), name="notifications-subscriptions"), 13 | re_path(r"^api-docs/", UniversalNotificationsDocsView.as_view(), name="notifications-docs"), 14 | ] 15 | 16 | router = DefaultRouter() 17 | urlpatterns += router.urls 18 | -------------------------------------------------------------------------------- /universal_notifications/backends/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HealthByRo/universal_notifications/67552a42ff7625971e0b2aa7ee42984db8be159c/universal_notifications/backends/__init__.py -------------------------------------------------------------------------------- /universal_notifications/backends/emails/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HealthByRo/universal_notifications/67552a42ff7625971e0b2aa7ee42984db8be159c/universal_notifications/backends/emails/__init__.py -------------------------------------------------------------------------------- /universal_notifications/backends/emails/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HealthByRo/universal_notifications/67552a42ff7625971e0b2aa7ee42984db8be159c/universal_notifications/backends/emails/models.py -------------------------------------------------------------------------------- /universal_notifications/backends/emails/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | from django.contrib.admin.views.decorators import staff_member_required 3 | 4 | from universal_notifications.backends.emails.views import FakeEmailSend 5 | 6 | urlpatterns = [ 7 | re_path(r"$", staff_member_required(FakeEmailSend.as_view()), name="backends.emails.fake_view"), 8 | ] 9 | -------------------------------------------------------------------------------- /universal_notifications/backends/emails/views.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.template import TemplateDoesNotExist 3 | from django.views.generic import TemplateView 4 | 5 | from universal_notifications.notifications import EmailNotification 6 | 7 | 8 | def fake_email_notification_factory(template): 9 | class FakeEmailNotification(EmailNotification): 10 | email_name = template 11 | email_subject = "Fake email!" 12 | check_subscription = False 13 | 14 | def format_receiver(cls, receiver): 15 | return receiver 16 | 17 | def format_receiver_for_notification_history(self, receiver): 18 | return receiver 19 | 20 | return FakeEmailNotification 21 | 22 | 23 | class FakeEmailSend(TemplateView): 24 | template_name = "emails/fake.html" 25 | 26 | def get_context_data(self, **kwargs): 27 | context = super(FakeEmailSend, self).get_context_data(**kwargs) 28 | context["template"] = self.request.GET.get("template") 29 | context["email"] = getattr(settings, "UNIVERSAL_NOTIFICATIONS_FAKE_EMAIL_TO", None) 30 | if context["template"] and context["email"]: 31 | FakeEmailNotification = fake_email_notification_factory(context["template"]) 32 | try: 33 | FakeEmailNotification(None, [context["email"]], {}).send() 34 | except TemplateDoesNotExist: 35 | context["template_does_not_exist"] = True 36 | return context 37 | -------------------------------------------------------------------------------- /universal_notifications/backends/push/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HealthByRo/universal_notifications/67552a42ff7625971e0b2aa7ee42984db8be159c/universal_notifications/backends/push/__init__.py -------------------------------------------------------------------------------- /universal_notifications/backends/push/apns.py: -------------------------------------------------------------------------------- 1 | """ 2 | Apple Push Notification Service 3 | Documentation is available on the iOS Developer Library: 4 | https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/ApplePushService.html 5 | """ 6 | 7 | import json 8 | import socket 9 | import ssl 10 | import struct 11 | import time 12 | from binascii import unhexlify 13 | from contextlib import closing 14 | 15 | from django.core.exceptions import ImproperlyConfigured 16 | from push_notifications import NotificationError 17 | from push_notifications.settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS 18 | 19 | from universal_notifications.backends.push.utils import get_app_settings 20 | 21 | 22 | class APNSError(NotificationError): 23 | pass 24 | 25 | 26 | class APNSServerError(APNSError): 27 | def __init__(self, status, identifier): 28 | super(APNSServerError, self).__init__(status, identifier) 29 | self.status = status 30 | self.identifier = identifier 31 | 32 | 33 | class APNSDataOverflow(APNSError): 34 | pass 35 | 36 | 37 | def _apns_create_socket(address_tuple, app_id): 38 | app_settings = get_app_settings(app_id) 39 | if not app_settings: 40 | raise ImproperlyConfigured('You need to set UNIVERSAL_NOTIFICATIONS_MOBILE_APPS[app_id]' 41 | ' to send messages through APNS') 42 | 43 | certfile = app_settings.get("APNS_CERTIFICATE") 44 | if not certfile: 45 | raise ImproperlyConfigured( 46 | 'You need to set UNIVERSAL_NOTIFICATIONS_MOBILE_APPS[app_id]["APNS_CERTIFICATE"] ' 47 | 'to send messages through APNS.' 48 | ) 49 | 50 | try: 51 | with open(certfile, "r") as f: 52 | f.read() 53 | except Exception as e: 54 | raise ImproperlyConfigured("The APNS certificate file at %r is not readable: %s" % (certfile, e)) 55 | 56 | sock = socket.socket() 57 | sock = ssl.wrap_socket(sock, ssl_version=ssl.PROTOCOL_TLSv1, certfile=certfile) 58 | sock.connect(address_tuple) 59 | 60 | return sock 61 | 62 | 63 | def _apns_create_socket_to_push(app_id): 64 | return _apns_create_socket((SETTINGS["APNS_HOST"], SETTINGS["APNS_PORT"]), app_id) 65 | 66 | 67 | def _apns_pack_frame(token_hex, payload, identifier, expiration, priority): 68 | token = unhexlify(token_hex) 69 | # |COMMAND|FRAME-LEN|{token}|{payload}|{id:4}|{expiration:4}|{priority:1} 70 | frame_len = 3 * 5 + len(token) + len(payload) + 4 + 4 + 1 # 5 items, each 3 bytes prefix, then each item length 71 | frame_fmt = "!BIBH%ssBH%ssBHIBHIBHB" % (len(token), len(payload)) 72 | frame = struct.pack( 73 | frame_fmt, 74 | 2, frame_len, 75 | 1, len(token), token, 76 | 2, len(payload), payload, 77 | 3, 4, identifier, 78 | 4, 4, expiration, 79 | 5, 1, priority) 80 | 81 | return frame 82 | 83 | 84 | def _apns_check_errors(sock): 85 | timeout = SETTINGS["APNS_ERROR_TIMEOUT"] 86 | if timeout is None: 87 | return # assume everything went fine! 88 | saved_timeout = sock.gettimeout() 89 | try: 90 | sock.settimeout(timeout) 91 | data = sock.recv(6) 92 | if data: 93 | command, status, identifier = struct.unpack("!BBI", data) 94 | # apple protocol says command is always 8. See http://goo.gl/ENUjXg 95 | assert command == 8, "Command must be 8!" 96 | if status != 0: 97 | raise APNSServerError(status, identifier) 98 | except socket.timeout: # py3, see http://bugs.python.org/issue10272 99 | pass 100 | except ssl.SSLError as e: # py2 101 | if "timed out" not in e.message: 102 | raise 103 | except AttributeError: 104 | pass 105 | finally: 106 | sock.settimeout(saved_timeout) 107 | 108 | 109 | def _apns_send(app_id, token, alert, badge=None, sound=None, category=None, content_available=False, 110 | action_loc_key=None, loc_key=None, loc_args=[], extra={}, identifier=0, 111 | expiration=None, priority=10, socket=None): 112 | data = {} 113 | aps_data = {} 114 | 115 | if action_loc_key or loc_key or loc_args: 116 | alert = alert or {} 117 | if action_loc_key: 118 | alert["action-loc-key"] = action_loc_key 119 | if loc_key: 120 | alert["loc-key"] = loc_key 121 | if loc_args: 122 | alert["loc-args"] = loc_args 123 | 124 | if alert is not None: 125 | aps_data["alert"] = alert 126 | 127 | if badge is not None: 128 | aps_data["badge"] = badge 129 | 130 | if sound is not None: 131 | aps_data["sound"] = sound 132 | 133 | if category is not None: 134 | aps_data["category"] = category 135 | 136 | if content_available: 137 | aps_data["content-available"] = 1 138 | 139 | data["aps"] = aps_data 140 | data.update(extra) 141 | 142 | # convert to json, avoiding unnecessary whitespace with separators (keys sorted for tests) 143 | json_data = json.dumps(data, separators=(",", ":"), sort_keys=True).encode("utf-8") 144 | 145 | max_size = SETTINGS["APNS_MAX_NOTIFICATION_SIZE"] 146 | if len(json_data) > max_size: 147 | raise APNSDataOverflow("Notification body cannot exceed %i bytes" % (max_size)) 148 | 149 | # if expiration isn't specified use 1 month from now 150 | expiration_time = expiration if expiration is not None else int(time.time()) + 2592000 151 | 152 | frame = _apns_pack_frame(token, json_data, identifier, expiration_time, priority) 153 | 154 | if socket: 155 | socket.write(frame) 156 | else: 157 | with closing(_apns_create_socket_to_push(app_id)) as socket: 158 | socket.write(frame) 159 | _apns_check_errors(socket) 160 | 161 | 162 | def apns_send_message(device, message=None, description=None, data=None): 163 | """ 164 | Sends an APNS notification to a single registration_id. 165 | This will send the notification as form data. 166 | 167 | Note that if set message should always be a string. If it is not set, 168 | it won't be included in the notification. You will need to pass None 169 | to this for silent notifications. 170 | """ 171 | alert = { 172 | "title": message, 173 | "body": description 174 | } 175 | # do not send title if description is not provided (notification must contain the body param) 176 | if not description: 177 | alert = { 178 | "body": message 179 | } 180 | 181 | data = data or {} 182 | _apns_send(device.app_id, device.notification_token, alert, **data) 183 | -------------------------------------------------------------------------------- /universal_notifications/backends/push/fcm.py: -------------------------------------------------------------------------------- 1 | # Send to single device. 2 | from pyfcm import FCMNotification 3 | 4 | from universal_notifications.backends.push.utils import get_app_settings 5 | 6 | 7 | def fcm_send_message(device, message, data=None): 8 | app_settings = get_app_settings(device.app_id) 9 | api_key = app_settings.get('FCM_API_KEY') 10 | if not app_settings or not api_key: 11 | return 12 | 13 | push_service = FCMNotification(api_key=api_key) 14 | return push_service.notify_single_device( 15 | registration_id=device.notification_token, 16 | message_body=message, 17 | data_message=data 18 | ) 19 | -------------------------------------------------------------------------------- /universal_notifications/backends/push/gcm.py: -------------------------------------------------------------------------------- 1 | """ 2 | Google Cloud Messaging 3 | Previously known as C2DM 4 | Documentation is available on the Android Developer website: 5 | https://developer.android.com/google/gcm/index.html 6 | """ 7 | from django.core.exceptions import ImproperlyConfigured 8 | from push_notifications import NotificationError 9 | from push_notifications.settings import PUSH_NOTIFICATIONS_SETTINGS as SETTINGS 10 | 11 | from universal_notifications.backends.push.utils import get_app_settings 12 | 13 | try: 14 | from urllib.parse import urlencode 15 | from urllib.request import Request, urlopen 16 | except ImportError: 17 | # Python 2 support 18 | from urllib import urlencode 19 | 20 | from urllib2 import Request, urlopen 21 | 22 | 23 | class GCMError(NotificationError): 24 | pass 25 | 26 | 27 | def _gcm_send(app_id, data, content_type): 28 | app_settings = get_app_settings(app_id) 29 | key = app_settings.get('GCM_API_KEY') 30 | if not key: 31 | raise ImproperlyConfigured("No GCM API key set") 32 | 33 | headers = { 34 | "Content-Type": content_type, 35 | "Authorization": "key=%s" % (key), 36 | "Content-Length": str(len(data)), 37 | } 38 | 39 | request = Request(SETTINGS["GCM_POST_URL"], data, headers) 40 | return urlopen(request).read() 41 | 42 | 43 | def _gcm_send_plain(device, data, collapse_key=None, delay_while_idle=False, time_to_live=0): 44 | """ 45 | Sends a GCM notification to a single registration_id. 46 | This will send the notification as form data. 47 | If sending multiple notifications, it is more efficient to use 48 | gcm_send_bulk_message() with a list of registration_ids 49 | """ 50 | 51 | values = {"registration_id": device.notification_token} 52 | 53 | if collapse_key: 54 | values["collapse_key"] = collapse_key 55 | 56 | if delay_while_idle: 57 | values["delay_while_idle"] = int(delay_while_idle) 58 | 59 | if time_to_live: 60 | values["time_to_live"] = time_to_live 61 | 62 | for k, v in data.items(): 63 | values["data.%s" % (k)] = v.encode("utf-8") 64 | 65 | data = urlencode(sorted(values.items())).encode("utf-8") # sorted items for tests 66 | 67 | result = _gcm_send(device.app_id, data, "application/x-www-form-urlencoded;charset=UTF-8") 68 | if result.startswith("Error="): 69 | raise GCMError(result) 70 | return result 71 | 72 | 73 | def gcm_send_message(device, message, data=None, collapse_key=None, delay_while_idle=False, time_to_live=0): 74 | """ 75 | Sends a GCM notification to a single registration_id. 76 | 77 | This will send the notification as form data if possible, otherwise it will 78 | fall back to json data. 79 | """ 80 | data["message"] = message 81 | args = data, collapse_key, delay_while_idle, time_to_live 82 | try: 83 | return _gcm_send_plain(device, *args) 84 | except GCMError: 85 | # TODO: check error and maybe deactivate user 86 | return False 87 | -------------------------------------------------------------------------------- /universal_notifications/backends/push/utils.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def get_app_settings(app_id): 5 | return getattr(settings, "UNIVERSAL_NOTIFICATIONS_MOBILE_APPS", {}).get(app_id) 6 | -------------------------------------------------------------------------------- /universal_notifications/backends/sms/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HealthByRo/universal_notifications/67552a42ff7625971e0b2aa7ee42984db8be159c/universal_notifications/backends/sms/__init__.py -------------------------------------------------------------------------------- /universal_notifications/backends/sms/abstract.py: -------------------------------------------------------------------------------- 1 | import phonenumbers 2 | from django.conf import settings 3 | 4 | 5 | class SMSEngineAbtract(object): 6 | 7 | def get_service_number(self): 8 | return '' 9 | 10 | def add_to_queue(self, obj): 11 | self.send(obj.message) 12 | obj.message.save() 13 | 14 | def send(self, obj, **kwargs): 15 | raise NotImplementedError 16 | 17 | def parse_received(self, raw): 18 | raise NotImplementedError 19 | 20 | def validate_mobile(self, value): 21 | """Validate if number is mobile 22 | 23 | Arguments: 24 | value {string|phonenumbers.PhoneNumber} -- phone number 25 | 26 | Returns: 27 | bool -- return True if number is mobile 28 | """ 29 | if not settings.UNIVERSAL_NOTIFICATIONS_VALIDATE_MOBILE: 30 | return True 31 | 32 | if not isinstance(value, phonenumbers.PhoneNumber): 33 | try: 34 | value = phonenumbers.parse(value, 'US') 35 | except phonenumbers.phonenumberutil.NumberParseException: 36 | return False 37 | return value 38 | -------------------------------------------------------------------------------- /universal_notifications/backends/sms/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.conf import settings 3 | 4 | try: 5 | from django.utils.importlib import import_module 6 | except ImportError: 7 | from importlib import import_module 8 | 9 | 10 | class SMS(object): 11 | 12 | def __new__(cls): 13 | __symbol = getattr(settings, 'UNIVERSAL_NOTIFICATIONS_SMS_ENGINE', 'Twilio').lower() 14 | return getattr(import_module('universal_notifications.backends.sms.engines.' + __symbol), 'Engine')() 15 | -------------------------------------------------------------------------------- /universal_notifications/backends/sms/engines/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HealthByRo/universal_notifications/67552a42ff7625971e0b2aa7ee42984db8be159c/universal_notifications/backends/sms/engines/__init__.py -------------------------------------------------------------------------------- /universal_notifications/backends/sms/engines/amazonsns.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | from django.conf import settings 3 | 4 | from universal_notifications.backends.sms.abstract import SMSEngineAbtract 5 | from universal_notifications.models import PhoneSent 6 | 7 | 8 | def get_sns_client(lookups=False): 9 | return boto3.client( 10 | 'sns', 11 | aws_access_key_id=getattr(settings, 'AWS_ACCESS_KEY_ID', ''), 12 | aws_secret_access_key=getattr(settings, 'AWS_SECRET_ACCESS_KEY', ''), 13 | region_name=getattr(settings, 'AWS_DEFAULT_REGION', 'us-east-1'), 14 | ) 15 | 16 | 17 | class Engine(SMSEngineAbtract): 18 | 19 | def send(self, obj): 20 | if not getattr(settings, 'UNIVERSAL_NOTIFICATIONS_AMAZON_SNS_API_ENABLED', False): 21 | self.status = PhoneSent.STATUS_SENT 22 | return 23 | 24 | if not obj.sms_id: 25 | try: 26 | sns_client = get_sns_client() 27 | response = sns_client.publish( 28 | PhoneNumber=obj.receiver.number, 29 | Message=obj.text, 30 | ) 31 | obj.status = PhoneSent.STATUS_SENT 32 | obj.sms_id = response['MessageId'] 33 | 34 | except Exception as e: 35 | obj.error_message = e 36 | obj.status = PhoneSent.STATUS_FAILED 37 | -------------------------------------------------------------------------------- /universal_notifications/backends/sms/engines/twilio.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import phonenumbers 4 | from django.conf import settings 5 | from redis import StrictRedis 6 | from rest_framework.renderers import JSONRenderer 7 | from twilio.base.exceptions import TwilioException 8 | from twilio.rest import Client 9 | from twilio.rest.lookups import Lookups 10 | from ws4redis import settings as private_settings 11 | from ws4redis.redis_store import RedisMessage 12 | 13 | from universal_notifications.backends.sms.abstract import SMSEngineAbtract 14 | from universal_notifications.backends.sms.utils import report_admins 15 | from universal_notifications.models import Phone, PhoneReceived, PhoneReceivedRaw, PhoneReceiver, PhoneSent 16 | 17 | 18 | def get_twilio_client(lookups=False): 19 | if lookups: 20 | return Lookups(settings.UNIVERSAL_NOTIFICATIONS_TWILIO_ACCOUNT, 21 | settings.UNIVERSAL_NOTIFICATIONS_TWILIO_TOKEN) 22 | return Client(settings.UNIVERSAL_NOTIFICATIONS_TWILIO_ACCOUNT, 23 | settings.UNIVERSAL_NOTIFICATIONS_TWILIO_TOKEN) 24 | 25 | 26 | class Engine(SMSEngineAbtract): 27 | 28 | def get_service_number(self): 29 | phone = Phone.objects.all().order_by('used_count').first() 30 | if not phone: 31 | return '' 32 | phone.used_count += 1 33 | phone.save() 34 | return phone.number 35 | 36 | def add_to_queue(self, obj): 37 | if getattr(settings, 'UNIVERSAL_NOTIFICATIONS_TWILIO_ENABLE_PROXY', False): 38 | connection = StrictRedis(**private_settings.WS4REDIS_CONNECTION) 39 | r = JSONRenderer() 40 | json_data = r.render({'number': obj.from_phone}) 41 | channel = getattr(settings, 'UNIVERSAL_NOTIFICATIONS_TWILIO_DISPATCHER_CHANNEL', '__un_twilio_dispatcher') 42 | connection.publish(channel, RedisMessage(json_data)) 43 | else: 44 | self.send(obj.message) 45 | obj.message.save() 46 | 47 | def send(self, obj): 48 | if not getattr(settings, 'UNIVERSAL_NOTIFICATIONS_TWILIO_API_ENABLED', False): 49 | self.status = PhoneSent.STATUS_SENT 50 | return 51 | 52 | if not obj.sms_id: 53 | try: 54 | obj.status = PhoneSent.STATUS_SENT 55 | if not obj.text: 56 | obj.text = '.' # hack for MMS 57 | twilio_client = get_twilio_client() 58 | params = { 59 | 'body': obj.text, 60 | 'to': obj.receiver.number, 61 | 'from_': obj.receiver.service_number, 62 | } 63 | if obj.media: 64 | if obj.media.startswith(('http://', 'https://')): 65 | params['media_url'] = obj.media 66 | else: 67 | params['media_url'] = "%s%s" % (settings.MEDIA_URL, obj.media_raw) 68 | message = twilio_client.messages.create(**params) 69 | obj.sms_id = message.sid 70 | except TwilioException as e: 71 | obj.error_message = e 72 | obj.status = PhoneSent.STATUS_FAILED 73 | 74 | def parse_received(self, raw): 75 | if raw.data.get('AccountSid') != settings.UNIVERSAL_NOTIFICATIONS_TWILIO_ACCOUNT: 76 | raw.status = PhoneReceivedRaw.STATUS_REJECTED 77 | raw.save() 78 | report_admins('Rejected incoming Twilio message', raw) 79 | return 80 | 81 | if raw.data.get('Direction') == 'inbound': 82 | # incoming voice call, handle only recording 83 | if raw.data.get('RecordingUrl'): 84 | # TODO: handle calls 85 | # variables = {'data': raw.data} 86 | # TODO: handle calls 87 | # for user in Account.objects.filter(is_superuser=True): 88 | # send_email('voice_mail', user.email, 'Patient leaved a voice mail', variables) 89 | pass 90 | elif raw.data.get('SmsStatus') == 'received': 91 | receiver, created = PhoneReceiver.objects.update_or_create( 92 | number=raw.data.get('From'), 93 | defaults={"service_number": raw.data.get('To')} 94 | ) 95 | if raw.data.get('SmsMessageSid', ''): 96 | try: 97 | PhoneReceived.objects.get(sms_id=raw.data.get('SmsMessageSid', '')) 98 | return 99 | except PhoneReceived.DoesNotExist: 100 | pass 101 | 102 | message = PhoneReceived() 103 | message.receiver = receiver 104 | message.media = raw.data.get('MediaUrl0', '') 105 | message.sms_id = raw.data.get('SmsSid', '') 106 | message.text = raw.data.get('Body', '') 107 | message.type = 'text' 108 | message.raw = raw 109 | 110 | stop_words = getattr(settings, 'UNIVERSAL_NOTIFICATIONS_TWILIO_STOP_WORDS', 111 | ('stop', 'unsubscribe', 'cancel', 'quit', 'end')) 112 | if message.text.lower() in stop_words and not receiver.is_blocked: 113 | receiver.is_blocked = True 114 | receiver.save() 115 | message.is_opt_out = True 116 | 117 | message.save() 118 | 119 | start_words = getattr(settings, 'UNIVERSAL_NOTIFICATIONS_TWILIO_START_WORDS', ('start',)) 120 | if message.text.lower() in start_words and receiver.is_blocked: 121 | receiver.is_blocked = False 122 | receiver.save() 123 | else: 124 | try: 125 | message = PhoneSent.objects.get(sms_id=raw.data.get('SmsSid')) 126 | except PhoneSent.DoesNotExist: 127 | return True 128 | 129 | message.status = raw.data.get('SmsStatus') 130 | message.error_code = raw.data.get('ErrorCode') 131 | message.error_message = raw.data.get('ErrorMessage') 132 | error_codes = getattr(settings, 'UNIVERSAL_NOTIFICATIONS_TWILIO_REPORT_ERRORS', 133 | [30001, 30006, 30007, 30009]) 134 | if message.error_code in error_codes: 135 | # special report for broken integration/number 136 | report_admins('Message issue', raw) 137 | message.save() 138 | return True 139 | 140 | def validate_mobile(self, value): 141 | """Validate if number is mobile 142 | 143 | Lookup Twilio info about number and validate if carrier is mobile or voip 144 | 145 | Arguments: 146 | value {string|phonenumbers.PhoneNumber} -- phone number 147 | 148 | Returns: 149 | bool -- return True if number is mobile 150 | """ 151 | if not settings.UNIVERSAL_NOTIFICATIONS_VALIDATE_MOBILE: 152 | return True 153 | 154 | value = super(Engine, self).validate_mobile(value) 155 | if not value: 156 | return False 157 | 158 | number = phonenumbers.format_number(value, phonenumbers.PhoneNumberFormat.E164) 159 | client = get_twilio_client(lookups=True) 160 | response = client.phone_numbers.get(number, include_carrier_info=True) 161 | if response.carrier['type'] not in ['voip', 'mobile']: 162 | return False 163 | return True 164 | -------------------------------------------------------------------------------- /universal_notifications/backends/sms/signals.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.conf import settings 3 | 4 | try: 5 | from django.utils.importlib import import_module 6 | except ImportError: 7 | from importlib import import_module 8 | 9 | try: 10 | __path, __symbol = getattr(settings, 'PHONE_RECEIVED_POST_SAVE_FUNC').rsplit('.', 1) 11 | phone_received_post_save = getattr(import_module(__path), __symbol) 12 | except (AttributeError, ImportError): 13 | def phone_received_post_save(sender, instance, created, **kwargs): 14 | pass 15 | -------------------------------------------------------------------------------- /universal_notifications/backends/sms/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import phonenumbers 4 | from django.conf import settings 5 | from django.contrib.sites.models import Site 6 | from django.core.mail import mail_admins 7 | 8 | try: 9 | from django.utils.importlib import import_module 10 | except ImportError: 11 | from importlib import import_module 12 | 13 | 14 | try: 15 | from django.urls import reverse 16 | except ImportError: 17 | # Django < 2.0 18 | from django.core.urlresolvers import reverse 19 | 20 | 21 | try: 22 | __path, __symbol = getattr(settings, 'UNIVERSAL_NOTIFICATIONS_SEND_SMS_FUNC').rsplit('.', 1) 23 | send_sms = getattr(import_module(__path), __symbol) 24 | except (AttributeError, ImportError): 25 | def send_sms(to_number, text, media=None, priority=9999, send_async=True): 26 | """Send SMS/MMS 27 | 28 | Send SMS/MMS 29 | 30 | Arguments: 31 | to_number {string} -- phone number 32 | text {string} -- SMS/MMS text 33 | 34 | Keyword Arguments: 35 | media {string} -- path or url to media file (default: {None}) 36 | priority {number} -- sending order if queued, ascending order (default: {9999}) 37 | """ 38 | from universal_notifications.tasks import send_message_task 39 | 40 | if send_async: 41 | send_message_task.delay(to_number, text, media, priority) 42 | else: 43 | send_message_task(to_number, text, media, priority) 44 | 45 | try: 46 | # Wide UCS-4 build 47 | emoji_pattern = re.compile(u'[' 48 | u'\U0001F300-\U0001F64F' 49 | u'\U0001F680-\U0001F6FF' 50 | u'\u2600-\u26FF\u2700-\u27BF]+', 51 | re.UNICODE) 52 | except re.error: 53 | # Narrow UCS-2 build 54 | emoji_pattern = re.compile(u'(' 55 | u'\ud83c[\udf00-\udfff]|' 56 | u'\ud83d[\udc00-\ude4f\ude80-\udeff]|' 57 | u'[\u2600-\u26FF\u2700-\u27BF])+', 58 | re.UNICODE) 59 | 60 | 61 | def clean_text(text): 62 | return emoji_pattern.sub(r'', text) 63 | 64 | 65 | def format_phone(phone): 66 | if not phone: 67 | return '' 68 | region = 'US' 69 | if phone.startswith('+'): 70 | region = None 71 | parsed = phonenumbers.parse(phone, region) 72 | return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164) 73 | 74 | 75 | def report_admins(subject, raw): 76 | url = reverse('admin:universal_notifications_phonereceivedraw_change', args=[raw.id]) 77 | domain = Site.objects.get_current().domain 78 | url = ''.join(['http://', domain]) 79 | mail_admins(subject, 'Message admin: %s' % url) 80 | -------------------------------------------------------------------------------- /universal_notifications/backends/twilio/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HealthByRo/universal_notifications/67552a42ff7625971e0b2aa7ee42984db8be159c/universal_notifications/backends/twilio/__init__.py -------------------------------------------------------------------------------- /universal_notifications/backends/twilio/api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.conf import settings 3 | from django.http import QueryDict 4 | from django.utils.safestring import SafeString 5 | from rest_framework import status 6 | from rest_framework.generics import GenericAPIView 7 | from rest_framework.permissions import AllowAny 8 | from rest_framework.renderers import StaticHTMLRenderer 9 | from rest_framework.response import Response 10 | from rest_framework.serializers import Serializer 11 | 12 | from universal_notifications.backends.sms.utils import clean_text 13 | from universal_notifications.models import PhoneReceivedRaw 14 | 15 | 16 | class TwilioAPI(GenericAPIView): 17 | """ 18 | Receive SMS/Phone from Twilio 19 | """ 20 | serializer_class = Serializer 21 | permission_classes = (AllowAny,) 22 | renderer_classes = (StaticHTMLRenderer,) 23 | 24 | def post(self, request): 25 | data = request.data 26 | 27 | # Clean emoji for now 28 | if isinstance(data, dict) and data.get('Body'): 29 | if isinstance(data, QueryDict): 30 | data = data.dict() 31 | data['Body'] = clean_text(data['Body']) 32 | PhoneReceivedRaw.objects.create(data=data) 33 | 34 | if data.get('Direction') == 'inbound': 35 | if data.get('CallStatus') == 'ringing': # incoming voice call 36 | text = getattr(settings, 'UNIVERSAL_NOTIFICATIONS_TWILIO_CALL_RESPONSE_DEFAULT', 37 | '' 38 | '' 39 | 'Hello, thanks for calling. ' 40 | 'To leave a message wait for the tone.' 41 | '' 42 | '') 43 | return Response(SafeString(text), content_type='text/xml') 44 | return Response({}, status=status.HTTP_202_ACCEPTED) 45 | -------------------------------------------------------------------------------- /universal_notifications/backends/twilio/fields.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | From django-extensions 4 | 5 | JSONField automatically serializes most Python terms to JSON data. 6 | Creates a TEXT field with a default value of "{}". See test_json.py for 7 | more information. 8 | 9 | from django.db import models 10 | from django_extensions.db.fields import json 11 | 12 | class LOL(models.Model): 13 | extra = json.JSONField() 14 | """ 15 | from __future__ import absolute_import 16 | 17 | import json 18 | import warnings 19 | 20 | import six 21 | from django.core.serializers.json import DjangoJSONEncoder 22 | from django.db import models 23 | 24 | 25 | def dumps(value): 26 | return DjangoJSONEncoder().encode(value) 27 | 28 | 29 | class JSONDict(dict): 30 | """ 31 | Hack so repr() called by dumpdata will output JSON instead of 32 | Python formatted data. This way fixtures will work! 33 | """ 34 | def __repr__(self): 35 | return dumps(self) 36 | 37 | 38 | class JSONList(list): 39 | """ 40 | As above 41 | """ 42 | def __repr__(self): 43 | return dumps(self) 44 | 45 | 46 | class JSONField(models.TextField): 47 | """JSONField is a generic textfield that neatly serializes/unserializes 48 | JSON objects seamlessly. Main thingy must be a dict object.""" 49 | 50 | def __init__(self, *args, **kwargs): 51 | warnings.warn("Django 1.9 features a native JsonField, this JSONField will " 52 | "be removed somewhere after Django 1.8 becomes unsupported.", 53 | DeprecationWarning) 54 | kwargs['default'] = kwargs.get('default', dict) 55 | models.TextField.__init__(self, *args, **kwargs) 56 | 57 | def get_default(self): 58 | if self.has_default(): 59 | default = self.default 60 | 61 | if callable(default): 62 | default = default() 63 | 64 | return self.to_python(default) 65 | return super(JSONField, self).get_default() 66 | 67 | def to_python(self, value): 68 | """Convert our string value to JSON after we load it from the DB""" 69 | if value is None or value == '': 70 | return {} 71 | 72 | if isinstance(value, six.string_types): 73 | res = json.loads(value) 74 | else: 75 | res = value 76 | 77 | if isinstance(res, dict): 78 | return JSONDict(**res) 79 | elif isinstance(res, list): 80 | return JSONList(res) 81 | 82 | return value 83 | 84 | def get_prep_value(self, value): 85 | if not isinstance(value, six.string_types): 86 | return dumps(value) 87 | return super(models.TextField, self).get_prep_value(value) 88 | 89 | def from_db_value(self, value, expression, connection, context=None): 90 | return self.to_python(value) 91 | 92 | def get_db_prep_save(self, value, connection, **kwargs): 93 | """Convert our JSON object to a string before we save""" 94 | if value is None and self.null: 95 | return None 96 | # default values come in as strings; only non-strings should be 97 | # run through `dumps` 98 | if not isinstance(value, six.string_types): 99 | value = dumps(value) 100 | 101 | return value 102 | 103 | def deconstruct(self): 104 | name, path, args, kwargs = super(JSONField, self).deconstruct() 105 | if self.default == '{}': 106 | del kwargs['default'] 107 | return name, path, args, kwargs 108 | -------------------------------------------------------------------------------- /universal_notifications/backends/websockets.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | 4 | from django.conf import settings 5 | from rest_framework.renderers import JSONRenderer 6 | from ws4redis.publisher import RedisPublisher 7 | from ws4redis.redis_store import RedisMessage 8 | from ws4redis.subscriber import RedisSubscriber 9 | 10 | from universal_notifications.tasks import ws_received_send_signal_task 11 | 12 | 13 | def publish(user, item=None, additional_data=None): 14 | if additional_data is None: 15 | additional_data = {} 16 | redis_publisher = RedisPublisher(facility='all', users=[user.email]) 17 | r = JSONRenderer() 18 | if item is None: 19 | data = {} 20 | else: 21 | data = item.as_dict() 22 | data.update(additional_data) 23 | data = r.render(data) 24 | message = RedisMessage(data) 25 | if getattr(settings, 'TESTING', False): 26 | # Do not send in tests 27 | return 28 | redis_publisher.publish_message(message) 29 | 30 | 31 | class RedisSignalSubscriber(RedisSubscriber): 32 | def publish_message(self, message, expire=None): 33 | try: 34 | message_data = json.loads(message) 35 | # I didn't found any better way to dig out who is subscribed to a given channel (not to mention who 36 | # just have send the given message). User ID can be passed in message, however this opens a hole in the 37 | # system, so - as long as we've only per-user channels (opened with a auth token), it should be safe to 38 | # use email of those users to identify the user. 39 | channel_emails = [str(x).split(":")[2] for x in self._subscription.channels.keys()] 40 | 41 | ws_received_send_signal_task.apply_async(args=[message_data, channel_emails]) 42 | except Exception: 43 | # I mean it, catch everything, log it if needed but do catch everything 44 | pass 45 | return super(RedisSignalSubscriber, self).publish_message(message, expire) 46 | -------------------------------------------------------------------------------- /universal_notifications/docs.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from importlib import import_module 3 | 4 | from django.apps import apps 5 | from django.template import TemplateDoesNotExist, TemplateSyntaxError 6 | from django.utils.safestring import mark_safe 7 | from rest_framework.compat import coreapi 8 | 9 | from universal_notifications.notifications import (EmailNotification, NotificationBase, PushNotification, 10 | SMSNotification, WSNotification) 11 | 12 | BASE_NOTIFICATIONS = (EmailNotification, NotificationBase, PushNotification, SMSNotification, WSNotification) 13 | 14 | 15 | class BaseGenerator(object): 16 | def __init__(self, obj, *args, **kwargs): 17 | super(BaseGenerator, self).__init__(*args, **kwargs) 18 | self._obj = obj 19 | 20 | def get_summary(self): 21 | return "TODO: summary" 22 | 23 | def get_type(self): 24 | return "TODO: type" 25 | 26 | def get_notes(self): 27 | parts = [self.get_summary() + "\n", self._obj.__doc__] 28 | if self._obj.check_subscription: 29 | parts.append("Subscription Category: %s" % self._obj.category) 30 | parts.append(self.get_class_specific_notes()) 31 | return "

".join(part for part in parts if part and part.strip()) # display only non empty parts 32 | 33 | def get_class_specific_notes(self): 34 | return "TODO: notes" 35 | 36 | def get_serializer(self): 37 | return None 38 | 39 | def skip(self): 40 | return False 41 | 42 | 43 | class WSDocGenerator(BaseGenerator): 44 | def get_summary(self): 45 | return self._obj.message 46 | 47 | def get_type(self): 48 | return self.get_serializer().__name__ 49 | 50 | def get_class_specific_notes(self): 51 | data = self.get_serializer().__name__ 52 | if self._obj.serializer_many: 53 | data = "[%s*]" % data 54 | return "Message
%s

Data
%s" % (self._obj.message, data) 55 | 56 | def get_serializer(self): 57 | if self._obj.serializer_class is None: 58 | # handling scase when serializer_class is defined during __init__ 59 | return self._obj().serializer_class 60 | return self._obj.serializer_class 61 | 62 | def skip(self): 63 | return not self._obj.message 64 | 65 | 66 | class SMSDocGenerator(BaseGenerator): 67 | def get_summary(self): 68 | return "SMS" 69 | 70 | def get_type(self): 71 | return None 72 | 73 | def get_class_specific_notes(self): 74 | return "Template:
%s" % self._obj.message 75 | 76 | def skip(self): 77 | return not self._obj.message 78 | 79 | 80 | class EmailDocGenerator(BaseGenerator): 81 | template_loader = None 82 | 83 | # ++ THIS IS UGLY. TODO: Find better method 84 | @classmethod 85 | def get_template_loader(cls): 86 | if not cls.template_loader: 87 | from django.template.engine import Engine 88 | from django.template.loaders.filesystem import Loader 89 | 90 | default_template_engine = Engine.get_default() 91 | cls.template_loader = Loader(default_template_engine) 92 | return cls.template_loader 93 | 94 | @classmethod 95 | def get_template(cls, template_name): 96 | template_loader = cls.get_template_loader() 97 | try: 98 | source, dummy = template_loader.load_template_source(template_name) 99 | return source 100 | except (TemplateDoesNotExist, TemplateSyntaxError): 101 | return "" 102 | # -- 103 | 104 | def get_summary(self): 105 | return self._obj.email_name 106 | 107 | def get_type(self): 108 | return None 109 | 110 | def get_class_specific_notes(self): 111 | notes = "Subject:
%s

Preview:
%s" % ( 112 | self._obj.email_subject, 113 | self.get_template("emails/email_%s.html" % self._obj.email_name) 114 | ) 115 | return mark_safe(notes) 116 | 117 | def skip(self): 118 | return not self._obj.email_name 119 | 120 | 121 | class NotificationsDocs(object): 122 | _registry = {} 123 | _autodiscovered = False 124 | _serializers = set() 125 | _generator_mapping = { 126 | EmailNotification: EmailDocGenerator, 127 | SMSNotification: SMSDocGenerator, 128 | WSNotification: WSDocGenerator 129 | } 130 | 131 | @classmethod 132 | def autodiscover(cls): 133 | if cls._autodiscovered: 134 | return 135 | 136 | cls._autodiscovered = True 137 | 138 | # classes 139 | for app_config in apps.get_app_configs(): 140 | if app_config.name == "universal_notifications": # no self importing 141 | continue 142 | try: 143 | module = import_module("%s.%s" % (app_config.name, "notifications")) 144 | for key in dir(module): 145 | item = getattr(module, key) 146 | if issubclass(item, NotificationBase) and item not in BASE_NOTIFICATIONS: 147 | notification_type = item.get_type() 148 | if notification_type not in cls._registry: 149 | cls._registry[notification_type] = {} 150 | item_key = "%s.%s" % (key, app_config.name) 151 | item_path = "%s (%s.notifications)" % (key, app_config.name) 152 | 153 | generator = cls.get_generator(item) 154 | if not generator.skip(): 155 | serializer = generator.get_serializer() 156 | if serializer: 157 | cls._serializers.add(serializer) 158 | cls._registry[notification_type][item_key] = {"cls": item, "path": item_path} 159 | except Exception: 160 | pass 161 | 162 | @classmethod 163 | def get_types(cls): 164 | return sorted(cls._registry.keys()) 165 | 166 | @classmethod 167 | def get_notifications(cls, notification_type): 168 | return sorted(cls._registry.get(notification_type, {}).items(), key=lambda x: x[0]) 169 | 170 | @classmethod 171 | def get_generator(cls, notification_cls): 172 | for key_cls, generator_cls in cls._generator_mapping.items(): 173 | if issubclass(notification_cls, key_cls): 174 | return generator_cls(notification_cls) 175 | return BaseGenerator(notification_cls) 176 | 177 | @classmethod 178 | def generate_notifications_docs(cls, notification_type): 179 | links = {} 180 | for key, value in cls.get_notifications(notification_type): 181 | generator = cls.get_generator(value["cls"]) 182 | links[value["path"]] = coreapi.Link( 183 | title=key, 184 | description=generator.get_notes(), 185 | action="GET", 186 | url=value["path"] 187 | ) 188 | 189 | return links 190 | -------------------------------------------------------------------------------- /universal_notifications/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HealthByRo/universal_notifications/67552a42ff7625971e0b2aa7ee42984db8be159c/universal_notifications/management/__init__.py -------------------------------------------------------------------------------- /universal_notifications/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HealthByRo/universal_notifications/67552a42ff7625971e0b2aa7ee42984db8be159c/universal_notifications/management/commands/__init__.py -------------------------------------------------------------------------------- /universal_notifications/management/commands/check_twilio_proxy.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.conf import settings 3 | from django.core.management.base import BaseCommand 4 | from redis import StrictRedis 5 | from rest_framework.renderers import JSONRenderer 6 | from ws4redis import settings as private_settings 7 | from ws4redis.redis_store import RedisMessage 8 | 9 | from universal_notifications.models import PhonePendingMessages 10 | 11 | 12 | class Command(BaseCommand): 13 | args = "" 14 | help = "Check twilio proxy" 15 | 16 | def handle(self, *args, **options): 17 | connection = StrictRedis(**private_settings.WS4REDIS_CONNECTION) 18 | numbers = PhonePendingMessages.objects.all().values_list("from_phone", flat=True).distinct() 19 | for n in numbers: 20 | r = JSONRenderer() 21 | json_data = r.render({"number": n}) 22 | channel = getattr(settings, "UNIVERSAL_NOTIFICATIONS_TWILIO_DISPATCHER_CHANNEL", "__un_twilio_dispatcher") 23 | connection.publish(channel, RedisMessage(json_data)) 24 | -------------------------------------------------------------------------------- /universal_notifications/management/commands/parse_pending_sms.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from datetime import timedelta 3 | 4 | from django.core.management.base import BaseCommand 5 | from django.utils.timezone import now 6 | 7 | from universal_notifications.models import PhoneReceivedRaw 8 | from universal_notifications.tasks import parse_received_message_task 9 | 10 | 11 | class Command(BaseCommand): 12 | args = "" 13 | help = "Check if message parsed" 14 | 15 | def handle(self, *args, **options): 16 | ago = now() - timedelta(minutes=1) 17 | raws = PhoneReceivedRaw.objects.filter(status=PhoneReceivedRaw.STATUS_PENDING, created__lte=ago) 18 | for raw in raws: 19 | parse_received_message_task(raw.id) 20 | -------------------------------------------------------------------------------- /universal_notifications/management/commands/run_twilio_proxy.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | import sys 4 | import threading 5 | import traceback 6 | from time import sleep 7 | 8 | from django.conf import settings 9 | from django.core.management.base import BaseCommand 10 | from raven.contrib.django import DjangoClient 11 | from redis import StrictRedis 12 | from ws4redis import settings as private_settings 13 | 14 | from universal_notifications.models import Phone, PhonePendingMessages 15 | 16 | 17 | class Queue(threading.Thread): 18 | def __init__(self, main, phone): 19 | self.main = main 20 | self.phone = phone 21 | threading.Thread.__init__(self) 22 | 23 | def stop(self): 24 | if self.phone.number in self.main.queues: 25 | self.main.queues.remove(self.phone.number) 26 | 27 | def check_messages(self): 28 | count = 1 29 | while count: 30 | messages = PhonePendingMessages.objects.filter(from_phone=self.phone.number) 31 | count = messages.count() 32 | if count: 33 | self.process_message(messages[0]) 34 | sleep(60 / self.phone.rate) 35 | else: 36 | self.stop() 37 | 38 | def process_message(self, message): 39 | message.message.send() 40 | message.message.save() 41 | message.delete() 42 | 43 | def run(self): 44 | try: 45 | self.check_messages() 46 | except Exception: 47 | info = sys.exc_info() 48 | raven_conf = getattr(settings, "RAVEN_CONFIG", False) 49 | if raven_conf and raven_conf.get("dsn"): 50 | client = DjangoClient(raven_conf.get("dsn")) 51 | exc_type, exc_value, exc_traceback = info 52 | error = str(traceback.format_exception(exc_type, exc_value, exc_traceback)) 53 | client.capture("raven.events.Message", message="Error Sending message", 54 | extra={"info": error, "number": self.phone.number}) 55 | raise Exception(info[1], None, info[2]) 56 | 57 | 58 | class Command(BaseCommand): 59 | args = "" 60 | help = "Run twilio proxy" 61 | queues = [] 62 | 63 | def create_queue(self, phone): 64 | if phone.number not in self.queues: 65 | t = Queue(self, phone) 66 | t.start() 67 | self.queues.append(phone.number) 68 | 69 | def handle(self, *args, **options): 70 | r = StrictRedis(**private_settings.WS4REDIS_CONNECTION) 71 | p = r.pubsub() 72 | channel = getattr(settings, "UNIVERSAL_NOTIFICATIONS_TWILIO_DISPATCHER_CHANNEL", "__un_twilio_dispatcher") 73 | p.subscribe(channel) 74 | 75 | phones = Phone.objects.all() 76 | for phone in phones: 77 | self.create_queue(phone) 78 | 79 | while True: 80 | message = p.get_message() 81 | if message: 82 | try: 83 | m = json.loads(message["data"]) 84 | if m.get("number"): 85 | try: 86 | phone = Phone.objects.get(number=m["number"]) 87 | self.create_queue(phone) 88 | except Phone.DoesNotExist: 89 | pass 90 | except (TypeError, ValueError): 91 | pass 92 | sleep(0.001) # be nice to the system :) 93 | -------------------------------------------------------------------------------- /universal_notifications/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='NotificationHistory', 15 | fields=[ 16 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 17 | ('created', models.DateTimeField(auto_now_add=True)), 18 | ('group', models.CharField(max_length=50)), 19 | ('klass', models.CharField(max_length=255)), 20 | ('receiver', models.CharField(max_length=255)), 21 | ('details', models.TextField()), 22 | ], 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /universal_notifications/migrations/0002_device.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('universal_notifications', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Device', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, verbose_name='ID', serialize=False, primary_key=True)), 20 | ('notification_token', models.TextField()), 21 | ('device_id', models.CharField(max_length=255)), 22 | ('is_active', models.BooleanField(default=True, help_text='Inactive devices will not be sent notifications')), 23 | ('created', models.DateTimeField(auto_now_add=True)), 24 | ('app_id', models.CharField(max_length=100)), 25 | ('platform', models.CharField(choices=[('ios', 'iOS'), ('gcm', 'Google Cloud Messagging (deprecated)'), ('fcm', 'Firebase Cloud Messaging')], max_length=10)), 26 | ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, related_name='devices', on_delete=models.CASCADE)), 27 | ], 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /universal_notifications/migrations/0003_auto_20170112_0609.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.5 on 2017-01-12 06:09 3 | from __future__ import unicode_literals 4 | 5 | import django.db.models.deletion 6 | from django.db import migrations, models 7 | 8 | import universal_notifications.backends.twilio.fields 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | dependencies = [ 14 | ('universal_notifications', '0002_device'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Phone', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('number', models.CharField(max_length=20, unique=True)), 23 | ('rate', models.IntegerField(default=6, verbose_name=b'Messages rate')), 24 | ('used_count', models.IntegerField(default=0)), 25 | ], 26 | options={ 27 | 'ordering': ['number'], 28 | }, 29 | ), 30 | migrations.CreateModel( 31 | name='PhonePendingMessages', 32 | fields=[ 33 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 34 | ('created', models.DateTimeField(auto_now_add=True)), 35 | ('from_phone', models.CharField(db_index=True, max_length=30)), 36 | ('priority', models.IntegerField(default=9999)), 37 | ], 38 | ), 39 | migrations.CreateModel( 40 | name='PhoneReceived', 41 | fields=[ 42 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 43 | ('text', models.TextField()), 44 | ('media', models.CharField(blank=True, max_length=255, null=True)), 45 | ('type', models.CharField(choices=[(b'voice', b'voice'), (b'text', b'Text')], default=b'text', max_length=35)), 46 | ('created', models.DateTimeField(auto_now_add=True)), 47 | ('updated', models.DateTimeField(auto_now=True)), 48 | ('sms_id', models.CharField(blank=True, max_length=50)), 49 | ('is_opt_out', models.BooleanField(default=False)), 50 | ], 51 | options={ 52 | 'verbose_name': 'Received Message', 53 | 'verbose_name_plural': 'Received Messages', 54 | }, 55 | ), 56 | migrations.CreateModel( 57 | name='PhoneReceivedRaw', 58 | fields=[ 59 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 60 | ('status', models.CharField(choices=[(b'pending', b'Pending'), (b'pass', b'Pass'), (b'fail', b'Fail'), (b'rejected', b'Rejected')], db_index=True, default=b'pending', max_length=35)), 61 | ('data', universal_notifications.backends.twilio.fields.JSONField()), 62 | ('created', models.DateTimeField(auto_now_add=True)), 63 | ('updated', models.DateTimeField(auto_now=True)), 64 | ('exception', models.TextField(blank=True)), 65 | ], 66 | ), 67 | migrations.CreateModel( 68 | name='PhoneReceiver', 69 | fields=[ 70 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 71 | ('number', models.CharField(db_index=True, max_length=20)), 72 | ('service_number', models.CharField(max_length=20)), 73 | ('is_blocked', models.BooleanField(default=False)), 74 | ], 75 | options={ 76 | 'ordering': ['number'], 77 | }, 78 | ), 79 | migrations.CreateModel( 80 | name='PhoneSent', 81 | fields=[ 82 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 83 | ('text', models.TextField()), 84 | ('sms_id', models.CharField(blank=True, max_length=34)), 85 | ('status', models.CharField(choices=[(b'pending', b'Pending'), (b'queued', b'Queued'), (b'failed', b'failed'), (b'sent', b'sent'), (b'no_answer', b'no answer from twilio'), (b'delivered', b'delivered'), (b'undelivered', b'undelivered')], default=b'pending', max_length=35)), 86 | ('twilio_error_code', models.CharField(blank=True, max_length=100, null=True)), 87 | ('twilio_error_message', models.TextField(blank=True, null=True)), 88 | ('created', models.DateTimeField(auto_now_add=True)), 89 | ('updated', models.DateTimeField(auto_now=True)), 90 | ('media_raw', models.CharField(blank=True, max_length=255, null=True)), 91 | ('media', models.CharField(blank=True, max_length=255, null=True)), 92 | ('receiver', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='universal_notifications.PhoneReceiver')), 93 | ], 94 | options={ 95 | 'verbose_name': 'Sent Message', 96 | 'verbose_name_plural': 'Sent Messages', 97 | }, 98 | ), 99 | migrations.AlterIndexTogether( 100 | name='phonereceiver', 101 | index_together=set([('number', 'service_number')]), 102 | ), 103 | migrations.AddField( 104 | model_name='phonereceived', 105 | name='raw', 106 | field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, to='universal_notifications.PhoneReceivedRaw'), 107 | ), 108 | migrations.AddField( 109 | model_name='phonereceived', 110 | name='receiver', 111 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='universal_notifications.PhoneReceiver'), 112 | ), 113 | migrations.AddField( 114 | model_name='phonependingmessages', 115 | name='message', 116 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='universal_notifications.PhoneSent'), 117 | ), 118 | ] 119 | -------------------------------------------------------------------------------- /universal_notifications/migrations/0004_auto_20170124_0731.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import django.db.models.deletion 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | 8 | import universal_notifications.backends.twilio.fields 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ('universal_notifications', '0003_auto_20170112_0609'), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='UnsubscribedUser', 21 | fields=[ 22 | ('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)), 23 | ('unsubscribed_from_all', models.BooleanField(default=False)), 24 | ('unsubscribed', universal_notifications.backends.twilio.fields.JSONField(default=dict)), 25 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 26 | ], 27 | ), 28 | migrations.AddField( 29 | model_name='notificationhistory', 30 | name='category', 31 | field=models.CharField(max_length=255, default='system'), 32 | preserve_default=False, 33 | ), 34 | migrations.AlterField( 35 | model_name='phone', 36 | name='rate', 37 | field=models.IntegerField(verbose_name='Messages rate', default=6), 38 | ), 39 | migrations.AlterField( 40 | model_name='phonereceived', 41 | name='type', 42 | field=models.CharField(max_length=35, default='text', choices=[('voice', 'voice'), ('text', 'Text')]), 43 | ), 44 | migrations.AlterField( 45 | model_name='phonereceivedraw', 46 | name='status', 47 | field=models.CharField(max_length=35, db_index=True, default='pending', choices=[('pending', 'Pending'), ('pass', 'Pass'), ('fail', 'Fail'), ('rejected', 'Rejected')]), 48 | ), 49 | migrations.AlterField( 50 | model_name='phonesent', 51 | name='status', 52 | field=models.CharField(max_length=35, default='pending', choices=[('pending', 'Pending'), ('queued', 'Queued'), ('failed', 'failed'), ('sent', 'sent'), ('no_answer', 'no answer from twilio'), ('delivered', 'delivered'), ('undelivered', 'undelivered')]), 53 | ), 54 | ] 55 | -------------------------------------------------------------------------------- /universal_notifications/migrations/0005_auto_20170316_1814.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.6 on 2017-03-16 18:14 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('universal_notifications', '0004_auto_20170124_0731'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RenameField( 16 | model_name='phonesent', 17 | old_name='twilio_error_code', 18 | new_name='error_code', 19 | ), 20 | migrations.RenameField( 21 | model_name='phonesent', 22 | old_name='twilio_error_message', 23 | new_name='error_message', 24 | ), 25 | migrations.AlterField( 26 | model_name='phonereceiver', 27 | name='service_number', 28 | field=models.CharField(blank=True, max_length=20), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /universal_notifications/migrations/0006_auto_20170323_0634.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('universal_notifications', '0005_auto_20170316_1814'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='phonereceiver', 16 | name='number', 17 | field=models.CharField(max_length=20, unique=True, db_index=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /universal_notifications/migrations/0007_auto_20170323_0649.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('universal_notifications', '0006_auto_20170323_0634'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='phonesent', 16 | name='sms_id', 17 | field=models.CharField(max_length=50, blank=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /universal_notifications/migrations/0008_auto_20170704_0810.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.3 on 2017-07-04 12:10 3 | from __future__ import unicode_literals 4 | 5 | import django.db.models.deletion 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('contenttypes', '0002_remove_content_type_name'), 13 | ('universal_notifications', '0007_auto_20170323_0649'), 14 | ] 15 | 16 | operations = [ 17 | migrations.AddField( 18 | model_name='notificationhistory', 19 | name='content_type', 20 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='contenttypes.ContentType'), 21 | ), 22 | migrations.AddField( 23 | model_name='notificationhistory', 24 | name='object_id', 25 | field=models.PositiveIntegerField(null=True), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /universal_notifications/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HealthByRo/universal_notifications/67552a42ff7625971e0b2aa7ee42984db8be159c/universal_notifications/migrations/__init__.py -------------------------------------------------------------------------------- /universal_notifications/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.conf import settings 3 | from django.contrib.contenttypes.fields import GenericForeignKey 4 | from django.contrib.contenttypes.models import ContentType 5 | from django.db import models 6 | from django.db.models.signals import post_save 7 | from django.utils.encoding import force_str 8 | from phonenumbers import NumberParseException 9 | 10 | from universal_notifications.backends.push.apns import apns_send_message 11 | from universal_notifications.backends.push.fcm import fcm_send_message 12 | from universal_notifications.backends.push.gcm import gcm_send_message 13 | from universal_notifications.backends.sms.signals import phone_received_post_save 14 | from universal_notifications.backends.sms.utils import format_phone 15 | from universal_notifications.backends.twilio.fields import JSONField 16 | 17 | AUTH_USER_MODEL = getattr(settings, "AUTH_USER_MODEL", "auth.User") 18 | TWILIO_MAX_RATE = getattr(settings, "UNIVERSAL_NOTIFICATIONS_TWILIO_MAX_RATE", 6) 19 | 20 | 21 | class NotificationHistory(models.Model): 22 | created = models.DateTimeField(auto_now_add=True) 23 | group = models.CharField(max_length=50) 24 | klass = models.CharField(max_length=255) 25 | receiver = models.CharField(max_length=255) 26 | details = models.TextField() 27 | category = models.CharField(max_length=255) 28 | content_type = models.ForeignKey(ContentType, null=True, on_delete=models.SET_NULL) 29 | object_id = models.PositiveIntegerField(null=True) 30 | source = GenericForeignKey("content_type", "object_id") 31 | 32 | 33 | class Device(models.Model): 34 | user = models.ForeignKey(AUTH_USER_MODEL, related_name="devices", on_delete=models.CASCADE) 35 | notification_token = models.TextField() 36 | device_id = models.CharField(max_length=255) 37 | is_active = models.BooleanField(default=True, help_text="Inactive devices will not be sent notifications") 38 | created = models.DateTimeField(auto_now_add=True) 39 | PLATFORM_IOS = "ios" 40 | PLATFORM_GCM = "gcm" 41 | PLATFORM_FCM = "fcm" 42 | PLATFORM_CHOICES = ( 43 | (PLATFORM_IOS, "iOS"), 44 | (PLATFORM_GCM, "Google Cloud Messagging (deprecated)"), 45 | (PLATFORM_FCM, "Firebase Cloud Messaging"), 46 | ) 47 | app_id = models.CharField(max_length=100) 48 | platform = models.CharField(max_length=10, choices=PLATFORM_CHOICES) 49 | 50 | def __unicode__(self): 51 | return "%s (%s)" % (self.user.email or "unknown user", self.device_id) 52 | 53 | def send_message(self, message, description="", **data): 54 | """Send message to device 55 | 56 | Args: 57 | message (string): Message string 58 | description (string): Optional description 59 | **data (dict, optional): Extra data 60 | 61 | Returns: 62 | boolean: status of sending notification 63 | """ 64 | if not self.is_active: 65 | return False 66 | 67 | message = force_str(message) 68 | description = force_str(description) 69 | if self.platform == Device.PLATFORM_GCM: 70 | return gcm_send_message(self, message, data) 71 | elif self.platform == Device.PLATFORM_IOS: 72 | return apns_send_message(self, message, description, data) 73 | elif self.platform == Device.PLATFORM_FCM: 74 | return fcm_send_message(self, message, data) 75 | else: 76 | return False 77 | 78 | 79 | class Phone(models.Model): 80 | number = models.CharField(max_length=20, unique=True) 81 | rate = models.IntegerField("Messages rate", default=TWILIO_MAX_RATE) 82 | used_count = models.IntegerField(default=0) 83 | 84 | class Meta: 85 | ordering = ["number"] 86 | 87 | def __unicode__(self): 88 | return self.number 89 | 90 | def save(self, *args, **kwargs): 91 | self.number = format_phone(self.number) 92 | return super(Phone, self).save(*args, **kwargs) 93 | 94 | 95 | class PhoneReceiverManager(models.Manager): 96 | 97 | def format_fields(self, filters): 98 | for field in ["number", "service_number"]: 99 | if field in filters: 100 | try: 101 | filters[field] = format_phone(filters[field]) 102 | except NumberParseException: 103 | raise PhoneReceiver.DoesNotExist 104 | return filters 105 | 106 | def filter(self, *args, **filters): 107 | filters = self.format_fields(filters) 108 | return super(PhoneReceiverManager, self).filter(*args, **filters) 109 | 110 | def get(self, **filters): 111 | filters = self.format_fields(filters) 112 | return super(PhoneReceiverManager, self).get(**filters) 113 | 114 | 115 | class PhoneReceiver(models.Model): 116 | number = models.CharField(max_length=20, db_index=True, unique=True) 117 | service_number = models.CharField(max_length=20, blank=True) 118 | is_blocked = models.BooleanField(default=False) 119 | 120 | objects = PhoneReceiverManager() 121 | 122 | class Meta: 123 | ordering = ["number"] 124 | index_together = ( 125 | ("number", "service_number"), 126 | ) 127 | 128 | def __unicode__(self): 129 | return self.number 130 | 131 | def save(self, *args, **kwargs): 132 | self.number = format_phone(self.number) 133 | self.service_number = format_phone(self.service_number) 134 | return super(PhoneReceiver, self).save(*args, **kwargs) 135 | 136 | 137 | class PhoneSent(models.Model): 138 | receiver = models.ForeignKey(PhoneReceiver, on_delete=models.CASCADE) 139 | text = models.TextField() 140 | sms_id = models.CharField(max_length=50, blank=True) 141 | STATUS_PENDING = "pending" 142 | STATUS_QUEUED = "queued" 143 | STATUS_FAILED = "failed" 144 | STATUS_SENT = "sent" 145 | STATUS_DELIVERED = "delivered" 146 | STATUS_UNDELIVERED = "undelivered" 147 | STATUS_NO_ANSWER = "no_answer" 148 | STATUS_CHOICES = ( 149 | (STATUS_PENDING, "Pending"), 150 | (STATUS_QUEUED, "Queued"), 151 | (STATUS_FAILED, "failed"), 152 | (STATUS_SENT, "sent"), 153 | (STATUS_NO_ANSWER, "no answer from twilio"), 154 | (STATUS_DELIVERED, "delivered"), 155 | (STATUS_UNDELIVERED, "undelivered"), 156 | ) 157 | status = models.CharField(max_length=35, choices=STATUS_CHOICES, default="pending") 158 | error_code = models.CharField(max_length=100, blank=True, null=True) 159 | error_message = models.TextField(blank=True, null=True) 160 | created = models.DateTimeField(auto_now_add=True) 161 | updated = models.DateTimeField(auto_now=True) 162 | media_raw = models.CharField(max_length=255, blank=True, null=True) 163 | media = models.CharField(max_length=255, blank=True, null=True) 164 | 165 | def send(self): 166 | if self.status not in [PhoneSent.STATUS_QUEUED, PhoneSent.STATUS_PENDING]: 167 | return 168 | 169 | from universal_notifications.backends.sms.base import SMS 170 | sms = SMS() 171 | sms.send(self) 172 | 173 | class Meta: 174 | verbose_name = "Sent Message" 175 | verbose_name_plural = verbose_name + "s" 176 | 177 | 178 | class PhoneReceivedRaw(models.Model): 179 | STATUS_PENDING = "pending" 180 | STATUS_PASS = "pass" 181 | STATUS_FAIL = "fail" 182 | STATUS_REJECTED = "rejected" 183 | STATUS_CHOICES = ( 184 | (STATUS_PENDING, "Pending"), 185 | (STATUS_PASS, "Pass"), 186 | (STATUS_FAIL, "Fail"), 187 | (STATUS_REJECTED, "Rejected"), 188 | ) 189 | status = models.CharField(max_length=35, choices=STATUS_CHOICES, default=STATUS_PENDING, db_index=True) 190 | data = JSONField() 191 | created = models.DateTimeField(auto_now_add=True) 192 | updated = models.DateTimeField(auto_now=True) 193 | exception = models.TextField(blank=True) 194 | 195 | def save(self, *args, **kwargs): 196 | from universal_notifications.tasks import parse_received_message_task 197 | 198 | super(PhoneReceivedRaw, self).save(*args, **kwargs) 199 | if self.status == PhoneReceivedRaw.STATUS_PENDING: 200 | parse_received_message_task.delay(self.id) 201 | 202 | 203 | class PhoneReceived(models.Model): 204 | TYPE_TEXT = "text" 205 | TYPE_VOICE = "voice" 206 | Type_choices = ( 207 | (TYPE_VOICE, "voice"), 208 | (TYPE_TEXT, "Text"), 209 | ) 210 | receiver = models.ForeignKey(PhoneReceiver, on_delete=models.CASCADE) 211 | text = models.TextField() 212 | media = models.CharField(max_length=255, blank=True, null=True) 213 | sms_id = models.CharField(max_length=50, blank=True) 214 | type = models.CharField(max_length=35, choices=Type_choices, default=TYPE_TEXT) 215 | created = models.DateTimeField(auto_now_add=True) 216 | updated = models.DateTimeField(auto_now=True) 217 | raw = models.ForeignKey(PhoneReceivedRaw, null=True, blank=True, editable=False, on_delete=models.SET_NULL) 218 | is_opt_out = models.BooleanField(default=False) 219 | 220 | class Meta: 221 | verbose_name = "Received Message" 222 | verbose_name_plural = verbose_name + "s" 223 | 224 | 225 | post_save.connect(phone_received_post_save, sender=PhoneReceived) 226 | 227 | 228 | class PhonePendingMessages(models.Model): 229 | created = models.DateTimeField(auto_now_add=True) 230 | from_phone = models.CharField(max_length=30, db_index=True) 231 | priority = models.IntegerField(default=9999) 232 | message = models.ForeignKey(PhoneSent, blank=True, null=True, on_delete=models.CASCADE) 233 | 234 | def save(self, *args, **kwargs): 235 | created = not self.id 236 | self.from_phone = format_phone(self.from_phone) 237 | ret = super(PhonePendingMessages, self).save(*args, **kwargs) 238 | if created: 239 | from universal_notifications.backends.sms.base import SMS 240 | sms = SMS() 241 | sms.add_to_queue(self) 242 | return ret 243 | 244 | 245 | class UnsubscribedUser(models.Model): 246 | user = models.ForeignKey(AUTH_USER_MODEL, on_delete=models.CASCADE) 247 | unsubscribed_from_all = models.BooleanField(default=False) 248 | unsubscribed = JSONField(default=dict) 249 | -------------------------------------------------------------------------------- /universal_notifications/notifications.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Universal Notifications 3 | 4 | Sample usage: 5 | WSNotification(item, receivers, context).send() 6 | 7 | """ 8 | import importlib 9 | import logging 10 | import re 11 | from email.utils import formataddr 12 | 13 | from django.conf import settings 14 | from django.contrib.contenttypes.models import ContentType 15 | from django.contrib.sites.models import Site 16 | from django.core.exceptions import ImproperlyConfigured 17 | from django.core.mail import EmailMessage 18 | from django.template import Context, Template 19 | from django.template.loader import get_template 20 | from premailer import Premailer 21 | 22 | from universal_notifications.backends.sms.utils import send_sms 23 | from universal_notifications.backends.websockets import publish 24 | from universal_notifications.models import Device, NotificationHistory, UnsubscribedUser 25 | 26 | logger = logging.getLogger(__name__) 27 | 28 | user_definitions = None 29 | if hasattr(settings, "UNIVERSAL_NOTIFICATIONS_USER_DEFINITIONS_FILE") and \ 30 | hasattr(settings, "UNIVERSAL_NOTIFICATIONS_USER_CATEGORIES_MAPPING"): 31 | user_definitions = importlib.import_module(settings.UNIVERSAL_NOTIFICATIONS_USER_DEFINITIONS_FILE) 32 | 33 | 34 | class NotificationBase(object): 35 | check_subscription = True 36 | category = "default" 37 | PRIORITY_CATEGORY = "system" # this category will be always sent 38 | 39 | @classmethod 40 | def get_type(cls): 41 | raise NotImplementedError 42 | 43 | def __init__(self, item, receivers, context=None): 44 | self.item = item 45 | self.receivers = receivers 46 | self.context = context or {} 47 | 48 | @classmethod 49 | def get_mapped_user_notifications_types_and_categories(cls, user): 50 | """Returns a dictionary for given user type: 51 | 52 | {"notificaiton_type": [categries list]} 53 | TODO: use this one in serializer. 54 | """ 55 | if not hasattr(settings, "UNIVERSAL_NOTIFICATIONS_USER_CATEGORIES_MAPPING"): 56 | notifications = {} 57 | for key in settings.UNIVERSAL_NOTIFICATIONS_CATEGORIES.keys(): 58 | notifications[key] = settings.UNIVERSAL_NOTIFICATIONS_CATEGORIES[key].keys() 59 | return notifications 60 | else: 61 | for user_type in settings.UNIVERSAL_NOTIFICATIONS_USER_CATEGORIES_MAPPING: 62 | if getattr(user_definitions, user_type)(user): 63 | return settings.UNIVERSAL_NOTIFICATIONS_USER_CATEGORIES_MAPPING[user_type] 64 | 65 | def get_user_categories_for_type(self, user): 66 | """Check categories available for given user type and this notification type. 67 | 68 | If no mapping present we assume all are allowed. 69 | Raises ImproperlyConfigured if no categories for given user availaible 70 | """ 71 | categories = self.get_mapped_user_notifications_types_and_categories(user) 72 | if categories: 73 | return categories[self.get_type().lower()] 74 | 75 | raise ImproperlyConfigured( 76 | "UNIVERSAL NOTIFICATIONS USER CATEGORIES MAPPING: No categories for given user: %s" % user) 77 | 78 | def get_context(self): 79 | context = self.context 80 | context["item"] = self.item 81 | return context 82 | 83 | def prepare_receivers(self): 84 | raise NotImplementedError 85 | 86 | def prepare_message(self): 87 | raise NotImplementedError 88 | 89 | def send_inner(self, prepared_receivers, prepared_message): 90 | raise NotImplementedError 91 | 92 | def get_notification_history_details(self): 93 | raise NotImplementedError 94 | 95 | def format_receiver_for_notification_history(self, receiver): 96 | return receiver 97 | 98 | def check_category(self): 99 | if self.category == self.PRIORITY_CATEGORY or not self.check_subscription: 100 | return 101 | if not self.category: 102 | raise ImproperlyConfigured("Category is required", self) 103 | 104 | notification_type = self.get_type().lower() 105 | if not hasattr(settings, "UNIVERSAL_NOTIFICATIONS_CATEGORIES"): 106 | raise ImproperlyConfigured("Please define UNIVERSAL_NOTIFICATIONS_CATEGORIES in your settings.py") 107 | 108 | categories = settings.UNIVERSAL_NOTIFICATIONS_CATEGORIES.get(notification_type, {}) 109 | if self.category not in categories.keys(): 110 | raise ImproperlyConfigured("No such category for Universal Notifications: %s: %s." % ( 111 | self.get_type(), self.category)) 112 | # check if user is allowed to get notifications from this category 113 | for user in self.receivers: 114 | if self.category not in self.get_user_categories_for_type(user): 115 | raise ImproperlyConfigured( 116 | "User is not allowed to receive notifications from '%s:%s' category" 117 | % (self.get_type(), self.category)) 118 | 119 | def verify_and_filter_receivers_subscriptions(self): 120 | """Returns new list of only receivers that are subscribed for given notification type/category.""" 121 | if not self.check_subscription or self.category == self.PRIORITY_CATEGORY: 122 | return self.receivers 123 | 124 | receivers_ids = (x.id for x in self.receivers) 125 | unsubscribed_map = {} 126 | for unsubscribed_user in UnsubscribedUser.objects.filter(user__in=receivers_ids): 127 | unsubscribed_map[unsubscribed_user.user_id] = unsubscribed_user 128 | 129 | filtered_receivers = [] 130 | ntype = self.get_type().lower() 131 | for receiver in self.receivers: 132 | unsubscribed_user = unsubscribed_map.get(receiver.id, None) 133 | if unsubscribed_user: 134 | if unsubscribed_user.unsubscribed_from_all: 135 | continue 136 | unsubscribed = unsubscribed_user.unsubscribed.get(ntype, {}) 137 | if "all" in unsubscribed or self.category in unsubscribed: 138 | continue 139 | filtered_receivers.append(receiver) 140 | self.receivers = filtered_receivers 141 | 142 | def save_notifications(self, prepared_receivers): 143 | for receiver in prepared_receivers: 144 | data = { 145 | "group": self.get_type(), 146 | "klass": self.__class__.__name__, 147 | "receiver": self.format_receiver_for_notification_history(receiver), 148 | "details": self.get_notification_history_details(), 149 | "category": self.category, 150 | } 151 | 152 | if hasattr(self.item, "id"): 153 | if isinstance(self.item.id, int): 154 | content_type = ContentType.objects.get_for_model(self.item) 155 | data["content_type"] = content_type 156 | data["object_id"] = self.item.id 157 | 158 | if getattr(settings, "UNIVERSAL_NOTIFICATIONS_HISTORY", True): 159 | if getattr(settings, "UNIVERSAL_NOTIFICATIONS_HISTORY_USE_DATABASE", True): 160 | NotificationHistory.objects.create(**data) 161 | 162 | logger.info("Notification sent: {}".format(data)) 163 | 164 | def send(self): 165 | self.check_category() 166 | self.verify_and_filter_receivers_subscriptions() 167 | prepared_receivers = self.prepare_receivers() 168 | prepared_message = self.prepare_message() 169 | result = self.send_inner(prepared_receivers, prepared_message) 170 | self.save_notifications(prepared_receivers) 171 | return result 172 | 173 | 174 | class WSNotification(NotificationBase): 175 | message = None # required 176 | serializer_class = None # required, DRF serializer 177 | serializer_many = False 178 | check_subscription = False 179 | 180 | def prepare_receivers(self): 181 | return set(self.receivers) 182 | 183 | def prepare_message(self): 184 | return { 185 | "message": self.message, 186 | "data": self.serializer_class(self.item, context=self.get_context(), many=self.serializer_many).data 187 | } 188 | 189 | def format_receiver_for_notification_history(self, receiver): 190 | return receiver.email 191 | 192 | def send_inner(self, prepared_receivers, prepared_message): 193 | for receiver in prepared_receivers: 194 | publish(receiver, additional_data=prepared_message) 195 | 196 | def get_notification_history_details(self): 197 | return "message: %s, serializer: %s" % (self.message, self.serializer_class.__name__) 198 | 199 | @classmethod 200 | def get_type(cls): 201 | return "WebSocket" 202 | 203 | 204 | class SMSNotification(NotificationBase): 205 | message = None # required, django template string 206 | send_async = getattr(settings, "UNIVERSAL_NOTIFICATIONS_SMS_SEND_IN_TASK", True) 207 | 208 | def prepare_receivers(self): 209 | """Filter out duplicated phone numbers""" 210 | receivers = set() 211 | phone_numbers = set() 212 | for r in self.receivers: 213 | if r.phone not in phone_numbers: 214 | receivers.add(r) 215 | 216 | return receivers 217 | 218 | def prepare_message(self): 219 | return Template(self.message).render(Context(self.get_context())) 220 | 221 | def send_inner(self, prepared_receivers, prepared_message): 222 | for receiver in prepared_receivers: 223 | self.context["receiver"] = receiver 224 | send_sms(receiver.phone, self.prepare_message(), send_async=self.send_async) 225 | 226 | def get_notification_history_details(self): 227 | return self.prepare_message() 228 | 229 | @classmethod 230 | def get_type(cls): 231 | return "SMS" 232 | 233 | 234 | class EmailNotification(NotificationBase): 235 | """Email notification 236 | 237 | Attributes: 238 | email_name required 239 | email_subject if not provided, will try to extract "" tag from email template 240 | sender optional sender 241 | categories optional categories which are added to EmailMessage (can be used with SendGrid) 242 | sendgrid_asm SendGrid unsubscribe groups configuration, available properties: 243 | * group_id - ID of an unsubscribe group 244 | * groups_to_display - Unsubscribe groups to display 245 | """ 246 | email_name = None # required 247 | email_subject = None # if not provided, will try to extract "<title>" tag from email template 248 | sender = None # optional 249 | categories = [] # optional 250 | sendgrid_asm = {} 251 | use_premailer = None 252 | 253 | def __init__(self, item, receivers, context=None, attachments=None): 254 | self.attachments = attachments or [] 255 | super(EmailNotification, self).__init__(item, receivers, context) 256 | 257 | @classmethod 258 | def format_receiver(cls, receiver): 259 | receiver_name = "%s %s" % (receiver.first_name, receiver.last_name) 260 | receiver_name = re.sub(r"\S*@\S*\s?", "", receiver_name).strip() 261 | return formataddr((receiver_name, receiver.email)) 262 | 263 | def prepare_receivers(self): 264 | return set(self.receivers) 265 | 266 | def prepare_message(self): 267 | return self.get_context() 268 | 269 | def format_receiver_for_notification_history(self, receiver): 270 | return receiver.email 271 | 272 | def prepare_subject(self): 273 | if self.email_subject: 274 | return Template(self.email_subject).render(Context(self.get_context())) 275 | 276 | def get_notification_history_details(self): 277 | return self.email_name 278 | 279 | @classmethod 280 | def get_type(cls): 281 | return "Email" 282 | 283 | def get_full_template_context(self): 284 | is_secure = getattr(settings, "UNIVERSAL_NOTIFICATIONS_IS_SECURE", False) 285 | protocol = "https://" if is_secure else "http://" 286 | site = Site.objects.get_current() 287 | return { 288 | "site": site, 289 | "STATIC_URL": settings.STATIC_URL, 290 | "is_secure": is_secure, 291 | "protocol": protocol, 292 | "domain": site.domain 293 | } 294 | 295 | def get_template(self): 296 | return get_template("emails/email_%s.html" % self.email_name) 297 | 298 | def send_email(self, subject, context, receiver): 299 | template = self.get_template() 300 | html = template.render(context) 301 | # update paths 302 | base = context["protocol"] + context["domain"] 303 | sender = self.sender or settings.DEFAULT_FROM_EMAIL 304 | if getattr(settings, "UNIVERSAL_NOTIFICATIONS_USE_PREMAILER", True) and self.use_premailer is not False: 305 | html = html.replace("{settings.STATIC_URL}CACHE/".format(settings=settings), 306 | "{settings.STATIC_ROOT}/CACHE/".format(settings=settings)) # get local file 307 | html = Premailer(html, 308 | remove_classes=False, 309 | exclude_pseudoclasses=False, 310 | keep_style_tags=True, 311 | include_star_selectors=True, 312 | strip_important=False, 313 | cssutils_logging_level=logging.CRITICAL, 314 | base_url=base).transform() 315 | 316 | # if subject is not provided, try to extract it from <title> tag 317 | if not subject: 318 | self.email_subject = self.__get_title_from_html(html) 319 | subject = self.prepare_subject() 320 | 321 | email = EmailMessage(subject, html, sender, [receiver], attachments=self.attachments) 322 | if self.categories: 323 | email.categories = self.categories 324 | 325 | if self.sendgrid_asm: 326 | email.asm = self.sendgrid_asm 327 | 328 | email.content_subtype = "html" 329 | email.send(fail_silently=False) 330 | 331 | def send_inner(self, prepared_receivers, prepared_message): 332 | subject = self.email_subject 333 | if subject: 334 | subject = self.prepare_subject() 335 | 336 | context = self.get_full_template_context() 337 | context.update(prepared_message) 338 | for receiver in prepared_receivers: 339 | context["receiver"] = receiver 340 | self.send_email(subject, context, self.format_receiver(receiver)) 341 | 342 | def __get_title_from_html(self, html): 343 | m = re.search(r"<title>(.*?)", html, re.MULTILINE | re.IGNORECASE) 344 | if not m: 345 | raise ImproperlyConfigured("Email notification {} does not provide email_subject nor " 346 | " in the template".format(self.email_name)) 347 | 348 | return m.group(1) 349 | 350 | 351 | class PushNotification(NotificationBase): 352 | title = None # required, django template string 353 | description = "" # optional, django template string 354 | 355 | @classmethod 356 | def get_type(cls): 357 | return "Push" 358 | 359 | def prepare_receivers(self): 360 | return set(self.receivers) 361 | 362 | def prepare_body(self): # self.item & self.context can be used here 363 | return {} 364 | 365 | def prepare_message(self): 366 | context = Context(self.get_context()) 367 | return { 368 | "title": Template(self.title).render(context), 369 | "description": Template(self.description).render(context), 370 | "data": self.prepare_body() 371 | } 372 | 373 | def format_receiver_for_notification_history(self, receiver): 374 | return receiver.email 375 | 376 | def send_inner(self, prepared_receivers, prepared_message): # TODO 377 | for receiver in prepared_receivers: 378 | for d in Device.objects.filter(user=receiver, is_active=True): 379 | d.send_message(prepared_message["title"], prepared_message["description"], **prepared_message["data"]) 380 | 381 | def get_notification_history_details(self): 382 | return self.prepare_message() 383 | -------------------------------------------------------------------------------- /universal_notifications/serializers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.conf import settings 3 | from rest_framework import serializers 4 | 5 | from universal_notifications.models import Device 6 | from universal_notifications.notifications import NotificationBase 7 | 8 | 9 | class DeviceSerializer(serializers.ModelSerializer): 10 | 11 | def create(self, data): 12 | data["user"] = self.context["request"].user 13 | 14 | # do not allow duplicating devices 15 | matching_device = data["user"].devices.filter( 16 | is_active=True, notification_token=data["notification_token"]).first() 17 | if matching_device: 18 | self.context["view"]._matching_device = matching_device 19 | return matching_device 20 | 21 | return super(DeviceSerializer, self).create(data) 22 | 23 | class Meta: 24 | model = Device 25 | fields = ["id", "platform", "notification_token", "device_id", "app_id"] 26 | 27 | 28 | class UnsubscribedSerializer(serializers.Serializer): 29 | unsubscribed_from_all = serializers.BooleanField(required=False) 30 | 31 | def get_configuration(self): 32 | request = self.context.get("request", None) 33 | if request and request.user.is_authenticated: 34 | return NotificationBase.get_mapped_user_notifications_types_and_categories(request.user) 35 | return None 36 | 37 | def to_representation(self, obj): 38 | result = { 39 | "unsubscribed_from_all": obj.unsubscribed_from_all, 40 | "labels": {} 41 | } 42 | 43 | configuration = self.get_configuration() 44 | if configuration: 45 | for ntype, ntype_configuration in configuration.items(): 46 | type_unsubscribed = set(obj.unsubscribed.get(ntype, [])) 47 | result["labels"][ntype] = {} 48 | result[ntype] = { 49 | "unsubscribed_from_all": "all" in type_unsubscribed 50 | } 51 | for key in ntype_configuration: 52 | result[ntype][key] = key not in type_unsubscribed 53 | result["labels"][ntype][key] = settings.UNIVERSAL_NOTIFICATIONS_CATEGORIES[ntype][key] 54 | 55 | return result 56 | 57 | def validate(self, data): 58 | """ validation actually maps categories data & adds them to validated data""" 59 | request = self.context.get("request", None) 60 | unsubscribed = {} 61 | data["unsubscribed"] = unsubscribed 62 | 63 | configuration = self.get_configuration() 64 | if configuration: 65 | for ntype, ntype_configuration in configuration.items(): 66 | unsubscribed[ntype] = [] 67 | if ntype in request.data: 68 | for key in ntype_configuration: 69 | if not request.data[ntype].get(key, True): 70 | unsubscribed[ntype].append(key) 71 | if request.data[ntype].get("unsubscribed_from_all", False): 72 | unsubscribed[ntype].append("all") 73 | 74 | return data 75 | 76 | def update(self, instance, data): 77 | for key, value in data.items(): 78 | setattr(instance, key, value) 79 | return instance 80 | -------------------------------------------------------------------------------- /universal_notifications/signals.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import django.dispatch 3 | 4 | ws_received = django.dispatch.Signal() 5 | -------------------------------------------------------------------------------- /universal_notifications/tasks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import traceback 3 | 4 | import six 5 | from django.conf import settings 6 | 7 | from universal_notifications.backends.sms.base import SMS 8 | from universal_notifications.backends.sms.utils import clean_text 9 | from universal_notifications.models import PhonePendingMessages, PhoneReceivedRaw, PhoneReceiver, PhoneSent 10 | from universal_notifications.signals import ws_received 11 | 12 | try: 13 | from django.utils.importlib import import_module 14 | except ImportError: 15 | from importlib import import_module 16 | 17 | CELERY_APP_PATH = getattr(settings, "CELERY_APP_PATH", False) 18 | 19 | if CELERY_APP_PATH: 20 | __path, __symbol = CELERY_APP_PATH.rsplit(".", 1) 21 | app = getattr(import_module(__path), __symbol) 22 | 23 | @app.task(ignore_result=True) 24 | def parse_received_message_task(message_id): 25 | try: 26 | raw = PhoneReceivedRaw.objects.get(id=message_id, status=PhoneReceivedRaw.STATUS_PENDING) 27 | except PhoneReceivedRaw.DoesNotExist: 28 | return 29 | 30 | try: 31 | sms = SMS() 32 | if sms.parse_received(raw): 33 | raw.status = PhoneReceivedRaw.STATUS_PASS 34 | raw.save() 35 | except Exception: 36 | raw.status = PhoneReceivedRaw.STATUS_FAIL 37 | raw.exception = traceback.format_exc() 38 | raw.save() 39 | 40 | @app.task(ignore_result=True) 41 | def send_message_task(to_number, text, media, priority): 42 | sms = SMS() 43 | 44 | try: 45 | receiver = PhoneReceiver.objects.get(number=to_number) 46 | except PhoneReceiver.DoesNotExist: 47 | service_number = sms.get_service_number() 48 | receiver = PhoneReceiver.objects.create(number=to_number, service_number=service_number) 49 | 50 | obj = PhoneSent() 51 | obj.receiver = receiver 52 | obj.text = six.text_type(clean_text(text)) 53 | obj.media_raw = media 54 | obj.status = PhoneSent.STATUS_QUEUED 55 | 56 | if receiver.is_blocked: 57 | obj.status = PhoneSent.STATUS_FAILED 58 | obj.save() 59 | return 60 | 61 | obj.save() 62 | 63 | data = { 64 | "from_phone": obj.receiver.service_number, 65 | "priority": priority, 66 | "message": obj, 67 | } 68 | PhonePendingMessages.objects.create(**data) 69 | 70 | @app.task(ignore_result=True) 71 | def ws_received_send_signal_task(message_data, channel_emails): 72 | ws_received.send(sender=None, message_data=message_data, channel_emails=channel_emails) 73 | -------------------------------------------------------------------------------- /universal_notifications/templates/emails/email_test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Email template used in tests 6 | 7 | 8 | This is some email 9 | 10 | 11 | -------------------------------------------------------------------------------- /universal_notifications/templates/emails/email_test_empty.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /universal_notifications/templates/emails/fake.html: -------------------------------------------------------------------------------- 1 | {% if template and email %} 2 | {% if template_does_not_exist %} 3 |

Template "emails/email_{{template}}.html" does not exist!

4 | {% else %} 5 | Sent email "{{ template }}" to: {{ email }} 6 | {% endif %} 7 | {% else %} 8 | {% if not template %} 9 |

GET param "template" is required. eg. for emails/email_reset_password_content.html set ?template=reset_password_content

10 | {% endif %} 11 | {% if not email %} 12 |

Please set email in settings variable UNIVERSAL_NOTIFICATIONS_FAKE_EMAIL_TO

13 | {% endif %} 14 | {% endif %} 15 | -------------------------------------------------------------------------------- /universal_notifications/urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.urls import include, re_path 3 | from rest_framework.routers import DefaultRouter 4 | 5 | urlpatterns = [ 6 | re_path(r"^emails/", include("universal_notifications.backends.emails.urls")), 7 | re_path(r"api/", include("universal_notifications.api_urls")), 8 | ] 9 | 10 | router = DefaultRouter() 11 | urlpatterns += router.urls 12 | --------------------------------------------------------------------------------