├── .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 "" 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 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"(.*?)", 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 |
--------------------------------------------------------------------------------