├── .coveragerc ├── .github └── workflows │ └── python-package.yml ├── .gitignore ├── AUTHORS ├── CHANGELOG ├── INSTALL ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── build │ └── empty └── source │ ├── _static │ └── empty │ ├── _templates │ └── empty │ ├── apps.rst │ ├── conf.py │ ├── exceptions.rst │ ├── grouping.rst │ ├── index.rst │ ├── messages.rst │ ├── messengers.rst │ ├── prioritizing.rst │ ├── quickstart.rst │ ├── recipients.rst │ ├── rst_guide.rst │ ├── toolbox.rst │ └── views.rst ├── pytest.ini ├── setup.cfg ├── setup.py ├── sitemessage ├── __init__.py ├── admin.py ├── apps.py ├── backends.py ├── exceptions.py ├── locale │ ├── en │ │ └── LC_MESSAGES │ │ │ └── django.po │ └── ru │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── sitemessage_check_undelivered.py │ │ ├── sitemessage_cleanup.py │ │ ├── sitemessage_probe.py │ │ └── sitemessage_send_scheduled.py ├── messages │ ├── __init__.py │ ├── base.py │ ├── email.py │ └── plain.py ├── messengers │ ├── __init__.py │ ├── base.py │ ├── facebook.py │ ├── smtp.py │ ├── telegram.py │ ├── twitter.py │ ├── vkontakte.py │ └── xmpp.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_subscription.py │ ├── 0003_auto_20210314_1053.py │ ├── 0004_message_group_mark.py │ └── __init__.py ├── models.py ├── settings.py ├── shortcuts.py ├── signals.py ├── static │ └── img │ │ └── sitemessage │ │ └── blank.png ├── templates │ └── sitemessage │ │ ├── messages │ │ ├── _base.html │ │ └── email_html__smtp.html │ │ ├── user_prefs_table-bootstrap.html │ │ └── user_prefs_table.html ├── templatetags │ ├── __init__.py │ └── sitemessage.py ├── tests │ ├── __init__.py │ ├── conftest.py │ ├── test_management.py │ ├── test_messengers.py │ ├── test_misc.py │ ├── test_models.py │ ├── test_toolbox.py │ ├── test_utils.py │ ├── test_views.py │ └── testapp │ │ ├── __init__.py │ │ ├── sitemessages.py │ │ └── urls.py ├── toolbox.py ├── utils.py └── views.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | include = sitemessage/* 3 | omit = sitemessage/migrations/*, sitemessage/tests/*, sitemessage/admin.py, sitemessage/config.py, sitemessage/schortcuts.py 4 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | python-version: [3.7, 3.8, 3.9, "3.10", "3.11"] 18 | django-version: [2.0, 2.1, 2.2, 3.0, 3.1, 3.2, 4.0, 4.1] 19 | 20 | exclude: 21 | - python-version: 3.11 22 | django-version: 2.1 23 | 24 | - python-version: 3.7 25 | django-version: 4.1 26 | - python-version: 3.7 27 | django-version: 4.0 28 | 29 | steps: 30 | - uses: actions/checkout@v2 31 | - name: Set up Python ${{ matrix.python-version }} & Django ${{ matrix.django-version }} 32 | uses: actions/setup-python@v2 33 | with: 34 | python-version: ${{ matrix.python-version }} 35 | - name: Install deps 36 | run: | 37 | python -m pip install pytest coverage coveralls "Django~=${{ matrix.django-version }}.0" 38 | - name: Run tests 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.github_token }} 41 | run: | 42 | coverage run --source=sitemessage setup.py test 43 | coveralls --service=github 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .project 2 | .pydevproject 3 | .idea 4 | .tox 5 | __pycache__ 6 | *.pyc 7 | *.pyo 8 | *.egg-info 9 | docs/_build/ 10 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | django-sitemessage authors 2 | ========================== 3 | 4 | Created by Igor `idle sign` Starikov. 5 | 6 | 7 | Contributors 8 | ------------ 9 | 10 | Stefano 11 | 12 | 13 | 14 | Translators 15 | ----------- 16 | 17 | Russian: Igor Starikov 18 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | django-sitemessage changelog 2 | ============================ 3 | 4 | 5 | v1.4.0 [2023-03-18] 6 | ------------------- 7 | + Added experimental support for message grouping. 8 | * Vkontakte API version bumped up. 9 | 10 | 11 | v1.3.4 [2021-12-18] 12 | ------------------- 13 | ! Fixed typo in Dispatch.READ_STATUS_UNDREAD. Now READ_STATUS_UNREAD. 14 | ! Message.context does not use 'ensure_ascii' anymore for readability. 15 | * Django 4.0 compatibility improved. 16 | * Improved admin pages performance for Subscription, Dispatch and Message (raw_id_fields). 17 | 18 | 19 | v1.3.3 [2021-03-14] 20 | ------------------- 21 | * Added missing migration. 22 | 23 | 24 | v1.3.2 [2020-10-31] 25 | ------------------- 26 | * Drop support for Python 3.5. 27 | * Dropped support for Django < 2.0. 28 | * Fixed deprecation warning. 29 | 30 | 31 | v1.3.1 [2020-01-23] 32 | ------------------- 33 | * Fixed regression in VKontakte targeted wall post. 34 | 35 | 36 | v1.3.0 [2020-01-22] 37 | ------------------- 38 | + Added basic type annotations. 39 | + VKontakteMessenger. Added auxiliary '.get_access_token()' method. 40 | * Fixed loosing dispatch errors in some cases. 41 | 42 | 43 | v1.2.1 [2020-01-21] 44 | ------------------- 45 | * Fixed 'proxies' parameter passing. 46 | * Fixed possible dispatches jamming in 'Processing' status. 47 | 48 | 49 | v1.2.0 [2020-01-21] 50 | ------------------- 51 | ! Dropped Python 2 support. 52 | + Messengers using 'requests' ('vk', 'fb', 'telegram') now support proxies. 53 | 54 | 55 | v1.1.0 [2019-12-08] 56 | ------------------- 57 | ! Dropped QA for Django 1.7. 58 | ! Dropped QA for Python 2. 59 | + Add Django 3.0, removed 1.8 compatibility. Effectively deprecates Py2 support. 60 | 61 | 62 | v1.0.0 63 | ------ 64 | ! Dropped QA for Python 3.4. 65 | * No functional changes. Celebrating 1.0.0. 66 | 67 | 68 | v0.11.1 69 | ------- 70 | * Fixed 'NotSupportedError' on some old DBMSs. 71 | 72 | 73 | v0.11.0 74 | ------- 75 | ! Renamed bogus 'SITEMESSAGE_DEFAULT_SHORTCUT_EMAIL_MESSAGES_TYPE' setting into 'SITEMESSAGE_SHORTCUT_EMAIL_MESSENGER_TYPE'. 76 | + Added 'sitemessage_check_undelivered' command and 'check_undelivered()' function (closes #7). 77 | + Added 'SITEMESSAGE_SHORTCUT_EMAIL_MESSAGE_TYPE' to replace 'SITEMESSAGE_DEFAULT_SHORTCUT_EMAIL_MESSAGES_TYPE'. 78 | + Added dispaches delivery retry in admin (closes #8). 79 | + SMTPMessenger. Added SSL support (closes #10). 80 | + SMTPMessenger. Added timeout support (closes #9). 81 | 82 | 83 | v0.10.0 84 | ------- 85 | + Added 'sitemessage_cleanup' command and 'cleanup_sent_messages()' function (closes #5). 86 | + Added 'sitemessage_probe' command and 'send_test_message()' function (closes #6). 87 | + Sending became distributed system friendlier (closes #4). 88 | 89 | 90 | v0.9.1 91 | ------ 92 | * Updated VKontakte messenger to conform to new rules. 93 | 94 | 95 | v0.9.0 96 | ------ 97 | + Django 2.0 basic compatibility. 98 | * Dropped support for Python<3.4 and Django<1.7. 99 | 100 | 101 | v0.8.4 102 | ------ 103 | * Django 1.11 compatibility improvements. 104 | 105 | 106 | v0.8.3 107 | ------ 108 | * Package distribution fix. 109 | 110 | 111 | v0.8.2 112 | ------ 113 | * `Message.get_subscribers()` now returns only active users by default. 114 | 115 | 116 | v0.8.1 117 | ------ 118 | * Django 1.10 compatibility improved. 119 | 120 | 121 | v0.8.0 122 | ------ 123 | + Implemented VKontakte messenger. 124 | + Implemented Facebook messenger. 125 | 126 | 127 | v0.7.2 128 | ---------- 129 | * Template compilation errors are now considered dispatch errors and properly logged. 130 | 131 | 132 | v0.7.1 133 | ------ 134 | * Django 1.9 compatibility improvements. 135 | 136 | 137 | v0.7.0 138 | ------ 139 | + Implemented Telegram messenger. 140 | + Added `allow_user_subscription` attr to base message and messenger classes. 141 | + Added schedule_tweet() shortcut. 142 | + Added schedule_telegram_message() shortcut. 143 | + Added Messenger.before_after_send_handling() context manager. 144 | * Fixed `get_user_preferences_for_ui()` producing buggy result. 145 | 146 | 147 | v0.6.0 148 | ------ 149 | + Adapted for Django 1.8. 150 | * Russian locale is updated. 151 | 152 | 153 | v0.5.1 154 | ------ 155 | * Message.schedule() now respects message type priority attribute. 156 | * `SITEMESSAGE_EMAIL_BACKEND_MESSAGES_PRIORITY` now defaults to None. 157 | 158 | 159 | v0.5.0 160 | ------ 161 | * IMPORTANT: Package layout changed (messages, messengers locations changed). 162 | * IMPORTANT: Changed default templates search path. 163 | + Implemented subscriptions handling. 164 | + Implemented Django email backend. 165 | + Implemented experimental `mark read` functionality for e-mails. 166 | + Implemented `List-Unsubscribe` e-mail header support for SMTP messenger. 167 | + Added Django 1.7+ migrations. 168 | + Added basic messages templates. 169 | + Added `INIT_BUILTIN_MESSAGE_TYPES` setting. 170 | + Added `EMAIL_BACKEND_MESSAGES_PRIORITY` setting. 171 | + Added `DEFAULT_SHORTCUT_EMAIL_MESSAGES_TYPE` setting. 172 | + Added `SITE_URL` setting. 173 | * Fixed delivery errors logging. 174 | * Dispatch model is now always passed into `compile()`. 175 | * Exception handling scope broadened for Twitter. 176 | 177 | 178 | v0.4.1 179 | ------ 180 | * Fixed `sitemessage_send_scheduled` command failure on Django 1.7. 181 | * Now `time_dispatched` field value is timezone-aware. 182 | 183 | 184 | v0.4.0 185 | ------ 186 | + Django 1.7 ready. 187 | + Added Russian loco. 188 | + Implemented get_message_type_for_app() and override_message_type_for_app(). 189 | 190 | 191 | v0.3.0 192 | ------ 193 | + Added Twitter messenger. 194 | * Messengers are moved into a separate module. 195 | 196 | 197 | v0.2.0 198 | ------ 199 | + Added message priorities support. 200 | + Added support for sending test messages. 201 | + Now dispatches creation updates `dispatches_ready` message property. 202 | + Now message templates may use data from 'message_model' and 'dispatch_model' variables. 203 | + Now message is cached for dispatches with errors. 204 | 205 | 206 | v0.1.0 207 | ------ 208 | + Basic functionality. -------------------------------------------------------------------------------- /INSTALL: -------------------------------------------------------------------------------- 1 | django-sitemessage installation 2 | =============================== 3 | 4 | 5 | Python ``pip`` package is required to install ``django-sitemessage``. 6 | 7 | 8 | From sources 9 | ------------ 10 | 11 | Use the following command line to install ``django-sitemessage`` from sources directory (containing setup.py): 12 | 13 | pip install . 14 | 15 | or 16 | 17 | python setup.py install 18 | 19 | 20 | From PyPI 21 | --------- 22 | 23 | Alternatively you can install ``django-sitemessage`` from PyPI: 24 | 25 | pip install django-sitemessage 26 | 27 | 28 | Use `-U` flag for upgrade: 29 | 30 | pip install -U django-sitemessage 31 | 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2023, Igor `idle sign` Starikov 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the django-sitemessage nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS 2 | include CHANGELOG 3 | include INSTALL 4 | include LICENSE 5 | include README.rst 6 | 7 | include docs/Makefile 8 | recursive-include docs *.rst 9 | recursive-include docs *.py 10 | 11 | recursive-include sitemessage/locale * 12 | recursive-include sitemessage/management *.py 13 | recursive-include sitemessage/messages *.py 14 | recursive-include sitemessage/messengers *.py 15 | recursive-include sitemessage/migrations *.py 16 | recursive-include sitemessage/south_migrations *.py 17 | recursive-include sitemessage/static * 18 | recursive-include sitemessage/templates *.html 19 | recursive-include sitemessage/templatetags *.py 20 | 21 | recursive-exclude * __pycache__ 22 | recursive-exclude * *.py[co] 23 | recursive-exclude * empty 24 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-sitemessage 2 | ================== 3 | https://github.com/idlesign/django-sitemessage 4 | 5 | .. image:: https://img.shields.io/pypi/v/django-sitemessage.svg 6 | :target: https://pypi.python.org/pypi/django-sitemessage 7 | 8 | .. image:: https://img.shields.io/pypi/l/django-sitemessage.svg 9 | :target: https://pypi.python.org/pypi/django-sitemessage 10 | 11 | .. image:: https://img.shields.io/coveralls/idlesign/django-sitemessage/master.svg 12 | :target: https://coveralls.io/r/idlesign/django-sitemessage 13 | 14 | Description 15 | ----------- 16 | 17 | *Reusable application for Django introducing a message delivery framework.* 18 | 19 | Features: 20 | 21 | * **Message Types** - message classes exposing message composition logic (plain text, html, etc.). 22 | * **Messengers** - clients for various protocols (smtp, jabber, twitter, telegram, facebook, vkontakte, etc.); 23 | * Support for user defined message types. 24 | * Support for user defined messenger types. 25 | * Message prioritization. 26 | * Message subscription/unsubscription system. 27 | * Message grouping to prevent flooding. 28 | * Message 'read' indication. 29 | * Means for background message delivery and cleanup. 30 | * Means to debug integration: test requisites, delivery log. 31 | * Django Admin integration. 32 | 33 | 34 | 1. Configure messengers for your project (create ``sitemessages.py`` in one of your apps): 35 | 36 | .. code-block:: python 37 | 38 | from sitemessage.toolbox import register_messenger_objects, register_message_types 39 | from sitemessage.messengers.smtp import SMTPMessenger 40 | 41 | register_messenger_objects( 42 | # Here we register one messenger to deliver emails. 43 | # By default it uses mailing related settings from Django settings file. 44 | SMTPMessenger() 45 | ) 46 | 47 | 48 | 2. Schedule messages for delivery when and where needed (e.g. in a view): 49 | 50 | .. code-block:: python 51 | 52 | from sitemessage.shortcuts import schedule_email 53 | 54 | def send_mail_view(request): 55 | ... 56 | 57 | # Suppose `user_model` is a recipient Django User model instance. 58 | user1_model = ... 59 | 60 | # We pass `request.user` into `sender` to keep track of senders. 61 | schedule_email('Message from sitemessage.', [user1_model, 'user2@host.com'], sender=request.user) 62 | 63 | ... 64 | 65 | 66 | 3. Periodically run Django management command from wherever you like (cli, cron, Celery, uWSGI, etc.): 67 | 68 | ./manage.py sitemessage_send_scheduled 69 | 70 | 71 | And that's only the tip of ``sitemessage`` iceberg, read the docs %) 72 | 73 | 74 | Documentation 75 | ------------- 76 | 77 | http://django-sitemessage.readthedocs.org/ 78 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 14 | 15 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest 16 | 17 | help: 18 | @echo "Please use \`make ' where is one of" 19 | @echo " html to make standalone HTML files" 20 | @echo " dirhtml to make HTML files named index.html in directories" 21 | @echo " singlehtml to make a single large HTML file" 22 | @echo " pickle to make pickle files" 23 | @echo " json to make JSON files" 24 | @echo " htmlhelp to make HTML files and a HTML help project" 25 | @echo " qthelp to make HTML files and a qthelp project" 26 | @echo " devhelp to make HTML files and a Devhelp project" 27 | @echo " epub to make an epub" 28 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 29 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 30 | @echo " text to make text files" 31 | @echo " man to make manual pages" 32 | @echo " changes to make an overview of all changed/added/deprecated items" 33 | @echo " linkcheck to check all external links for integrity" 34 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 35 | 36 | clean: 37 | -rm -rf $(BUILDDIR)/* 38 | 39 | html: 40 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 41 | @echo 42 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 43 | 44 | dirhtml: 45 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 48 | 49 | singlehtml: 50 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 51 | @echo 52 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 53 | 54 | pickle: 55 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 56 | @echo 57 | @echo "Build finished; now you can process the pickle files." 58 | 59 | json: 60 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 61 | @echo 62 | @echo "Build finished; now you can process the JSON files." 63 | 64 | htmlhelp: 65 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 66 | @echo 67 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 68 | ".hhp project file in $(BUILDDIR)/htmlhelp." 69 | 70 | qthelp: 71 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 72 | @echo 73 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 74 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 75 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-sitemessage.qhcp" 76 | @echo "To view the help file:" 77 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-sitemessage.qhc" 78 | 79 | devhelp: 80 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 81 | @echo 82 | @echo "Build finished." 83 | @echo "To view the help file:" 84 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-sitemessage" 85 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-sitemessage" 86 | @echo "# devhelp" 87 | 88 | epub: 89 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 90 | @echo 91 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 92 | 93 | latex: 94 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 95 | @echo 96 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 97 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 98 | "(use \`make latexpdf' here to do that automatically)." 99 | 100 | latexpdf: 101 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 102 | @echo "Running LaTeX files through pdflatex..." 103 | make -C $(BUILDDIR)/latex all-pdf 104 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 105 | 106 | text: 107 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 108 | @echo 109 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 110 | 111 | man: 112 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 113 | @echo 114 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 115 | 116 | changes: 117 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 118 | @echo 119 | @echo "The overview file is in $(BUILDDIR)/changes." 120 | 121 | linkcheck: 122 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 123 | @echo 124 | @echo "Link check complete; look for any errors in the above output " \ 125 | "or in $(BUILDDIR)/linkcheck/output.txt." 126 | 127 | doctest: 128 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 129 | @echo "Testing of doctests in the sources finished, look at the " \ 130 | "results in $(BUILDDIR)/doctest/output.txt." 131 | -------------------------------------------------------------------------------- /docs/build/empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-sitemessage/28db11c674d3d3eb59396a9b1e88b8033ff88e20/docs/build/empty -------------------------------------------------------------------------------- /docs/source/_static/empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-sitemessage/28db11c674d3d3eb59396a9b1e88b8033ff88e20/docs/source/_static/empty -------------------------------------------------------------------------------- /docs/source/_templates/empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-sitemessage/28db11c674d3d3eb59396a9b1e88b8033ff88e20/docs/source/_templates/empty -------------------------------------------------------------------------------- /docs/source/apps.rst: -------------------------------------------------------------------------------- 1 | Sitemessage for reusable applications 2 | ===================================== 3 | 4 | ``sitemessage`` offers reusable applications authors an API to send messages in a way that 5 | can be customized by project authors. 6 | 7 | 8 | 9 | For applications authors 10 | ------------------------ 11 | 12 | 13 | Use **sitemessage.toolbox.get_message_type_for_app** to return a registered message type object for your application. 14 | 15 | 16 | .. note:: 17 | 18 | Project authors can override the above mentioned object to customize messages. 19 | 20 | 21 | .. code-block:: python 22 | 23 | from sitemessage.toolbox import get_message_type_for_app, schedule_messages, recipients 24 | 25 | 26 | def schedule_email(text, to, subject): 27 | """Suppose you're sending a notification and want to sent a plain text e-mail by default.""" 28 | 29 | # This says: give me a message type `email_plain` if not overridden. 30 | message_cls = get_message_type_for_app('myapp', 'email_plain') 31 | message_obj = message_cls(subject, text) 32 | 33 | # And this actually schedules a message to send via `smtp` messenger. 34 | schedule_messages(message_obj, recipients('smtp', to)) 35 | 36 | 37 | .. note:: 38 | 39 | It's advisable for reusable applications authors to document which message types are used 40 | in the app by default, with which arguments, so that project authors may design their 41 | custom message classes accordingly. 42 | 43 | 44 | 45 | For project authors 46 | ------------------- 47 | 48 | Use **sitemessage.toolbox.override_message_type_for_app** to override a given message type used by a certain application with a custom one. 49 | 50 | 51 | .. note:: 52 | 53 | You'd probably need to know which message types are used in an app by default, and with which arguments, 54 | so that you may design your custom message classes accordingly (e.g. by subclassing the default type). 55 | 56 | 57 | .. code-block:: python 58 | 59 | from sitemessage.toolbox import override_message_type_for_app 60 | 61 | # This will override `email_plain` message type by `my_custom_email_plain` for `myapp` application. 62 | override_message_type_for_app('myapp', 'email_plain', 'my_custom_email_plain') 63 | 64 | 65 | .. warning:: 66 | 67 | Be sure to call ``override_message_type_for_app`` beforehand. So that to the moment when a thirdparty app 68 | will try to send a message, message type is overridden. 69 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-sitemessage documentation build configuration file. 4 | # 5 | # This file is execfile()d with the current directory set to its containing dir. 6 | # 7 | # Note that not all possible configuration values are present in this 8 | # autogenerated file. 9 | # 10 | # All configuration values have a default; values that are commented out 11 | # serve to show the default. 12 | 13 | import sys, os 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | sys.path.insert(0, os.path.abspath('../../')) 19 | from sitemessage import VERSION 20 | 21 | # -- Mocking ------------------------------------------------------------------ 22 | 23 | # This is used to mock certain modules. 24 | # It helps to build docs in environments where those modules are not available. 25 | # E.g. it could be useful for http://readthedocs.org/ 26 | MODULES_TO_MOCK = [ # TODO fix autodocs 27 | 'django', 28 | 'django.conf', 29 | 'django.contrib.auth', 30 | 'django.contrib.auth.models', 31 | 'django.utils', 32 | 'django.utils.importlib', 33 | 'django.utils.module_loading', 34 | 'django.utils.translation', 35 | 'django.template.loader', 36 | ] 37 | 38 | 39 | class ModuleMock(object): 40 | 41 | __all__ = [] 42 | 43 | def __init__(self, *args, **kwargs): 44 | pass 45 | 46 | def __call__(self, *args, **kwargs): 47 | return ModuleMock() 48 | 49 | @classmethod 50 | def __getattr__(cls, name): 51 | if name in ('__file__', '__path__'): 52 | return '/dev/null' 53 | elif name[0] == name[0].upper(): 54 | MockType = type(name, (), {}) 55 | MockType.__module__ = __name__ 56 | return MockType 57 | else: 58 | return ModuleMock() 59 | 60 | for mod_name in MODULES_TO_MOCK: 61 | sys.modules[mod_name] = ModuleMock() 62 | 63 | 64 | # -- General configuration ----------------------------------------------------- 65 | 66 | # If your documentation needs a minimal Sphinx version, state it here. 67 | #needs_sphinx = '1.0' 68 | 69 | # Add any Sphinx extension module names here, as strings. They can be extensions 70 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 71 | extensions = ['sphinx.ext.autodoc'] 72 | 73 | # Add any paths that contain templates here, relative to this directory. 74 | templates_path = ['_templates'] 75 | 76 | # The suffix of source filenames. 77 | source_suffix = '.rst' 78 | 79 | # The encoding of source files. 80 | #source_encoding = 'utf-8-sig' 81 | 82 | # The master toctree document. 83 | master_doc = 'index' 84 | 85 | # General information about the project. 86 | project = u'django-sitemessage' 87 | copyright = u'2014-2023, Igor `idle sign` Starikov' 88 | 89 | # The version info for the project you're documenting, acts as replacement for 90 | # |version| and |release|, also used in various other places throughout the 91 | # built documents. 92 | # 93 | # The short X.Y version. 94 | version = '.'.join(map(str, VERSION)) 95 | # The full version, including alpha/beta/rc tags. 96 | release = '.'.join(map(str, VERSION)) 97 | 98 | # The language for content autogenerated by Sphinx. Refer to documentation 99 | # for a list of supported languages. 100 | #language = None 101 | 102 | # There are two options for replacing |today|: either, you set today to some 103 | # non-false value, then it is used: 104 | #today = '' 105 | # Else, today_fmt is used as the format for a strftime call. 106 | #today_fmt = '%B %d, %Y' 107 | 108 | # List of patterns, relative to source directory, that match files and 109 | # directories to ignore when looking for source files. 110 | exclude_patterns = [] 111 | 112 | # The reST default role (used for this markup: `text`) to use for all documents. 113 | #default_role = None 114 | 115 | # If true, '()' will be appended to :func: etc. cross-reference text. 116 | #add_function_parentheses = True 117 | 118 | # If true, the current module name will be prepended to all description 119 | # unit titles (such as .. function::). 120 | #add_module_names = True 121 | 122 | # If true, sectionauthor and moduleauthor directives will be shown in the 123 | # output. They are ignored by default. 124 | #show_authors = False 125 | 126 | # The name of the Pygments (syntax highlighting) style to use. 127 | pygments_style = 'sphinx' 128 | 129 | # A list of ignored prefixes for module index sorting. 130 | #modindex_common_prefix = [] 131 | 132 | 133 | # -- Options for HTML output --------------------------------------------------- 134 | 135 | # The theme to use for HTML and HTML Help pages. See the documentation for 136 | # a list of builtin themes. 137 | html_theme = 'default' 138 | 139 | # Theme options are theme-specific and customize the look and feel of a theme 140 | # further. For a list of options available for each theme, see the 141 | # documentation. 142 | #html_theme_options = {} 143 | 144 | # Add any paths that contain custom themes here, relative to this directory. 145 | #html_theme_path = [] 146 | 147 | # The name for this set of Sphinx documents. If None, it defaults to 148 | # " v documentation". 149 | #html_title = None 150 | 151 | # A shorter title for the navigation bar. Default is the same as html_title. 152 | #html_short_title = None 153 | 154 | # The name of an image file (relative to this directory) to place at the top 155 | # of the sidebar. 156 | #html_logo = None 157 | 158 | # The name of an image file (within the static path) to use as favicon of the 159 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 160 | # pixels large. 161 | #html_favicon = None 162 | 163 | # Add any paths that contain custom static files (such as style sheets) here, 164 | # relative to this directory. They are copied after the builtin static files, 165 | # so a file named "default.css" will overwrite the builtin "default.css". 166 | html_static_path = ['_static'] 167 | 168 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 169 | # using the given strftime format. 170 | #html_last_updated_fmt = '%b %d, %Y' 171 | 172 | # If true, SmartyPants will be used to convert quotes and dashes to 173 | # typographically correct entities. 174 | #html_use_smartypants = True 175 | 176 | # Custom sidebar templates, maps document names to template names. 177 | #html_sidebars = {} 178 | 179 | # Additional templates that should be rendered to pages, maps page names to 180 | # template names. 181 | #html_additional_pages = {} 182 | 183 | # If false, no module index is generated. 184 | #html_domain_indices = True 185 | 186 | # If false, no index is generated. 187 | #html_use_index = True 188 | 189 | # If true, the index is split into individual pages for each letter. 190 | #html_split_index = False 191 | 192 | # If true, links to the reST sources are added to the pages. 193 | #html_show_sourcelink = True 194 | 195 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 196 | #html_show_sphinx = True 197 | 198 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 199 | #html_show_copyright = True 200 | 201 | # If true, an OpenSearch description file will be output, and all pages will 202 | # contain a tag referring to it. The value of this option must be the 203 | # base URL from which the finished HTML is served. 204 | #html_use_opensearch = '' 205 | 206 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 207 | #html_file_suffix = None 208 | 209 | # Output file base name for HTML help builder. 210 | htmlhelp_basename = 'django-sitemessagedoc' 211 | 212 | 213 | # -- Options for LaTeX output -------------------------------------------------- 214 | 215 | # The paper size ('letter' or 'a4'). 216 | #latex_paper_size = 'letter' 217 | 218 | # The font size ('10pt', '11pt' or '12pt'). 219 | #latex_font_size = '10pt' 220 | 221 | # Grouping the document tree into LaTeX files. List of tuples 222 | # (source start file, target name, title, author, documentclass [howto/manual]). 223 | latex_documents = [ 224 | ('index', 'django-sitemessage.tex', u'django-sitemessage Documentation', 225 | u'Igor `idle sign` Starikov', 'manual'), 226 | ] 227 | 228 | # The name of an image file (relative to this directory) to place at the top of 229 | # the title page. 230 | #latex_logo = None 231 | 232 | # For "manual" documents, if this is true, then toplevel headings are parts, 233 | # not chapters. 234 | #latex_use_parts = False 235 | 236 | # If true, show page references after internal links. 237 | #latex_show_pagerefs = False 238 | 239 | # If true, show URL addresses after external links. 240 | #latex_show_urls = False 241 | 242 | # Additional stuff for the LaTeX preamble. 243 | #latex_preamble = '' 244 | 245 | # Documents to append as an appendix to all manuals. 246 | #latex_appendices = [] 247 | 248 | # If false, no module index is generated. 249 | #latex_domain_indices = True 250 | 251 | 252 | # -- Options for manual page output -------------------------------------------- 253 | 254 | # One entry per manual page. List of tuples 255 | # (source start file, name, description, authors, manual section). 256 | man_pages = [ 257 | ('index', 'django-sitemessage', u'django-sitemessage Documentation', 258 | [u'Igor `idle sign` Starikov'], 1) 259 | ] 260 | -------------------------------------------------------------------------------- /docs/source/exceptions.rst: -------------------------------------------------------------------------------- 1 | Exceptions 2 | ========== 3 | 4 | 5 | The following exception classes are used by `sitemessage`. 6 | 7 | 8 | .. automodule:: sitemessage.exceptions 9 | :members: 10 | 11 | -------------------------------------------------------------------------------- /docs/source/grouping.rst: -------------------------------------------------------------------------------- 1 | Grouping messages 2 | ================= 3 | 4 | **sitemessage** allows you to group messages in such a way that even if your application 5 | generates many messages (between send attempts) your user receives them as one. 6 | 7 | 8 | .. code-block:: python 9 | 10 | from sitemessage.messages.base import MessageBase 11 | 12 | 13 | class MyMessage(MessageBase): 14 | 15 | ... 16 | 17 | # Define group ID at class level or as a @property 18 | group_mark = 'groupme' 19 | 20 | # In case your message has some complex context 21 | # you may want to override 'merge_context' to add a new message 22 | # context to the context already existing in message stored in DB 23 | @classmethod 24 | def merge_context(cls, context: dict, new_context: dict) -> dict: 25 | merged = ... # 26 | return merged 27 | 28 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | django-sitemessage documentation 2 | ================================ 3 | https://github.com/idlesign/django-sitemessage 4 | 5 | 6 | 7 | Description 8 | ----------- 9 | 10 | *Reusable application for Django introducing a message delivery framework.* 11 | 12 | Features: 13 | 14 | * **Message Types** - message classes exposing message composition logic (plain text, html, etc.). 15 | * **Messengers** - clients for various protocols (smtp, jabber, twitter, telegram, facebook, vkontakte, etc.); 16 | * Support for user defined message types. 17 | * Support for user defined messenger types. 18 | * Message prioritization. 19 | * Message subscription/unsubscription system. 20 | * Message grouping to prevent flooding. 21 | * Message 'read' indication. 22 | * Means for background message delivery and cleanup. 23 | * Means to debug integration: test requisites, delivery log. 24 | * Django Admin integration. 25 | 26 | Currently supported messengers: 27 | 28 | 1. SMTP; 29 | 2. XMPP (requires ``sleekxmpp`` package); 30 | 3. Twitter (requires ``twitter`` package); 31 | 4. Telegram (requires ``requests`` package); 32 | 5. Facebook (requires ``requests`` package); 33 | 6. VKontakte (requires ``requests`` package). 34 | 35 | 36 | 37 | Requirements 38 | ------------ 39 | 40 | 1. Python 3.7+ 41 | 2. Django 2.0+ 42 | 43 | 44 | 45 | Table of Contents 46 | ----------------- 47 | 48 | .. toctree:: 49 | :maxdepth: 2 50 | 51 | quickstart 52 | toolbox 53 | messages 54 | messengers 55 | exceptions 56 | prioritizing 57 | grouping 58 | recipients 59 | views 60 | apps 61 | 62 | 63 | Get involved into django-sitemessage 64 | ------------------------------------ 65 | 66 | **Submit issues.** If you spotted something weird in application behavior or want to propose a feature you can do 67 | that at https://github.com/idlesign/django-sitemessage/issues 68 | 69 | **Write code.** If you are eager to participate in application development, fork it 70 | at https://github.com/idlesign/django-sitemessage, write your code, whether it should be a bugfix or a feature 71 | implementation, and make a pull request right from the forked project page. 72 | 73 | **Translate.** If want to translate the application into your native language use Transifex: 74 | https://www.transifex.net/projects/p/django-sitemessage/. 75 | 76 | **Spread the word.** If you have some tips and tricks or any other words in mind that you think might be of interest 77 | for the others — publish it. 78 | 79 | 80 | Also 81 | ---- 82 | 83 | If the application is not what you want for messaging with Django, you might be interested in considering 84 | other choices at https://www.djangopackages.com/grids/g/notification/ or https://www.djangopackages.com/grids/g/newsletter/ 85 | or https://www.djangopackages.com/grids/g/email/. 86 | -------------------------------------------------------------------------------- /docs/source/messages.rst: -------------------------------------------------------------------------------- 1 | Messages 2 | ======== 3 | 4 | 5 | `sitemessage` message classes expose message composition logic (plain text, html, etc.). 6 | 7 | You can either use builtin classes or define your own. 8 | 9 | 10 | Helper functions 11 | ---------------- 12 | 13 | * **sitemessage.toolbox.register_message_types(\*message_types)** 14 | 15 | Registers message types (classes). 16 | 17 | * **get_registered_message_types()** 18 | 19 | Returns registered message types dict indexed by their aliases. 20 | 21 | * **get_registered_message_type(message_type)** 22 | 23 | Returns registered message type (class) by alias, 24 | 25 | 26 | 27 | Builtin message types 28 | --------------------- 29 | 30 | Builtin message types are available from **sitemessage.messages**: 31 | 32 | * **sitemessage.messages.plain.PlainTextMessage** 33 | 34 | * **sitemessage.messages.email.EmailTextMessage** 35 | 36 | * **sitemessage.messages.email.EmailHtmlMessage** 37 | 38 | 39 | 40 | User defined message types 41 | -------------------------- 42 | 43 | To define a message type one needs to inherit from **sitemessage.messages.base.MessageBase** (or a builtin message class), 44 | and to register it with **sitemessage.toolbox.register_message_types** (put these instructions 45 | into `sitemessages.py` in one of your apps): 46 | 47 | 48 | .. code-block:: python 49 | 50 | from sitemessage.messages.base import MessageBase 51 | from sitemessage.toolbox import register_message_types 52 | from django.utils import timezone 53 | 54 | 55 | class MyMessage(MessageBase): 56 | 57 | # Message types could be addressed by aliases. 58 | alias = 'mymessage' 59 | 60 | # Message type title to show up in UI 61 | title = 'Super message' 62 | 63 | # Define a template path to build messages from. 64 | # You can omit this setting and place your template under 65 | # `templates/sitemessage/messages/` naming it as `mymessage__.html` 66 | # where is a messenger alias, e.g. `smtp`. 67 | template = 'mymessages/mymessage.html' 68 | 69 | # Define a send retry limit for that message type. 70 | send_retry_limit = 10 71 | 72 | # If we don't want users to subscribe for messages of this type 73 | # (see get_user_preferences_for_ui()) we just forbid such subscriptions. 74 | allow_user_subscription = False 75 | 76 | def __init__(self, text, date): 77 | # Calling base class __init__ and passing message context 78 | super(MyMessage, self).__init__({'text': text, 'date': date}) 79 | 80 | @classmethod 81 | def get_template_context(cls, context): 82 | """Here we can add some data into template context 83 | right before rendering. 84 | 85 | """ 86 | context.update({'greeting': 'Hi!'}) 87 | return context 88 | 89 | @classmethod 90 | def create(cls, text): 91 | """Let it be an alternative constructor - kind of a shortcut.""" 92 | 93 | # This recipient list is comprised of users subscribed to this message type. 94 | recipients = cls.get_subscribers() 95 | 96 | # Or we can build recipient list for a certain messenger manually. 97 | # recipients = cls.recipients('smtp', 'someone@sowhere.local') 98 | 99 | date_now = timezone.now().date().strftime('%d.%m.%Y') 100 | cls(text, date_now).schedule(recipients) 101 | 102 | register_message_types(MyMessage) 103 | 104 | 105 | .. note:: 106 | 107 | Look through ``MessageBase`` and other builtin message classes for more code examples. 108 | 109 | 110 | Now, as long as our message type uses a template, let's create it (`mymessages/mymessage.html`): 111 | 112 | .. code-block:: html 113 | 114 | 115 | 116 | 117 | 118 | {{ greeting }} 119 | 120 | 121 |

{{ greeting }}

122 | {{ text }} 123 |
124 | {{ date }} 125 | 126 | 127 | 128 | 129 | .. note:: 130 | 131 | The following context variables are available in templates by default: 132 | 133 | **SITE_URL** - base site URL 134 | 135 | **message_model** - message model data 136 | 137 | **dispatch_model** - message dispatch model data 138 | 139 | **directive_unsubscribe** - unsubscribe directive string (e.g. URL, command) 140 | 141 | **directive_mark_read** - mark dispatch as read directive string (e.g. Url, command) 142 | 143 | 144 | 145 | After that you can schedule and send messages of this new type: 146 | 147 | .. code-block:: python 148 | 149 | from sitemessage.toolbox import schedule_messages, recipients 150 | from myproject.sitemessages import MyMessage 151 | 152 | 153 | # Scheduling message send via smtp. 154 | schedule_messages(MyMessage('Some text', '17.06.2014'), recipients('smtp', 'user1@host.com')) 155 | 156 | # Or we can use out shortcut method: 157 | MyMessage.create('Some other text') 158 | -------------------------------------------------------------------------------- /docs/source/messengers.rst: -------------------------------------------------------------------------------- 1 | Messengers 2 | ========== 3 | 4 | 5 | `sitemessage` messenger classes implement clients for various protocols (smtp, jabber, etc.). 6 | 7 | You can either use builtin classes or define your own. 8 | 9 | 10 | Helper functions 11 | ---------------- 12 | 13 | * ``sitemessage.toolbox.register_messenger_objects(*messengers)`` 14 | 15 | Registers (configures) messengers. 16 | 17 | * ``sitemessage.toolbox.get_registered_messenger_objects()`` 18 | 19 | Returns registered (configured) messengers dict indexed by messenger aliases. 20 | 21 | * ``sitemessage.toolbox.get_registered_messenger_object(messenger)`` 22 | 23 | Returns registered (configured) messenger by alias, 24 | 25 | 26 | 27 | Builtin messengers 28 | ------------------ 29 | 30 | Builtin messengers are available from **sitemessage.messengers**: 31 | 32 | 33 | smtp.SMTPMessenger 34 | ~~~~~~~~~~~~~~~~~~ 35 | 36 | aliased *smtp* 37 | 38 | .. warning:: 39 | 40 | Uses Python's built-in ``smtplib``. 41 | 42 | 43 | 44 | xmpp.XMPPSleekMessenger 45 | ~~~~~~~~~~~~~~~~~~~~~~~ 46 | 47 | aliased *xmppsleek* 48 | 49 | .. warning:: 50 | 51 | Requires ``sleekxmpp`` package. 52 | 53 | .. code-block:: python 54 | 55 | from sitemessage.toolbox import schedule_messages, recipients 56 | 57 | 58 | # Sending jabber message. 59 | schedule_messages('Hello there!', recipients('xmppsleek', 'somebody@example.ru')) 60 | 61 | 62 | 63 | twitter.TwitterMessenger 64 | ~~~~~~~~~~~~~~~~~~~~~~~~ 65 | 66 | aliased *twitter* 67 | 68 | .. warning:: 69 | 70 | Requires ``twitter`` package. 71 | 72 | .. code-block:: python 73 | 74 | from sitemessage.toolbox import schedule_messages, recipients 75 | 76 | 77 | # Twitting example. 78 | schedule_messages('My tweet.', recipients('twitter', '')) 79 | 80 | # Tweet to somebody. 81 | schedule_messages('Hey, that is my tweet for you.', recipients('twitter', 'idlesign')) 82 | 83 | 84 | 85 | telegram.TelegramMessenger 86 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 87 | 88 | aliased *telegram* 89 | 90 | .. warning:: 91 | 92 | Requires ``requests`` package and a registered Telegram Bot. See https://core.telegram.org/bots/api 93 | 94 | To send messages to a channel, your bot needs to be and administrator of that channel. 95 | 96 | 97 | .. code-block:: python 98 | 99 | from sitemessage.toolbox import schedule_messages, recipients 100 | 101 | 102 | # Let's send a message to chat with ID 12345678. 103 | # (To get chat IDs from `/start` command messages sent to our bot 104 | # by users you can use get_chat_ids() method of Telegram messenger). 105 | schedule_messages('Hi there!', recipients('telegram', '12345678')) 106 | 107 | # Message to a channel mychannel 108 | schedule_messages('Hi all!', recipients('telegram', '@mychannel')) 109 | 110 | 111 | facebook.FacebookMessenger 112 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 113 | 114 | aliased *fb* 115 | 116 | .. warning:: 117 | 118 | Requires ``requests`` package, registered FB application and page. 119 | 120 | See ``FacebookMessenger`` docstring for detailed instructions. 121 | 122 | 123 | .. code-block:: python 124 | 125 | from sitemessage.toolbox import schedule_messages, recipients 126 | 127 | 128 | # Schedule a message or a URL for FB timeline. 129 | schedule_messages('Hi there!', recipients('fb', '')) 130 | 131 | 132 | vkontakte.VKontakteMessenger 133 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 134 | 135 | aliased *vk* 136 | 137 | .. warning:: 138 | 139 | Requires ``requests`` package, registered VK application and community page. 140 | 141 | See ``VKontakteMessenger`` docstring for detailed instructions. 142 | 143 | 144 | .. code-block:: python 145 | 146 | from sitemessage.toolbox import schedule_messages, recipients 147 | 148 | 149 | # Schedule a message or a URL for VK page wall. 1245 - user_id; use -12345 (with minus) to post to community wall. 150 | schedule_messages('Hi there!', recipients('vk', '12345')) 151 | 152 | 153 | Proxying 154 | -------- 155 | 156 | Some messengers (`vk`, `fb`, `telegram`) are able to use proxies (e.g. SOCKS5). 157 | 158 | One may pass `proxy` argument to use proxies. 159 | 160 | .. code-block:: python 161 | 162 | 163 | TelegramMessenger('token', proxy={'https': 'socks5://user:pass@host:port'}) 164 | 165 | 166 | Sending test messages 167 | --------------------- 168 | 169 | After a messenger is configured you can try whether it works properly using its **send_test_message** method: 170 | 171 | .. code-block:: python 172 | 173 | from sitemessage.messengers.smtp import SMTPMessenger 174 | 175 | 176 | msgr = SMTPMessenger('user1@host.com', 'user1', 'user1password', host='smtp.host.com', use_tls=True) 177 | msgr.send_test_message('user1@host.com', 'This is a test message') 178 | 179 | 180 | 181 | User defined messengers 182 | ----------------------- 183 | 184 | To define a message type one needs to inherit from **sitemessage.messengers.base.MessengerBase** (or a builtin messenger class), 185 | and to register it with **sitemessage.toolbox.register_messenger_objects** (put these instructions 186 | into `sitemessages.py` in one of your apps): 187 | 188 | 189 | .. code-block:: python 190 | 191 | from sitemessage.messengers.base import MessengerBase 192 | from sitemessage.toolbox import register_messenger_objects 193 | 194 | 195 | class MyMessenger(MessengerBase): 196 | 197 | # Messengers could be addressed by aliases. 198 | alias = 'mymessenger' 199 | 200 | # Messenger title to show up in UI 201 | title = 'Super messenger' 202 | 203 | # If we don't want users to subscribe for messages from that messenger 204 | # (see get_user_preferences_for_ui()) we just forbid such subscriptions. 205 | allow_user_subscription = False 206 | 207 | def __init__(self): 208 | """This messenger doesn't accept any configuration arguments. 209 | Other may expect login, password, host, etc. to connect this messenger to a service. 210 | 211 | """ 212 | @classmethod 213 | def get_address(cls, recipient): 214 | address = recipient 215 | if hasattr(recipient, 'username'): 216 | # We'll simply get address from User object `username`. 217 | address = '%s--address' % recipient.username 218 | return address 219 | 220 | def before_send(self): 221 | """We don't need that for now, but usually here will be messenger warm up (connect) code.""" 222 | 223 | def after_send(self): 224 | """We don't need that for now, but usually here will be messenger cool down (disconnect) code.""" 225 | 226 | def send(self, message_cls, message_model, dispatch_models): 227 | """This is the main sending method that every messenger must implement.""" 228 | 229 | # `dispatch_models` from sitemessage are models representing a dispatch 230 | # of a certain message_model for a definite addressee. 231 | for dispatch_model in dispatch_models: 232 | 233 | # For demonstration purposes we won't send a dispatch anywhere, 234 | # we'll just mark it as sent: 235 | self.mark_sent(dispatch_model) # See also: self.mark_failed() and self.mark_error(). 236 | 237 | register_messenger_objects(MyMessenger()) 238 | 239 | 240 | .. note:: 241 | 242 | Look through ``MessengerBase`` and other builtin messenger classes for more information and 243 | code examples. 244 | 245 | 246 | After that you can schedule and send messages with your messenger as usual: 247 | 248 | .. code-block:: python 249 | 250 | from sitemessage.toolbox import schedule_messages, recipients 251 | 252 | 253 | user2 = ... # Let's suppose it's an instance of Django user model. 254 | # We'll just try to send PlainText message. 255 | schedule_messages('Some plain text message', recipients('mymessenger', ['user1--address', user2])) 256 | -------------------------------------------------------------------------------- /docs/source/prioritizing.rst: -------------------------------------------------------------------------------- 1 | Prioritizing messages 2 | ===================== 3 | 4 | **sitemessage** supports message sending prioritization: any message might be given 5 | a positive number to describe its priority. 6 | 7 | .. note:: 8 | 9 | It's up to you to decide the meaning of priority numbers. 10 | 11 | 12 | Prioritization is supported on the following two levels: 13 | 14 | 15 | 1. You can define `priority` within your message type class. 16 | 17 | .. code-block:: python 18 | 19 | from sitemessage.messages.base import MessageBase 20 | 21 | 22 | class MyMessage(MessageBase): 23 | 24 | alias = 'mymessage' 25 | 26 | priority = 10 # Messages of this type will automatically have priority of 10. 27 | 28 | ... 29 | 30 | 31 | 2. Or you can override priority defined within message type, by supplying `priority` argument 32 | to messages scheduling functions. 33 | 34 | .. code-block:: python 35 | 36 | from sitemessage.shortcuts import schedule_email 37 | from sitemessage.toolbox import schedule_messages, recipients 38 | 39 | 40 | schedule_email('Email from sitemessage.', 'user2@host.com', priority=1) 41 | 42 | # or 43 | 44 | schedule_messages('My message', recipients('smtp', 'user1@host.com'), priority=16) 45 | 46 | 47 | 48 | After that you can use **sitemessage_send_scheduled** management command with **--priority** 49 | argument to send message when needed:: 50 | 51 | ./manage.py sitemessage_send_scheduled --priority 10 52 | 53 | 54 | .. note:: 55 | 56 | Use a scheduler (e.g cron, uWSGI cron/cron2, etc.) to send messages with different priorities 57 | on different days or intervals, and even simultaneously. 58 | -------------------------------------------------------------------------------- /docs/source/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quickstart 2 | ========== 3 | 4 | * Add the **sitemessage** application to INSTALLED_APPS in your settings file (usually 'settings.py'). 5 | * Run './manage.py syncdb' to install `sitemessage` tables into database. 6 | 7 | 8 | .. note:: 9 | 10 | When switching from an older version do not forget to upgrade your database schema. 11 | 12 | That could be done with the following command issued in your Django project directory:: 13 | 14 | ./manage.py migrate 15 | 16 | 17 | 1. Configure messengers for your project (create ``sitemessages.py`` in one of your apps): 18 | 19 | .. code-block:: python 20 | 21 | from sitemessage.toolbox import register_messenger_objects 22 | from sitemessage.messengers.smtp import SMTPMessenger 23 | from sitemessage.messengers.xmpp import XMPPSleekMessenger 24 | 25 | 26 | # We register two messengers to deliver emails and jabber messages. 27 | register_messenger_objects( 28 | SMTPMessenger('user1@host.com', 'user1', 'user1password', host='smtp.host.com', use_tls=True), 29 | XMPPSleekMessenger('user1@jabber.host.com', 'user1password', 'jabber.host.com'), 30 | ) 31 | 32 | # Or you may want to define your own message type for further usage. 33 | class MyMessage(MessageBase): 34 | 35 | alias = 'myxmpp' 36 | supported_messengers = ['xmppsleek'] 37 | 38 | @classmethod 39 | def create(cls, message: str): 40 | cls(message).schedule(cls.recipients('xmppsleek', ['a@some.tld', 'b@some.tld', ])) 41 | 42 | register_message_types(MyMessage) 43 | 44 | 45 | 2. Schedule messages for delivery when and where needed (e.g. in a view): 46 | 47 | .. code-block:: python 48 | 49 | from sitemessage.shortcuts import schedule_email, schedule_jabber_message 50 | from .sitemessages import MyFbMessage 51 | 52 | 53 | def send_messages_view(request): 54 | ... 55 | # Suppose `user_model` is a recipient User Model instance. 56 | user1_model = ... 57 | 58 | # Schedule both email and jabber messages to delivery. 59 | schedule_email('Email from sitemessage.', [user1_model, 'user2@host.com']) 60 | schedule_jabber_message('Jabber message from sitetree', ['user1@jabber.host.com', 'user2@jabber.host.com']) 61 | ... 62 | 63 | # Or if you want to send your message type: 64 | MyMessage.create('Hi there!') 65 | 66 | 67 | 3. Periodically run Django management command from wherever you like (cli, cron, Celery, uWSGI, etc.):: 68 | 69 | ./manage.py sitemessage_send_scheduled 70 | -------------------------------------------------------------------------------- /docs/source/recipients.rst: -------------------------------------------------------------------------------- 1 | Recipients and subscriptions 2 | ============================ 3 | 4 | 5 | Postponed dispatches 6 | -------------------- 7 | 8 | Note that when scheduling a message you can omit `recipients` parameter. 9 | 10 | In that case no dispatch objects are created on scheduling, instead the process of creation 11 | is postponed until `prepare_dispatches()` function is called. 12 | 13 | 14 | .. code-block:: python 15 | 16 | from sitemessage.toolbox import schedule_messages, prepare_dispatches 17 | 18 | # Here `recipients` parameter is omitted ... 19 | schedule_messages(MyMessage('Some text')) 20 | 21 | # ... instead dispatches are created later. 22 | prepare_dispatches() 23 | 24 | 25 | 26 | `prepare_dispatches()` by default generates dispatches using recipients list comprised 27 | from users subscription preferences data (see below). 28 | 29 | 30 | Handling subscriptions 31 | ---------------------- 32 | 33 | **sitemessage** supports basic subscriptions mechanics, and that's how it works. 34 | 35 | 36 | **sitemessage.toolbox.get_user_preferences_for_ui** is able to generate user subscription preferences data, 37 | that could be rendered in HTML as table using **sitemessage_prefs_table** template tag. 38 | 39 | .. note:: 40 | 41 | **sitemessage_prefs_table** tag support table layout customization through custom templates. 42 | 43 | * **user_prefs_table-bootstrap.html** - Bootstrap-style table. 44 | 45 | {% sitemessage_prefs_table from subscr_prefs template "sitemessage/user_prefs_table-bootstrap.html" %} 46 | 47 | 48 | This table in its turn could be placed in *form* tag to allow users to choose message types they want to receive 49 | using various messengers. 50 | 51 | At last **sitemessage.toolbox.set_user_preferences_from_request** can process *form* data from a request 52 | and store subscription data into DB. 53 | 54 | 55 | .. code-block:: python 56 | 57 | from django.shortcuts import render 58 | from sitemessage.toolbox import set_user_preferences_from_request, get_user_preferences_for_ui 59 | 60 | 61 | def user_preferences(self, request): 62 | """Let's suppose this simplified view handles user preferences.""" 63 | 64 | ... 65 | 66 | if request.POST: 67 | # Process form data: 68 | set_user_preferences_from_request(request) 69 | ... 70 | 71 | # Prepare preferences data. 72 | subscr_prefs = get_user_preferences_for_ui(request.user) 73 | 74 | ... 75 | 76 | return render(request, 'user_preferences.html', {'subscr_prefs': subscr_prefs}) 77 | 78 | 79 | 80 | .. note:: 81 | 82 | **get_user_preferences_for_ui** allows messenger titles customization and both 83 | message types and messengers filtering. You can also forbid subscriptions on message type 84 | or messenger level by setting `allow_user_subscription` class attribute to `False`. 85 | 86 | 87 | And that's what is in a template used by the view above: 88 | 89 | .. code-block:: html 90 | 91 | 92 | {% load sitemessage %} 93 | 94 |
95 | {% csrf_token %} 96 | 97 | 98 | {% sitemessage_prefs_table from subscr_prefs %} 99 | 100 | 101 |
102 | 103 | 104 | .. note:: 105 | 106 | You can get subscribers as recipients list right from your message type, using `get_subscribers()` method. 107 | 108 | 109 | Handling unsubscriptions 110 | ------------------------ 111 | 112 | .. _handle_unsubscriptions: 113 | 114 | **sitemessage** bundles some views, and one of those allows users to unsubscribe from certain message types 115 | just by visiting it. 116 | 117 | Please refer to :ref:`Bundled views ` section of this documentation. 118 | 119 | After that, for example, your E-mail client (if it supports `List-Unsubscribe` header) will happily introduce you 120 | some button to unsubscribe from messages of that type. 121 | -------------------------------------------------------------------------------- /docs/source/rst_guide.rst: -------------------------------------------------------------------------------- 1 | RST Quick guide 2 | =============== 3 | 4 | Online reStructuredText editor - http://rst.ninjs.org/ 5 | 6 | 7 | Main heading 8 | ============ 9 | 10 | 11 | Secondary heading 12 | ----------------- 13 | 14 | 15 | 16 | Typography 17 | ---------- 18 | 19 | **Bold** 20 | 21 | `Italic` 22 | 23 | ``Accent`` 24 | 25 | 26 | 27 | Blocks 28 | ------ 29 | 30 | Double colon to consider the following paragraphs preformatted:: 31 | 32 | This text is preformated. Can be used for code samples. 33 | 34 | 35 | .. code-block:: python 36 | 37 | # code-block accepts language name to highlight code 38 | # E.g.: python, html 39 | import this 40 | 41 | 42 | .. note:: 43 | 44 | This text will be rendered as a note block (usually green). 45 | 46 | 47 | .. warning:: 48 | 49 | This text will be rendered as a warning block (usually red). 50 | 51 | 52 | 53 | Lists 54 | ----- 55 | 56 | 1. Ordered item 1. 57 | 58 | Indent paragraph to make in belong to the above list item. 59 | 60 | 2. Ordered item 2. 61 | 62 | 63 | + Unordered item 1. 64 | + Unordered item . 65 | 66 | 67 | 68 | Links 69 | ----- 70 | 71 | :ref:`Documentation inner link label ` 72 | 73 | .. _some-marker: 74 | 75 | 76 | `Outer link label `_ 77 | 78 | Inline URLs are converted to links automatically: http://github.com/idlesign/makeapp/ 79 | 80 | 81 | 82 | Automation 83 | ---------- 84 | 85 | http://sphinx-doc.org/ext/autodoc.html 86 | 87 | .. automodule:: my_module 88 | :members: 89 | 90 | .. autoclass:: my_module.MyClass 91 | :members: do_this, do_that 92 | :inherited-members: 93 | -------------------------------------------------------------------------------- /docs/source/toolbox.rst: -------------------------------------------------------------------------------- 1 | Toolbox 2 | ======= 3 | 4 | `sitemessage` toolbox exposes some commonly used functions. 5 | 6 | 7 | Defining message recipients 8 | --------------------------- 9 | 10 | **sitemessage.toolbox.recipients** allows to define message recipients for various messengers, 11 | so that they could be passed into message scheduling functions: 12 | 13 | .. code-block:: python 14 | 15 | from sitemessage.toolbox import recipients 16 | from sitemessage.messengers.smtp import SMTPMessenger 17 | from sitemessage.messengers.xmpp import XMPPSleekMessenger 18 | 19 | 20 | # The first argument could be Messenger alias: 21 | my_smtp_recipients = recipients('smtp', ['user1@host.com', 'user2@host.com']), 22 | 23 | # or a Messenger class itself: 24 | my_jabber_recipients = recipients(XMPPSleekMessenger, ['user1@jabber.host.com', 'user2@jabber.host.com']), 25 | 26 | # Second arguments accepts either Django User model instance or an actual address: 27 | user1_model = ... 28 | my_smtp_recipients = recipients(SMTPMessenger, [user1_model, 'user2@host.com']) 29 | 30 | # You can also merge recipients from several messengers: 31 | my_recipients = my_smtp_recipients + my_jabber_recipients 32 | 33 | 34 | 35 | Scheduling messages 36 | ------------------- 37 | 38 | **sitemessage.toolbox.schedule_messages** is a generic tool to schedule messages: 39 | 40 | 41 | .. code-block:: python 42 | 43 | from sitemessage.toolbox import schedule_messages, recipients 44 | # Let's import a built-in message type class we'll use. 45 | from sitemessage.messages import EmailHtmlMessage 46 | 47 | 48 | schedule_messages( 49 | # You can pass one or several message objects: 50 | [ 51 | # The first param of this Message Type is `subject`. The second may be either an html itself: 52 | EmailHtmlMessage('Message subject 1', 'Some text'), 53 | 54 | # or a dictionary 55 | EmailHtmlMessage('Message subject 2', {'title': 'My message', 'entry': 'Some text.'}), 56 | 57 | # NOTE: Different Message Types may expect different arguments. 58 | ], 59 | 60 | # The same applies to recipients: add one or many as required: 61 | recipients('smtp', ['user1@host.com', 'user2@host.com']), 62 | 63 | # It's useful sometimes to know message sender in terms of Django users: 64 | sender=request.user 65 | ) 66 | 67 | 68 | 69 | Sending test messages 70 | --------------------- 71 | 72 | When your messengers are configured you can try and send a test message using **sitemessage_probe** 73 | management command:: 74 | 75 | ./manage.py sitemessage_probe smtp --to someone@example.com 76 | 77 | 78 | Or you can use **sitemessage.toolbox.send_test_message** function: 79 | 80 | .. code-block:: python 81 | 82 | from sitemessage.toolbox import send_test_message 83 | 84 | send_test_message('smtp', to='someone@example.com') 85 | 86 | 87 | Sending messages 88 | ---------------- 89 | 90 | Scheduled messages are normally sent with the help of **sitemessage_send_scheduled** management command, that 91 | could be issued from wherever you like (cron, Celery, etc.):: 92 | 93 | ./manage.py sitemessage_send_scheduled 94 | 95 | 96 | Nevertheless you can directly use **sitemessage.toolbox.send_scheduled_messages** from sitemessage toolbox: 97 | 98 | .. code-block:: python 99 | 100 | from sitemessage.toolbox import send_scheduled_messages 101 | 102 | 103 | # Note that this might eventually raise UnknownMessengerError, UnknownMessageTypeError exceptions. 104 | send_scheduled_messages() 105 | 106 | # Or if you do not want sitemessage exceptions to be raised (that way scheduled messages 107 | # with unknown message types or for which messengers are not configured won't be sent): 108 | send_scheduled_messages(ignore_unknown_messengers=True, ignore_unknown_message_types=True) 109 | 110 | # To send only messages of a certain priority use `priority` argument. 111 | send_scheduled_messages(priority=10) 112 | 113 | 114 | Cleanup sent messages and dispatches 115 | ------------------------------------ 116 | 117 | You can delete sent dispatches and message from DB using **sitemessage_cleanup**:: 118 | 119 | ./manage.py sitemessage_cleanup --ago 5 120 | 121 | 122 | Or you can use **sitemessage.toolbox.cleanup_sent_messages** from sitemessage toolbox: 123 | 124 | .. code-block:: python 125 | 126 | from sitemessage.toolbox import cleanup_sent_messages 127 | 128 | # Remove all dispatches (but not messages) 5 days old. 129 | cleanup_sent_messages(ago=5, dispatches_only=True) 130 | 131 | # Delete all sent messages and dispatches. 132 | cleanup_sent_messages() 133 | 134 | 135 | Use sitemessage to send Django-generated e-mails 136 | ------------------------------------------------ 137 | 138 | In `settings.py` of your project set `EMAIL_BACKEND` to a backend shipped with **sitemessage**. 139 | 140 | .. code-block:: python 141 | 142 | EMAIL_BACKEND = 'sitemessage.backends.EmailBackend' 143 | 144 | 145 | After that Django's `send_mail()` function will schedule e-mails using **sitemessage** machinery. 146 | -------------------------------------------------------------------------------- /docs/source/views.rst: -------------------------------------------------------------------------------- 1 | Bundled views 2 | ============= 3 | 4 | .. _bundled_views: 5 | 6 | **sitemessage** bundles some views, and one of those allows users to unsubscribe from certain message types, 7 | or mark messages read just by visiting pages linked to those views. So let's configure your project to use those views: 8 | 9 | 10 | .. code-block:: python 11 | 12 | from sitemessage.toolbox import get_sitemessage_urls 13 | 14 | ... 15 | 16 | # Somewhere in your urls.py. 17 | 18 | urlpatterns += get_sitemessage_urls() # Attaching sitemessage URLs. 19 | 20 | 21 | 22 | Unsubscribe 23 | ----------- 24 | 25 | Read :ref:`Handling unsubscriptions ` to get some information on how unsubscription works. 26 | 27 | 28 | Mark read 29 | --------- 30 | 31 | When bundled views are attached to your app you can mark messages as read. 32 | 33 | For example if you put the following code in your HTML e-mail message template, the message dispatch in your DB 34 | will be marked read as soon as Mail Client will render `` tag. 35 | 36 | .. code-block:: django+html 37 | 38 | {% if directive_mark_read %} 39 | 40 | {% endif %} 41 | 42 | 43 | This allows to track whether a user has read a message. 44 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --pyargs 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | release = clean --all sdist bdist_wheel upload 3 | test = pytest 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from setuptools import setup 5 | 6 | from sitemessage import VERSION 7 | 8 | PATH_BASE = os.path.dirname(__file__) 9 | PYTEST_RUNNER = ['pytest-runner'] if 'test' in sys.argv else [] 10 | 11 | f = open(os.path.join(PATH_BASE, 'README.rst')) 12 | README = f.read() 13 | f.close() 14 | 15 | 16 | setup( 17 | name='django-sitemessage', 18 | version='.'.join(map(str, VERSION)), 19 | url='https://github.com/idlesign/django-sitemessage', 20 | 21 | description='Reusable application for Django introducing a message delivery framework', 22 | long_description=README, 23 | license='BSD 3-Clause License', 24 | 25 | author='Igor `idle sign` Starikov', 26 | author_email='idlesign@yandex.ru', 27 | 28 | packages=['sitemessage'], 29 | include_package_data=True, 30 | zip_safe=False, 31 | 32 | install_requires=[ 33 | 'django-etc >= 1.2.0', 34 | ], 35 | setup_requires=[] + PYTEST_RUNNER, 36 | tests_require=[ 37 | 'pytest', 38 | 'pytest-djangoapp>=1.1.0', 39 | ], 40 | 41 | classifiers=[ 42 | # As in https://pypi.python.org/pypi?:action=list_classifiers 43 | 'Development Status :: 5 - Production/Stable', 44 | 'Environment :: Web Environment', 45 | 'Framework :: Django', 46 | 'Intended Audience :: Developers', 47 | 'Operating System :: OS Independent', 48 | 'Programming Language :: Python', 49 | 'Programming Language :: Python :: 3', 50 | 'Programming Language :: Python :: 3.7', 51 | 'Programming Language :: Python :: 3.8', 52 | 'Programming Language :: Python :: 3.9', 53 | 'Programming Language :: Python :: 3.10', 54 | 'Programming Language :: Python :: 3.11', 55 | 'License :: OSI Approved :: BSD License' 56 | ], 57 | ) 58 | 59 | -------------------------------------------------------------------------------- /sitemessage/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (1, 4, 0) 2 | 3 | 4 | default_app_config = 'sitemessage.apps.SitemessageConfig' -------------------------------------------------------------------------------- /sitemessage/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from .models import Message, Dispatch, DispatchError, Subscription 5 | 6 | 7 | class DispatchInlineAdmin(admin.TabularInline): 8 | 9 | model = Dispatch 10 | extra = 0 11 | raw_id_fields = ('recipient',) 12 | readonly_fields = ('retry_count',) 13 | 14 | 15 | class DispatchErrorInlineAdmin(admin.TabularInline): 16 | 17 | model = DispatchError 18 | extra = 0 19 | readonly_fields = ('time_created', 'dispatch', 'error_log') 20 | 21 | 22 | class MessageAdmin(admin.ModelAdmin): 23 | 24 | list_display = ('time_created', 'cls', 'dispatches_ready') 25 | list_filter = ('cls', 'dispatches_ready') 26 | raw_id_fields = ('sender',) 27 | ordering = ('-time_created',) 28 | 29 | inlines = (DispatchInlineAdmin,) 30 | 31 | 32 | class DispatchAdmin(admin.ModelAdmin): 33 | 34 | list_display = ('time_created', 'dispatch_status', 'address', 'time_dispatched', 'messenger', 'retry_count') 35 | list_filter = ('dispatch_status', 'messenger') 36 | ordering = ('-time_created',) 37 | raw_id_fields = ('recipient', 'message') 38 | readonly_fields = ('retry_count',) 39 | 40 | actions = [ 41 | 'schedule_failed', 42 | ] 43 | 44 | inlines = (DispatchErrorInlineAdmin,) 45 | 46 | def schedule_failed(self, request, queryset): 47 | queryset.update(dispatch_status=Dispatch.DISPATCH_STATUS_PENDING) 48 | 49 | schedule_failed.short_description = _('Make selected dispatches pending') 50 | 51 | 52 | class DispatchErrorAdmin(admin.ModelAdmin): 53 | 54 | list_display = ('time_created', 'dispatch') 55 | ordering = ('-time_created',) 56 | readonly_fields = ('time_created', 'dispatch', 'error_log') 57 | 58 | 59 | class SubscriptionAdmin(admin.ModelAdmin): 60 | 61 | list_display = ('time_created', 'message_cls', 'messenger_cls') 62 | ordering = ('-time_created',) 63 | readonly_fields = ('time_created',) 64 | raw_id_fields = ('recipient',) 65 | list_filter = ('message_cls', 'messenger_cls') 66 | 67 | 68 | admin.site.register(Message, MessageAdmin) 69 | admin.site.register(Dispatch, DispatchAdmin) 70 | admin.site.register(DispatchError, DispatchErrorAdmin) 71 | admin.site.register(Subscription, SubscriptionAdmin) 72 | -------------------------------------------------------------------------------- /sitemessage/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class SitemessageConfig(AppConfig): 6 | """Sitemessage configuration.""" 7 | 8 | name = 'sitemessage' 9 | verbose_name = _('Messaging') 10 | 11 | def ready(self): 12 | from sitemessage.utils import import_project_sitemessage_modules 13 | import_project_sitemessage_modules() 14 | 15 | from sitemessage.settings import INIT_BUILTIN_MESSAGE_TYPES 16 | if INIT_BUILTIN_MESSAGE_TYPES: 17 | from sitemessage.messages import register_builtin_message_types 18 | register_builtin_message_types() 19 | -------------------------------------------------------------------------------- /sitemessage/backends.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from django.core.mail import EmailMessage 4 | from django.core.mail.backends.base import BaseEmailBackend 5 | 6 | from .settings import EMAIL_BACKEND_MESSAGES_PRIORITY 7 | from .shortcuts import schedule_email 8 | 9 | 10 | class EmailBackend(BaseEmailBackend): 11 | """Email backend for Django built-in mailing functions scheduling messages.""" 12 | 13 | def send_messages(self, email_messages: List[EmailMessage]): 14 | 15 | if not email_messages: 16 | return 17 | 18 | sent = 0 19 | for message in email_messages: 20 | 21 | if not message.recipients(): 22 | continue 23 | 24 | schedule_email( 25 | {'contents': message.body}, 26 | message.recipients(), 27 | subject=message.subject, 28 | priority=EMAIL_BACKEND_MESSAGES_PRIORITY, 29 | ) 30 | 31 | sent += 1 32 | 33 | return sent 34 | -------------------------------------------------------------------------------- /sitemessage/exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | class SiteMessageError(Exception): 3 | """Base class for sitemessage errors.""" 4 | 5 | 6 | class SiteMessageConfigurationError(SiteMessageError): 7 | """This error is raised on configuration errors.""" 8 | 9 | 10 | class UnknownMessageTypeError(SiteMessageError): 11 | """This error is raised when there's a try to access an unknown message type.""" 12 | 13 | 14 | class MessengerException(SiteMessageError): 15 | """Base messenger exception.""" 16 | 17 | 18 | class UnknownMessengerError(MessengerException): 19 | """This error is raised when there's a try to access an unknown messenger.""" 20 | 21 | 22 | class MessengerWarmupException(MessengerException): 23 | """This exception represents a delivery error due to a messenger warm up process failure.""" 24 | -------------------------------------------------------------------------------- /sitemessage/locale/en/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2020-01-21 19:08+0700\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: admin.py:48 21 | msgid "Make selected dispatches pending" 22 | msgstr "" 23 | 24 | #: config.py:9 25 | msgid "Messaging" 26 | msgstr "" 27 | 28 | #: messages/base.py:34 29 | msgid "Notification" 30 | msgstr "" 31 | 32 | #: messages/email.py:9 33 | msgid "Email notification" 34 | msgstr "" 35 | 36 | #: messages/plain.py:13 37 | msgid "Text notification" 38 | msgstr "" 39 | 40 | #: messengers/facebook.py:31 41 | msgid "Facebook" 42 | msgstr "" 43 | 44 | #: messengers/smtp.py:14 45 | msgid "E-mail" 46 | msgstr "" 47 | 48 | #: messengers/smtp.py:97 49 | msgid "No Subject" 50 | msgstr "" 51 | 52 | #: messengers/telegram.py:15 53 | msgid "Telegram" 54 | msgstr "" 55 | 56 | #: messengers/twitter.py:15 57 | msgid "Tweet" 58 | msgstr "" 59 | 60 | #: messengers/vkontakte.py:31 61 | msgid "VKontakte" 62 | msgstr "" 63 | 64 | #: messengers/xmpp.py:16 65 | msgid "XMPP" 66 | msgstr "" 67 | 68 | #: models.py:64 69 | #, python-format 70 | msgid "Value `%r` is not a valid context." 71 | msgstr "" 72 | 73 | #: models.py:90 models.py:200 models.py:381 models.py:395 74 | msgid "Time created" 75 | msgstr "" 76 | 77 | #: models.py:93 78 | msgid "Sender" 79 | msgstr "" 80 | 81 | #: models.py:96 models.py:398 82 | msgid "Message class" 83 | msgstr "" 84 | 85 | #: models.py:97 models.py:398 86 | msgid "Message logic class identifier." 87 | msgstr "" 88 | 89 | #: models.py:99 90 | msgid "Message context" 91 | msgstr "" 92 | 93 | #: models.py:102 94 | msgid "Priority" 95 | msgstr "" 96 | 97 | #: models.py:103 98 | msgid "" 99 | "Number describing message sending priority. Messages with different " 100 | "priorities can be sent with different periodicity." 101 | msgstr "" 102 | 103 | #: models.py:107 104 | msgid "Dispatches ready" 105 | msgstr "" 106 | 107 | #: models.py:108 108 | msgid "" 109 | "Indicates whether dispatches for this message are already formed and ready " 110 | "to delivery." 111 | msgstr "" 112 | 113 | #: models.py:111 models.py:205 114 | msgid "Message" 115 | msgstr "" 116 | 117 | #: models.py:112 118 | msgid "Messages" 119 | msgstr "" 120 | 121 | #: models.py:182 122 | msgid "Pending" 123 | msgstr "" 124 | 125 | #: models.py:183 126 | msgid "Processing" 127 | msgstr "" 128 | 129 | #: models.py:184 130 | msgid "Sent" 131 | msgstr "" 132 | 133 | #: models.py:185 134 | msgid "Error" 135 | msgstr "" 136 | 137 | #: models.py:186 138 | msgid "Failed" 139 | msgstr "" 140 | 141 | #: models.py:193 142 | msgid "Unread" 143 | msgstr "" 144 | 145 | #: models.py:194 146 | msgid "Read" 147 | msgstr "" 148 | 149 | #: models.py:203 150 | msgid "Time dispatched" 151 | msgstr "" 152 | 153 | #: models.py:203 154 | msgid "Time of the last delivery attempt." 155 | msgstr "" 156 | 157 | #: models.py:208 models.py:401 158 | msgid "Messenger" 159 | msgstr "" 160 | 161 | #: models.py:208 models.py:401 162 | msgid "Messenger class identifier." 163 | msgstr "" 164 | 165 | #: models.py:211 models.py:404 166 | msgid "Recipient" 167 | msgstr "" 168 | 169 | #: models.py:213 models.py:406 170 | msgid "Address" 171 | msgstr "" 172 | 173 | #: models.py:213 models.py:406 174 | msgid "Recipient address." 175 | msgstr "" 176 | 177 | #: models.py:216 178 | msgid "Retry count" 179 | msgstr "" 180 | 181 | #: models.py:216 182 | msgid "A number of delivery retries has already been made." 183 | msgstr "" 184 | 185 | #: models.py:218 186 | msgid "Message cache" 187 | msgstr "" 188 | 189 | #: models.py:221 190 | msgid "Dispatch status" 191 | msgstr "" 192 | 193 | #: models.py:223 194 | msgid "Read status" 195 | msgstr "" 196 | 197 | #: models.py:226 models.py:382 198 | msgid "Dispatch" 199 | msgstr "" 200 | 201 | #: models.py:227 202 | msgid "Dispatches" 203 | msgstr "" 204 | 205 | #: models.py:383 206 | msgid "Text" 207 | msgstr "" 208 | 209 | #: models.py:386 210 | msgid "Dispatch error" 211 | msgstr "" 212 | 213 | #: models.py:387 214 | msgid "Dispatch errors" 215 | msgstr "" 216 | 217 | #: models.py:409 218 | msgid "Subscription" 219 | msgstr "" 220 | 221 | #: models.py:410 222 | msgid "Subscriptions" 223 | msgstr "" 224 | 225 | #: templates/sitemessage/messages/_base.html:21 226 | msgid "You can unsubscribe from this list using" 227 | msgstr "" 228 | 229 | #: templates/sitemessage/messages/_base.html:21 230 | msgid "this link" 231 | msgstr "" 232 | 233 | #: toolbox.py:116 234 | #, python-format 235 | msgid "You have %(count)s undelivered dispatch(es) at %(url)s" 236 | msgstr "" 237 | 238 | #: toolbox.py:120 239 | msgid "[SITEMESSAGE] Undelivered dispatches" 240 | msgstr "" 241 | -------------------------------------------------------------------------------- /sitemessage/locale/ru/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-sitemessage/28db11c674d3d3eb59396a9b1e88b8033ff88e20/sitemessage/locale/ru/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /sitemessage/locale/ru/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # 5 | # Translators: 6 | # Igor Starikov , 2014-2015 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: django-sitemessage\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2020-01-21 19:08+0700\n" 12 | "PO-Revision-Date: 2020-01-21 19:10+0700\n" 13 | "Last-Translator: Igor 'idle sign' Starikov \n" 14 | "Language-Team: Russian (http://www.transifex.com/projects/p/django-" 15 | "sitemessage/language/ru/)\n" 16 | "Language: ru\n" 17 | "MIME-Version: 1.0\n" 18 | "Content-Type: text/plain; charset=UTF-8\n" 19 | "Content-Transfer-Encoding: 8bit\n" 20 | "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" 21 | "%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" 22 | "X-Generator: Poedit 2.0.6\n" 23 | 24 | #: admin.py:48 25 | msgid "Make selected dispatches pending" 26 | msgstr "Сделать выбранные депеши ожидающими" 27 | 28 | #: config.py:9 29 | msgid "Messaging" 30 | msgstr "Корреспонденция" 31 | 32 | #: messages/base.py:34 33 | msgid "Notification" 34 | msgstr "Оповещение" 35 | 36 | #: messages/email.py:9 37 | msgid "Email notification" 38 | msgstr "Оповещение эл. письмом" 39 | 40 | #: messages/plain.py:13 41 | msgid "Text notification" 42 | msgstr "Текстовое оповещение" 43 | 44 | #: messengers/facebook.py:31 45 | msgid "Facebook" 46 | msgstr "" 47 | 48 | #: messengers/smtp.py:14 49 | msgid "E-mail" 50 | msgstr "Эл. почта" 51 | 52 | #: messengers/smtp.py:97 53 | msgid "No Subject" 54 | msgstr "Без темы" 55 | 56 | #: messengers/telegram.py:15 57 | msgid "Telegram" 58 | msgstr "" 59 | 60 | #: messengers/twitter.py:15 61 | msgid "Tweet" 62 | msgstr "Твит" 63 | 64 | #: messengers/vkontakte.py:31 65 | msgid "VKontakte" 66 | msgstr "ВКонтакте" 67 | 68 | #: messengers/xmpp.py:16 69 | msgid "XMPP" 70 | msgstr "XMPP" 71 | 72 | #: models.py:64 73 | #, python-format 74 | msgid "Value `%r` is not a valid context." 75 | msgstr "Значение `%r` не может быть использовано в качестве контекста." 76 | 77 | #: models.py:90 models.py:200 models.py:381 models.py:395 78 | msgid "Time created" 79 | msgstr "Время создания" 80 | 81 | #: models.py:93 82 | msgid "Sender" 83 | msgstr "Отправитель" 84 | 85 | #: models.py:96 models.py:398 86 | msgid "Message class" 87 | msgstr "Класс сообщения" 88 | 89 | #: models.py:97 models.py:398 90 | msgid "Message logic class identifier." 91 | msgstr "Идентификатор класса сообщения, описывающего логику работы с ним." 92 | 93 | #: models.py:99 94 | msgid "Message context" 95 | msgstr "Контекст сообщения" 96 | 97 | #: models.py:102 98 | msgid "Priority" 99 | msgstr "Приоритет" 100 | 101 | #: models.py:103 102 | msgid "" 103 | "Number describing message sending priority. Messages with different " 104 | "priorities can be sent with different periodicity." 105 | msgstr "" 106 | "Число, описывающее приоритет отправки. Сообщения с различным приоритетом " 107 | "могут отправляться с разной периодичностью." 108 | 109 | #: models.py:107 110 | msgid "Dispatches ready" 111 | msgstr "Депеши готовы" 112 | 113 | #: models.py:108 114 | msgid "" 115 | "Indicates whether dispatches for this message are already formed and ready " 116 | "to delivery." 117 | msgstr "" 118 | "Указывает на то, что для данного сообщения депеши сформированы и готовы к " 119 | "отправке." 120 | 121 | #: models.py:111 models.py:205 122 | msgid "Message" 123 | msgstr "Сообщение" 124 | 125 | #: models.py:112 126 | msgid "Messages" 127 | msgstr "Сообщения" 128 | 129 | #: models.py:182 130 | msgid "Pending" 131 | msgstr "Ожидает" 132 | 133 | #: models.py:183 134 | msgid "Processing" 135 | msgstr "В обработке" 136 | 137 | #: models.py:184 138 | msgid "Sent" 139 | msgstr "Отправлена" 140 | 141 | #: models.py:185 142 | msgid "Error" 143 | msgstr "Ошибка" 144 | 145 | #: models.py:186 146 | msgid "Failed" 147 | msgstr "Провал" 148 | 149 | #: models.py:193 150 | msgid "Unread" 151 | msgstr "Не прочитано" 152 | 153 | #: models.py:194 154 | msgid "Read" 155 | msgstr "Прочитано" 156 | 157 | #: models.py:203 158 | msgid "Time dispatched" 159 | msgstr "Время отправки" 160 | 161 | #: models.py:203 162 | msgid "Time of the last delivery attempt." 163 | msgstr "Время последней попытки доставки." 164 | 165 | #: models.py:208 models.py:401 166 | msgid "Messenger" 167 | msgstr "Средство доставки" 168 | 169 | #: models.py:208 models.py:401 170 | msgid "Messenger class identifier." 171 | msgstr "Идентификатор класса средства доставки." 172 | 173 | #: models.py:211 models.py:404 174 | msgid "Recipient" 175 | msgstr "Получатель" 176 | 177 | #: models.py:213 models.py:406 178 | msgid "Address" 179 | msgstr "Адрес" 180 | 181 | #: models.py:213 models.py:406 182 | msgid "Recipient address." 183 | msgstr "Адрес получателя." 184 | 185 | #: models.py:216 186 | msgid "Retry count" 187 | msgstr "Счетчик попыток" 188 | 189 | #: models.py:216 190 | msgid "A number of delivery retries has already been made." 191 | msgstr "Число предпринятых попыток доставки." 192 | 193 | #: models.py:218 194 | msgid "Message cache" 195 | msgstr "Кеш сообщения" 196 | 197 | #: models.py:221 198 | msgid "Dispatch status" 199 | msgstr "Статус депеши" 200 | 201 | #: models.py:223 202 | msgid "Read status" 203 | msgstr "Статус прочтения" 204 | 205 | #: models.py:226 models.py:382 206 | msgid "Dispatch" 207 | msgstr "Депеша" 208 | 209 | #: models.py:227 210 | msgid "Dispatches" 211 | msgstr "Депеши" 212 | 213 | #: models.py:383 214 | msgid "Text" 215 | msgstr "Текст" 216 | 217 | #: models.py:386 218 | msgid "Dispatch error" 219 | msgstr "Ошибка доставки" 220 | 221 | #: models.py:387 222 | msgid "Dispatch errors" 223 | msgstr "Ошибки доставки" 224 | 225 | #: models.py:409 226 | msgid "Subscription" 227 | msgstr "Подписка" 228 | 229 | #: models.py:410 230 | msgid "Subscriptions" 231 | msgstr "Подписки" 232 | 233 | #: templates/sitemessage/messages/_base.html:21 234 | msgid "You can unsubscribe from this list using" 235 | msgstr "Вы можете отписаться от данной рассылки, воспользовавшись" 236 | 237 | #: templates/sitemessage/messages/_base.html:21 238 | msgid "this link" 239 | msgstr "этой ссылкой" 240 | 241 | #: toolbox.py:116 242 | #, python-format 243 | msgid "You have %(count)s undelivered dispatch(es) at %(url)s" 244 | msgstr "Есть недоставленные депеши для %(url)s: %(count)s шт." 245 | 246 | #: toolbox.py:120 247 | msgid "[SITEMESSAGE] Undelivered dispatches" 248 | msgstr "[SITEMESSAGE] Недоставленные депеши" 249 | -------------------------------------------------------------------------------- /sitemessage/management/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /sitemessage/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /sitemessage/management/commands/sitemessage_check_undelivered.py: -------------------------------------------------------------------------------- 1 | from traceback import format_exc 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from ...toolbox import check_undelivered 6 | 7 | 8 | class Command(BaseCommand): 9 | 10 | help = 'Sends a notification email if any undelivered dispatches.' 11 | 12 | def add_arguments(self, parser): 13 | parser.add_argument( 14 | '--to', action='store', dest='to', default=None, 15 | help='Recipient e-mail. If not set Django ADMINS setting is used.') 16 | 17 | def handle(self, *args, **options): 18 | 19 | to = options.get('to', None) 20 | 21 | self.stdout.write('Checking for undelivered dispatches ...\n') 22 | 23 | try: 24 | undelivered_count = check_undelivered(to=to) 25 | 26 | self.stdout.write(f'Undelivered dispatches count: {undelivered_count}.\n') 27 | 28 | except Exception as e: 29 | self.stderr.write(self.style.ERROR(f'Error on check: {e}\n{format_exc()}')) 30 | 31 | else: 32 | self.stdout.write('Check done.\n') 33 | -------------------------------------------------------------------------------- /sitemessage/management/commands/sitemessage_cleanup.py: -------------------------------------------------------------------------------- 1 | from traceback import format_exc 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from ...toolbox import cleanup_sent_messages 6 | 7 | 8 | class Command(BaseCommand): 9 | 10 | help = 'Removes sent dispatches from DB.' 11 | 12 | def add_arguments(self, parser): 13 | parser.add_argument( 14 | '--ago', action='store', dest='ago', default=None, type=int, 15 | help='Allows cleanup messages sent X days ago. Defaults to None (cleanup all sent).') 16 | 17 | parser.add_argument( 18 | '--dispatches_only', action='store_false', dest='dispatches_only', default=False, 19 | help='Remove dispatches only (messages objects will stay intact).') 20 | 21 | def handle(self, *args, **options): 22 | 23 | ago = options.get('ago', None) 24 | dispatches_only = options.get('dispatches_only', False) 25 | 26 | suffix = [] 27 | 28 | if not dispatches_only: 29 | suffix.append('and messages') 30 | 31 | if ago: 32 | suffix.append(f'sent {ago} days ago') 33 | 34 | self.stdout.write(f"Cleaning up dispatches {' '.join(suffix)} ...\n") 35 | 36 | try: 37 | cleanup_sent_messages(ago=ago, dispatches_only=dispatches_only) 38 | 39 | except Exception as e: 40 | self.stderr.write(self.style.ERROR(f'Error on cleanup: {e}\n{format_exc()}')) 41 | 42 | else: 43 | self.stdout.write('Cleanup done.\n') 44 | -------------------------------------------------------------------------------- /sitemessage/management/commands/sitemessage_probe.py: -------------------------------------------------------------------------------- 1 | from traceback import format_exc 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from ...toolbox import send_test_message 6 | 7 | 8 | class Command(BaseCommand): 9 | 10 | help = 'Removes sent dispatches from DB.' 11 | 12 | args = '[messenger]' 13 | 14 | def add_arguments(self, parser): 15 | parser.add_argument('messenger', metavar='messenger', help='Messenger to test.') 16 | parser.add_argument( 17 | '--to', action='store', dest='to', default=None, 18 | help='Recipient address (if supported by messenger).') 19 | 20 | def handle(self, messenger, *args, **options): 21 | 22 | to = options.get('to', None) 23 | 24 | self.stdout.write(f'Sending test message using {messenger} ...\n') 25 | 26 | try: 27 | result = send_test_message(messenger, to=to) 28 | self.stdout.write(f'Probing function result: {result}.\n') 29 | 30 | except Exception as e: 31 | self.stderr.write(self.style.ERROR(f'Error on probe: {e}\n{format_exc()}')) 32 | 33 | else: 34 | self.stdout.write('Probing done.\n') 35 | -------------------------------------------------------------------------------- /sitemessage/management/commands/sitemessage_send_scheduled.py: -------------------------------------------------------------------------------- 1 | from traceback import format_exc 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from ...toolbox import send_scheduled_messages 6 | 7 | 8 | class Command(BaseCommand): 9 | 10 | help = 'Sends scheduled messages (both in pending and error statuses).' 11 | 12 | def add_arguments(self, parser): 13 | parser.add_argument( 14 | '--priority', action='store', dest='priority', default=None, 15 | help='Allows to filter scheduled messages by a priority number. Defaults to None.') 16 | 17 | def handle(self, *args, **options): 18 | priority = options.get('priority', None) 19 | priority_str = '' 20 | 21 | if priority is not None: 22 | priority_str = f'with priority {priority} ' 23 | 24 | self.stdout.write(f'Sending scheduled messages {priority_str} ...\n') 25 | 26 | try: 27 | send_scheduled_messages(priority=priority) 28 | 29 | except Exception as e: 30 | self.stderr.write(self.style.ERROR(f'Error on send: {e}\n{format_exc()}')) 31 | 32 | else: 33 | self.stdout.write('Sending done.\n') 34 | -------------------------------------------------------------------------------- /sitemessage/messages/__init__.py: -------------------------------------------------------------------------------- 1 | from ..utils import register_message_types 2 | 3 | 4 | def register_builtin_message_types(): 5 | """Registers the built-in message types.""" 6 | from .plain import PlainTextMessage 7 | from .email import EmailTextMessage, EmailHtmlMessage 8 | register_message_types(PlainTextMessage, EmailTextMessage, EmailHtmlMessage) 9 | -------------------------------------------------------------------------------- /sitemessage/messages/email.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from django.utils.translation import gettext as _ 4 | 5 | from .base import MessageBase 6 | 7 | 8 | class _EmailMessageBase(MessageBase): 9 | 10 | supported_messengers = ['smtp'] 11 | title = _('Email notification') 12 | 13 | def __init__(self, subject: str, text_or_dict: Union[str, dict], type_name: str, template_path: str = None): 14 | context = { 15 | 'subject': subject, 16 | 'type': type_name, 17 | } 18 | self.update_context(context, text_or_dict) 19 | super().__init__(context, template_path=template_path) 20 | 21 | 22 | class EmailTextMessage(_EmailMessageBase): 23 | """Simple plain text message to send as an e-mail.""" 24 | 25 | alias = 'email_plain' 26 | template_ext = 'txt' 27 | 28 | def __init__(self, subject: str, text_or_dict: Union[dict, str], template_path: str = None): 29 | super().__init__(subject, text_or_dict, 'plain', template_path=template_path) 30 | 31 | 32 | class EmailHtmlMessage(_EmailMessageBase): 33 | """HTML message to send as an e-mail.""" 34 | 35 | alias = 'email_html' 36 | template_ext = 'html' 37 | 38 | def __init__(self, subject: str, html_or_dict: Union[str, dict], template_path: str = None): 39 | super().__init__(subject, html_or_dict, 'html', template_path=template_path) 40 | -------------------------------------------------------------------------------- /sitemessage/messages/plain.py: -------------------------------------------------------------------------------- 1 | from .base import MessageBase 2 | 3 | from django.utils.translation import gettext as _ 4 | 5 | 6 | class PlainTextMessage(MessageBase): 7 | """Simple plain text message class to allow schedule_messages() 8 | to accept message as a simple string instead of a message object. 9 | 10 | """ 11 | 12 | alias = 'plain' 13 | title = _('Text notification') 14 | 15 | def __init__(self, text: str): 16 | super().__init__({self.SIMPLE_TEXT_ID: text}) 17 | -------------------------------------------------------------------------------- /sitemessage/messengers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-sitemessage/28db11c674d3d3eb59396a9b1e88b8033ff88e20/sitemessage/messengers/__init__.py -------------------------------------------------------------------------------- /sitemessage/messengers/base.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from functools import partial 3 | from itertools import chain 4 | from typing import List, Optional, Dict, Any, Type, Union, Callable 5 | 6 | from django.contrib.auth.base_user import AbstractBaseUser 7 | 8 | from ..exceptions import UnknownMessageTypeError, MessengerException 9 | from ..models import Dispatch, Message, MessageTuple 10 | from ..utils import Recipient, is_iterable, TypeRecipients 11 | 12 | if False: # pragma: nocover 13 | from ..messages.base import MessageBase # noqa 14 | 15 | TypeProxy = Optional[Union[Callable, dict]] 16 | 17 | 18 | class DispatchProcessingHandler: 19 | """Context manager to facilitate exception handling on various 20 | messages processing stages. 21 | 22 | """ 23 | def __init__( 24 | self, 25 | *, 26 | messenger: 'MessengerBase', 27 | messages: Optional[List[MessageTuple]] = None 28 | ): 29 | self.messenger = messenger 30 | 31 | self.dispatches = ( 32 | chain.from_iterable(( 33 | dispatch 34 | for dispatch in (item.dispatches for item in messages) 35 | )) 36 | if messages else None 37 | ) 38 | 39 | def __enter__(self): 40 | messenger = self.messenger 41 | 42 | with messenger._exception_handling(self.dispatches): 43 | messenger._init_delivery_statuses_dict() 44 | messenger.before_send() 45 | 46 | def __exit__(self, exc_type, exc_val, exc_tb): 47 | messenger = self.messenger 48 | 49 | with messenger._exception_handling(self.dispatches): 50 | messenger.after_send() 51 | 52 | messenger._update_dispatches() 53 | 54 | return True 55 | 56 | 57 | class MessengerBase: 58 | """Base class for messengers used by sitemessage. 59 | 60 | Custom messenger classes, implementing various message delivery 61 | mechanics, other messenger classes must inherit from this one. 62 | 63 | """ 64 | alias: str = None 65 | """Messenger alias to address it from different places, Should rather be quite unique %)""" 66 | 67 | title: str = None 68 | """Title to show to user.""" 69 | 70 | allow_user_subscription: bool = True 71 | """Makes subscription for this messenger messages available for users (see get_user_preferences_for_ui())""" 72 | 73 | address_attr: str = None 74 | """User object attribute containing address.""" 75 | 76 | # Dispatches by status dict will be here runtime. See init_delivery_statuses_dict(). 77 | _st = None 78 | 79 | @classmethod 80 | def get_alias(cls) -> str: 81 | """Returns messenger alias.""" 82 | 83 | if cls.alias is None: 84 | cls.alias = cls.__name__ 85 | 86 | return cls.alias 87 | 88 | def __str__(self) -> str: 89 | return self.__class__.get_alias() 90 | 91 | @contextmanager 92 | def before_after_send_handling(self, messages: Optional[List[MessageTuple]] = None): 93 | """Context manager that allows to execute send wrapped 94 | in before_send() and after_send(). 95 | 96 | """ 97 | with DispatchProcessingHandler(messenger=self, messages=messages): 98 | yield 99 | 100 | @contextmanager 101 | def _exception_handling(self, dispatches: Optional[List[Dispatch]]): 102 | """Propagates unhandled exceptions to dispatches log. 103 | 104 | :param dispatches: 105 | 106 | """ 107 | try: 108 | yield 109 | 110 | except Exception as e: 111 | for dispatch in dispatches or []: 112 | self.mark_error(dispatch, e) 113 | 114 | def send_test_message(self, to: str, text: str) -> Any: 115 | """Sends a test message using messengers settings. 116 | 117 | :param to: an address to send test message to 118 | :param text: text to send 119 | 120 | """ 121 | result = None # noqa 122 | 123 | with self.before_after_send_handling(): 124 | result = self._test_message(to, text) 125 | 126 | return result 127 | 128 | def _test_message(self, to: str, text: str) -> Any: 129 | """This method should be implemented by a heir to send a test message. 130 | 131 | :param to: an address to send test message to 132 | :param text: text to send 133 | 134 | """ 135 | raise NotImplementedError # pragma: nocover 136 | 137 | @classmethod 138 | def get_address(cls, recipient: Any) -> Any: 139 | """Returns recipient address. 140 | 141 | Heirs may override this to deduce address from `recipient` data 142 | (e.g. to get address from Django User model instance). 143 | 144 | :param recipient: any object passed to `recipients()` 145 | 146 | """ 147 | address = recipient 148 | 149 | address_attr = cls.address_attr 150 | 151 | if address_attr: 152 | address = getattr(recipient, address_attr, None) or address 153 | 154 | return address 155 | 156 | @classmethod 157 | def structure_recipients_data(cls, recipients: TypeRecipients) -> List[Recipient]: 158 | """Converts recipients data into a list of Recipient objects. 159 | 160 | :param recipients: list of objects 161 | 162 | """ 163 | if not is_iterable(recipients): 164 | recipients = (recipients,) 165 | 166 | objects = [] 167 | for recipient in recipients: 168 | user = None 169 | 170 | if isinstance(recipient, AbstractBaseUser): 171 | user = recipient 172 | 173 | address = cls.get_address(recipient) 174 | 175 | objects.append(Recipient(cls.get_alias(), user, address)) 176 | 177 | return objects 178 | 179 | def _init_delivery_statuses_dict(self): 180 | """Initializes a dict indexed by message delivery statuses.""" 181 | self._st = { 182 | 'pending': [], 183 | 'sent': [], 184 | 'error': [], 185 | 'failed': [] 186 | } 187 | 188 | def mark_pending(self, dispatch: Dispatch): 189 | """Marks a dispatch as pending. 190 | 191 | Should be used within send(). 192 | 193 | :param dispatch: a Dispatch 194 | 195 | """ 196 | self._st['pending'].append(dispatch) 197 | 198 | def mark_sent(self, dispatch: Dispatch): 199 | """Marks a dispatch as successfully sent. 200 | 201 | Should be used within send(). 202 | 203 | :param dispatch: a Dispatch 204 | 205 | """ 206 | self._st['sent'].append(dispatch) 207 | 208 | def mark_error( 209 | self, 210 | dispatch: Dispatch, 211 | error_log: Union[str, Exception], 212 | message_cls: Optional[Type['MessageBase']] = None 213 | ): 214 | """Marks a dispatch as having error or consequently as failed 215 | if send retry limit for that message type is exhausted. 216 | 217 | Should be used within send(). 218 | 219 | :param dispatch: a Dispatch 220 | :param error_log: error message or exception object 221 | :param message_cls: MessageBase heir 222 | 223 | """ 224 | if message_cls is None: 225 | message_cls = dispatch.message.get_type() 226 | 227 | if message_cls.send_retry_limit is not None and (dispatch.retry_count + 1) >= message_cls.send_retry_limit: 228 | self.mark_failed(dispatch, error_log) 229 | 230 | else: 231 | dispatch.error_log = error_log 232 | self._st['error'].append(dispatch) 233 | 234 | def mark_failed(self, dispatch: Dispatch, error_log: Union[str, Exception]): 235 | """Marks a dispatch as failed. 236 | 237 | Sitemessage won't try to deliver already failed messages. 238 | 239 | Should be used within send(). 240 | 241 | :param dispatch: a Dispatch 242 | :param error_log: str - error message 243 | 244 | """ 245 | dispatch.error_log = error_log 246 | self._st['failed'].append(dispatch) 247 | 248 | def before_send(self): 249 | """This one is called right before send procedure. 250 | Usually heir will implement some messenger warm up (connect) code. 251 | 252 | """ 253 | 254 | def after_send(self): 255 | """This one is called right after send procedure. 256 | Usually heir will implement some messenger cool down (disconnect) code. 257 | 258 | """ 259 | 260 | def process_messages(self, messages: Dict[int, MessageTuple], ignore_unknown_message_types: bool = False): 261 | """Performs message processing. 262 | 263 | :param messages: indexed by message id dict with messages data 264 | :param ignore_unknown_message_types: whether to silence exceptions 265 | 266 | :raises UnknownMessageTypeError: 267 | 268 | """ 269 | send = self.send 270 | exception_handling = self._exception_handling 271 | 272 | messages = list(messages.values()) 273 | 274 | with self.before_after_send_handling(messages=messages): 275 | 276 | for message, dispatches in messages: 277 | 278 | try: 279 | message_cls = message.get_type() 280 | compile_message = partial(message_cls.compile, message=message, messenger=self) 281 | 282 | except UnknownMessageTypeError: 283 | if ignore_unknown_message_types: 284 | continue 285 | raise 286 | 287 | message_type_cache = None 288 | 289 | for dispatch in dispatches: 290 | 291 | if dispatch.message_cache: 292 | continue 293 | 294 | # Create actual message text for further usage. 295 | with exception_handling(dispatches=[dispatch]): 296 | if message_type_cache is None and not message_cls.has_dynamic_context: 297 | # If a message class doesn't depend upon a dispatch data for message compilation, 298 | # we'd compile a message just once. 299 | message_type_cache = compile_message(dispatch=dispatch) 300 | 301 | dispatch.message_cache = message_type_cache or compile_message(dispatch=dispatch) 302 | 303 | with exception_handling(dispatches=dispatches): 304 | # Batch send to cover wider messenger scenarios. 305 | send(message_cls, message, dispatches) 306 | 307 | def _update_dispatches(self): 308 | """Updates dispatched data in DB according to information gather by `mark_*` methods,""" 309 | Dispatch.log_dispatches_errors(self._st['error'] + self._st['failed']) 310 | Dispatch.set_dispatches_statuses(**self._st) 311 | self._init_delivery_statuses_dict() 312 | 313 | def send(self, message_cls: Type['MessageBase'], message_model: Message, dispatch_models: List[Dispatch]): 314 | """Main send method must be implemented by all heirs. 315 | 316 | :param message_cls: a MessageBase heir 317 | :param message_model: message model 318 | :param dispatch_models: Dispatch models for this Message 319 | 320 | """ 321 | raise NotImplementedError # pragma: nocover 322 | 323 | 324 | class RequestsMessengerBase(MessengerBase): 325 | """Shared requests-based messenger base class. 326 | 327 | Uses `requests` module: https://pypi.python.org/pypi/requests 328 | 329 | """ 330 | timeout: int = 10 331 | """Request timeout.""" 332 | 333 | def __init__(self, proxy: TypeProxy = None, **kwargs): 334 | """Configures messenger. 335 | 336 | :param proxy: Dictionary of proxy settings, 337 | or a callable returning such a dictionary. 338 | 339 | """ 340 | import requests 341 | 342 | self.lib = requests 343 | self.proxy = proxy 344 | 345 | def _get_common_params(self) -> dict: 346 | """Returns common parameters for every request.""" 347 | 348 | proxy = self.proxy 349 | 350 | if proxy: 351 | 352 | if callable(proxy): 353 | proxy = proxy() 354 | 355 | params = { 356 | 'timeout': self.timeout, 357 | 'proxies': proxy or None 358 | } 359 | 360 | return params 361 | 362 | def get(self, url: str, json: bool = True) -> Union[dict, str]: 363 | """Performs POST and returns data. 364 | 365 | :param url: 366 | :param json: Expect json data and return it as dict. 367 | 368 | """ 369 | try: 370 | params = self._get_common_params() 371 | response = self.lib.get(url, **params) 372 | result = response.json() if json else response.text 373 | return result 374 | 375 | except self.lib.exceptions.RequestException as e: 376 | raise MessengerException(e) 377 | 378 | def post(self, url: str, data: dict) -> dict: 379 | """Performs POST and returns data. 380 | 381 | :param url: 382 | :param data: data to send. 383 | 384 | """ 385 | try: 386 | params = self._get_common_params() 387 | response = self.lib.post(url, data=data, **params) 388 | result = response.json() 389 | return result 390 | 391 | except self.lib.exceptions.RequestException as e: 392 | raise MessengerException(e) 393 | 394 | def _test_message(self, to: str, text: str): 395 | return self._send_message(self._build_message(text, to=to)) 396 | 397 | def _build_message(self, text: str, to: Optional[str] = None) -> str: 398 | """Builds a message before send. 399 | 400 | :param text: Contents to add to message. 401 | :param to: Recipient address. 402 | 403 | """ 404 | return text 405 | 406 | def _send_message(self, msg: str, to: Optional[str] = None): 407 | """Should implement actual sending. 408 | 409 | :param msg: Contents to add to message. 410 | :param to: Recipient address. 411 | 412 | """ 413 | raise NotImplementedError # pragma: nocover 414 | 415 | def send(self, message_cls: Type['MessageBase'], message_model: Message, dispatch_models: List[Dispatch]): 416 | for dispatch_model in dispatch_models: 417 | try: 418 | recipient = dispatch_model.address 419 | msg = self._build_message(dispatch_model.message_cache, to=recipient) 420 | self._send_message(msg, to=recipient) 421 | self.mark_sent(dispatch_model) 422 | 423 | except Exception as e: 424 | self.mark_error(dispatch_model, e, message_cls) 425 | -------------------------------------------------------------------------------- /sitemessage/messengers/facebook.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext as _ 2 | 3 | from .base import RequestsMessengerBase, TypeProxy 4 | from ..exceptions import MessengerException 5 | 6 | 7 | class FacebookMessengerException(MessengerException): 8 | """Exceptions raised by Facebook messenger.""" 9 | 10 | 11 | class FacebookMessenger(RequestsMessengerBase): 12 | """Implements Facebook page wall message publishing. 13 | 14 | Steps to be done: 15 | 16 | 1. Create FB application for your website at https://developers.facebook.com/apps/ 17 | 18 | 2. Create a page 19 | (possibly at https://developers.facebook.com/apps/{app_id}/settings/advanced/ under `App Page` 20 | - replace {app_id} with your application ID). 21 | 22 | 3. Go to Graph API Explorer - https://developers.facebook.com/tools/explorer/ 23 | 3.1. Pick your application from top right dropdown. 24 | 3.2. `Get User Token` using dropdown near Access Token field. Check `manage_pages` permission. 25 | 26 | 4. Get page access token from your user token and application credentials using .get_page_access_token(). 27 | 28 | """ 29 | 30 | alias = 'fb' 31 | title = _('Facebook') 32 | 33 | _graph_version = '2.6' 34 | 35 | _url_base = 'https://graph.facebook.com' 36 | _url_versioned = _url_base + '/v' + _graph_version 37 | _tpl_url_feed = _url_versioned + '/%(page_id)s/feed' 38 | 39 | def __init__(self, page_access_token: str, proxy: TypeProxy = None): 40 | """Configures messenger. 41 | 42 | :param page_access_token: Unique authentication token of your FB page. 43 | One could be generated from User token using .get_page_access_token(). 44 | 45 | """ 46 | super().__init__(proxy=proxy) 47 | self.access_token = page_access_token 48 | 49 | def get_page_access_token(self, app_id: str, app_secret: str, user_token: str) -> dict: 50 | """Returns a dictionary of never expired page token indexed by page names. 51 | 52 | :param app_id: Application ID 53 | :param app_secret: Application secret 54 | :param user_token: User short-lived token 55 | 56 | """ 57 | url_extend = ( 58 | self._url_base + '/oauth/access_token?grant_type=fb_exchange_token&' 59 | 'client_id=%(app_id)s&client_secret=%(app_secret)s&fb_exchange_token=%(user_token)s') 60 | 61 | response = self.get(url_extend % {'app_id': app_id, 'app_secret': app_secret, 'user_token': user_token}) 62 | user_token_long_lived = response.split('=')[-1] 63 | 64 | json = self.get(self._url_versioned + f'/me/accounts?access_token={user_token_long_lived}', json=True) 65 | 66 | tokens = {item['name']: item['access_token'] for item in json['data'] if item.get('access_token')} 67 | 68 | return tokens 69 | 70 | def _send_message(self, msg: str, to: str = None): 71 | 72 | # Automatically deduce message type. 73 | message_type = 'link' if msg.startswith('http') else 'message' 74 | 75 | json = self.post( 76 | url=self._tpl_url_feed % {'page_id': 'me'}, 77 | data={'access_token': self.access_token, message_type: msg}) 78 | 79 | if 'error' in json: 80 | error = json['error'] 81 | raise FacebookMessengerException(f"{error['code']}: {error['message']}") 82 | 83 | return json['id'] # Returns post ID. 84 | -------------------------------------------------------------------------------- /sitemessage/messengers/smtp.py: -------------------------------------------------------------------------------- 1 | from typing import Type, List 2 | 3 | from django.conf import settings 4 | from django.utils.html import strip_tags 5 | from django.utils.translation import gettext as _ 6 | 7 | from .base import MessengerBase, Message, Dispatch 8 | from ..exceptions import MessengerWarmupException 9 | 10 | if False: # pragma: nocover 11 | from ..messages.base import MessageBase # noqa 12 | 13 | 14 | class SMTPMessenger(MessengerBase): 15 | """Implements SMTP message delivery using Python builtin smtplib module.""" 16 | 17 | alias = 'smtp' 18 | smtp = None 19 | title = _('E-mail') 20 | 21 | address_attr = 'email' 22 | 23 | _session_started = False 24 | 25 | def __init__( 26 | self, 27 | from_email: str = None, 28 | login: str = None, 29 | password: str = None, 30 | host: str = None, 31 | port: str = None, 32 | use_tls: bool = None, 33 | use_ssl: bool = None, 34 | debug: bool = False, 35 | timeout: int = None 36 | ): 37 | """Configures messenger. 38 | 39 | :param from_email: e-mail address to send messages from 40 | :param login: login to log into SMTP server 41 | :param password: password to log into SMTP server 42 | :param host: string - SMTP server host 43 | :param port: SMTP server port 44 | :param use_tls: whether to use TLS 45 | :param use_ssl: whether to use SSL 46 | :param debug: whether to switch smtplib into debug mode. 47 | :param timeout: timeout to establish s connection. 48 | 49 | """ 50 | import smtplib 51 | 52 | from email.mime.text import MIMEText 53 | from email.mime.multipart import MIMEMultipart 54 | 55 | self.debug = debug 56 | self.lib = smtplib 57 | self.mime_text = MIMEText 58 | self.mime_multipart = MIMEMultipart 59 | 60 | self.from_email = from_email or getattr(settings, 'SERVER_EMAIL') 61 | self.login = login or getattr(settings, 'EMAIL_HOST_USER') 62 | self.password = password or getattr(settings, 'EMAIL_HOST_PASSWORD') 63 | self.host = host or getattr(settings, 'EMAIL_HOST') 64 | self.port = port or getattr(settings, 'EMAIL_PORT') 65 | self.use_tls = use_tls or getattr(settings, 'EMAIL_USE_TLS') 66 | self.use_ssl = use_ssl or getattr(settings, 'EMAIL_USE_SSL') 67 | self.timeout = timeout or getattr(settings, 'EMAIL_TIMEOUT') 68 | 69 | def _test_message(self, to: str, text: str): 70 | return self._send_message(self._build_message(to, text, mtype='html')) 71 | 72 | def before_send(self): 73 | lib = self.lib 74 | 75 | try: 76 | smtp_cls = lib.SMTP_SSL if self.use_ssl else lib.SMTP 77 | kwargs = {} 78 | timeout = self.timeout 79 | 80 | if timeout: 81 | kwargs['timeout'] = timeout 82 | 83 | smtp = smtp_cls(self.host, self.port, **kwargs) 84 | 85 | self.smtp = smtp 86 | 87 | smtp.set_debuglevel(self.debug) 88 | 89 | if self.use_tls: 90 | smtp.ehlo() 91 | if smtp.has_extn('STARTTLS'): 92 | smtp.starttls() 93 | smtp.ehlo() # This time over TLS. 94 | 95 | if self.login: 96 | smtp.login(self.login, self.password) 97 | 98 | self._session_started = True 99 | 100 | except lib.SMTPException as e: 101 | raise MessengerWarmupException(f'SMTP Error: {e}') 102 | 103 | def after_send(self): 104 | self.smtp.quit() 105 | 106 | def _build_message(self, to: str, text: str, subject: str = None, mtype: str = None, unsubscribe_url: str = None): 107 | """Constructs a MIME message from message and dispatch models.""" 108 | 109 | if subject is None: 110 | subject = '%s' % _('No Subject') 111 | 112 | if mtype == 'html': 113 | msg = self.mime_multipart() 114 | text_part = self.mime_multipart('alternative') 115 | text_part.attach(self.mime_text(strip_tags(text), _charset='utf-8')) 116 | text_part.attach(self.mime_text(text, 'html', _charset='utf-8')) 117 | msg.attach(text_part) 118 | 119 | else: 120 | msg = self.mime_text(text, _charset='utf-8') 121 | 122 | msg['From'] = self.from_email 123 | msg['To'] = to 124 | msg['Subject'] = subject 125 | 126 | if unsubscribe_url: 127 | msg['List-Unsubscribe'] = f'<{unsubscribe_url}>' 128 | 129 | return msg 130 | 131 | def _send_message(self, msg): 132 | return self.smtp.sendmail(msg['From'], msg['To'], msg.as_string()) 133 | 134 | def send(self, message_cls: Type['MessageBase'], message_model: Message, dispatch_models: List[Dispatch]): 135 | 136 | if not self._session_started: 137 | return 138 | 139 | for dispatch_model in dispatch_models: 140 | 141 | msg = self._build_message( 142 | dispatch_model.address, 143 | dispatch_model.message_cache, 144 | message_model.context.get('subject'), 145 | message_model.context.get('type'), 146 | message_cls.get_unsubscribe_directive(message_model, dispatch_model) 147 | ) 148 | 149 | try: 150 | refused = self._send_message(msg) 151 | 152 | if refused: 153 | self.mark_failed(dispatch_model, f"`{msg['To']}` address is rejected by server") 154 | continue 155 | 156 | self.mark_sent(dispatch_model) 157 | 158 | except Exception as e: 159 | self.mark_error(dispatch_model, e, message_cls) 160 | -------------------------------------------------------------------------------- /sitemessage/messengers/telegram.py: -------------------------------------------------------------------------------- 1 | from typing import Type, List 2 | 3 | from django.utils.translation import gettext as _ 4 | 5 | from .base import RequestsMessengerBase, TypeProxy, Message, Dispatch 6 | from ..exceptions import MessengerWarmupException, MessengerException 7 | 8 | if False: # pragma: nocover 9 | from ..messages.base import MessageBase # noqa 10 | 11 | 12 | class TelegramMessengerException(MessengerException): 13 | """Exceptions raised by Telegram messenger.""" 14 | 15 | 16 | class TelegramMessenger(RequestsMessengerBase): 17 | """Implements Telegram message delivery via Telegram Bot API.""" 18 | 19 | alias = 'telegram' 20 | title = _('Telegram') 21 | 22 | address_attr = 'telegram' 23 | 24 | _session_started = False 25 | _tpl_url = 'https://api.telegram.org/bot%(token)s/%(method)s' 26 | 27 | def __init__(self, auth_token: str, proxy: TypeProxy = None): 28 | """Configures messenger. 29 | 30 | Register a Telegram Bot using instructions from https://core.telegram.org/bots/api 31 | 32 | :param auth_token: Bot unique authentication token 33 | """ 34 | super().__init__(proxy=proxy) 35 | self.auth_token = auth_token 36 | 37 | def _verify_bot(self): 38 | """Sends an API command to test whether bot is authorized.""" 39 | self._send_command('getMe') 40 | 41 | def get_updates(self) -> list: 42 | """Returns new messages addressed to bot.""" 43 | with self.before_after_send_handling(): 44 | result = self._send_command('getUpdates') 45 | return result 46 | 47 | def get_chat_ids(self) -> list: 48 | """Returns unique chat IDs from `/start` command messages sent to our bot by users. 49 | Those chat IDs can be used to send messages to chats. 50 | 51 | """ 52 | updates = self.get_updates() 53 | chat_ids = [] 54 | if updates: 55 | for update in updates: 56 | message = update['message'] 57 | if message['text'] == '/start': 58 | chat_ids.append(message['chat']['id']) 59 | return list(set(chat_ids)) 60 | 61 | def before_send(self): 62 | if self._session_started: 63 | return 64 | 65 | try: 66 | self._verify_bot() 67 | self._session_started = True 68 | 69 | except TelegramMessengerException as e: 70 | raise MessengerWarmupException(f'Telegram Error: {e}') 71 | 72 | def _build_message(self, text: str, to: str = None) -> dict: 73 | return {'chat_id': to, 'text': text} 74 | 75 | def _send_command(self, method_name: str, data: dict = None): 76 | """Sends a command to API. 77 | 78 | :param method_name: 79 | :param data: 80 | 81 | """ 82 | json = self.post(url=self._tpl_url % {'token': self.auth_token, 'method': method_name}, data=data) 83 | 84 | if not json['ok']: 85 | raise TelegramMessengerException(json['description']) 86 | 87 | return json['result'] 88 | 89 | def _send_message(self, msg: dict, to=None): 90 | return self._send_command('sendMessage', msg) 91 | 92 | def send(self, message_cls: Type['MessageBase'], message_model: Message, dispatch_models: List[Dispatch]): 93 | if not self._session_started: 94 | return 95 | super().send(message_cls, message_model, dispatch_models) 96 | -------------------------------------------------------------------------------- /sitemessage/messengers/twitter.py: -------------------------------------------------------------------------------- 1 | from typing import Type, List 2 | 3 | from django.utils.translation import gettext as _ 4 | 5 | from .base import MessengerBase, Message, Dispatch 6 | from ..exceptions import MessengerWarmupException 7 | 8 | if False: # pragma: nocover 9 | from ..messages.base import MessageBase # noqa 10 | 11 | 12 | class TwitterMessenger(MessengerBase): 13 | """Implements to Twitter message delivery using `twitter` module. 14 | 15 | https://github.com/sixohsix/twitter 16 | 17 | """ 18 | 19 | alias = 'twitter' 20 | title = _('Tweet') 21 | 22 | address_attr = 'twitter' 23 | 24 | _session_started = False 25 | 26 | def __init__(self, api_key: str, api_secret: str, access_token: str, access_token_secret: str): 27 | """Configures messenger. 28 | 29 | Register Twitter application here - https://apps.twitter.com/ 30 | 31 | :param api_key: API key for Twitter client 32 | :param api_secret: API secret for Twitter client 33 | :param access_token: Access token for an account to tweet from 34 | :param access_token_secret: Access token secret for an account to tweet from 35 | """ 36 | import twitter 37 | 38 | self.lib = twitter 39 | self.api_key = api_key 40 | self.api_secret = api_secret 41 | self.access_token = access_token 42 | self.access_token_secret = access_token_secret 43 | 44 | def _test_message(self, to: str, text: str): 45 | return self._send_message(self._build_message(to, text)) 46 | 47 | def before_send(self): 48 | try: 49 | self.api = self.lib.Twitter( 50 | auth=self.lib.OAuth(self.access_token, self.access_token_secret, self.api_key, self.api_secret)) 51 | self._session_started = True 52 | 53 | except self.lib.api.TwitterError as e: 54 | raise MessengerWarmupException(f'Twitter Error: {e}') 55 | 56 | @classmethod 57 | def _build_message(cls, to: str, text: str): 58 | if to: 59 | if not to.startswith('@'): 60 | to = f'@{to}' 61 | 62 | to = f'{to} ' 63 | 64 | else: 65 | to = '' 66 | 67 | return f'{to}{text}' 68 | 69 | def _send_message(self, msg: str): 70 | return self.api.statuses.update(status=msg) 71 | 72 | def send(self, message_cls: Type['MessageBase'], message_model: Message, dispatch_models: List[Dispatch]): 73 | if self._session_started: 74 | for dispatch_model in dispatch_models: 75 | msg = self._build_message(dispatch_model.address, dispatch_model.message_cache) 76 | try: 77 | self._send_message(msg) 78 | self.mark_sent(dispatch_model) 79 | except Exception as e: 80 | self.mark_error(dispatch_model, e, message_cls) 81 | -------------------------------------------------------------------------------- /sitemessage/messengers/vkontakte.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext as _ 2 | 3 | from .base import RequestsMessengerBase, TypeProxy 4 | from ..exceptions import MessengerException 5 | 6 | 7 | class VKontakteMessengerException(MessengerException): 8 | """Exceptions raised by VKontakte messenger.""" 9 | 10 | 11 | class VKontakteMessenger(RequestsMessengerBase): 12 | """Implements VKontakte page wall message publishing. 13 | 14 | Steps to be done: 15 | 16 | 1. Create a user/community page. 17 | 2. Create `Standalone` application at http://vk.com/apps?act=manage 18 | 3. Get your Application ID (under Settings menu item in left menu) 19 | 4. To generate an access token: 20 | 21 | VKontakteMessenger.get_access_token(app_id=APP_ID) 22 | 23 | * Replace APP_ID with actual application ID. 24 | * This will open browser window. 25 | 26 | 5. Confirm and copy token from URL in browser (symbols after `access_token=` but before &) 27 | 6. Use this token. 28 | 29 | """ 30 | alias = 'vk' 31 | title = _('VKontakte') 32 | 33 | address_attr = 'vkontakte' 34 | 35 | _url_wall = 'https://api.vk.com/method/wall.post' 36 | _api_version = '5.131' 37 | 38 | def __init__(self, access_token: str, proxy: TypeProxy = None): 39 | """Configures messenger. 40 | 41 | :param access_token: Unique authentication token to access your VK user/community page. 42 | :param proxy: Dictionary of proxy settings, 43 | or a callable returning such a dictionary. 44 | 45 | """ 46 | super().__init__(proxy=proxy) 47 | self.access_token = access_token 48 | 49 | @classmethod 50 | def get_access_token(self, *, app_id: str) -> str: 51 | """Return an URL to get access token. 52 | 53 | Opens browser, trying to redirect to a page 54 | from location URL of which one can extract access_token 55 | (see the messenger class docstring). 56 | 57 | :param app_id: Application ID. 58 | 59 | """ 60 | url = ( 61 | 'https://oauth.vk.com/authorize?' 62 | 'client_id=%(app_id)s&' 63 | 'scope=wall,offline&' 64 | 'display=page&' 65 | 'response_type=token&' 66 | 'v=%(api_version)s&' 67 | 'redirect_uri=https://oauth.vk.com/blank.html' 68 | 69 | ) % {'app_id': app_id, 'api_version': self._api_version} 70 | 71 | import webbrowser 72 | webbrowser.open(url) 73 | 74 | return url 75 | 76 | def _send_message(self, msg: str, to: str = None): 77 | 78 | # Automatically deduce message type. 79 | message_type = 'attachments' if msg.startswith('http') else 'message' 80 | 81 | json = self.post( 82 | url=self._url_wall, 83 | data={ 84 | message_type: msg, 85 | 'owner_id': to, 86 | 'from_group': 1, 87 | 'access_token': self.access_token, 88 | 'v': self._api_version, 89 | }) 90 | 91 | if 'error' in json: 92 | error = json['error'] 93 | raise VKontakteMessengerException(f"{error['error_code']}: {error['error_msg']}") 94 | 95 | return json['response']['post_id'] # Returns post ID. 96 | -------------------------------------------------------------------------------- /sitemessage/messengers/xmpp.py: -------------------------------------------------------------------------------- 1 | from typing import Type, List 2 | 3 | from django.utils.translation import gettext as _ 4 | 5 | from .base import MessengerBase, Dispatch, Message 6 | from ..exceptions import MessengerWarmupException 7 | 8 | if False: # pragma: nocover 9 | from ..messages.base import MessageBase # noqa 10 | 11 | 12 | class XMPPSleekMessenger(MessengerBase): 13 | """Implements XMPP message delivery using `sleekxmpp` module. 14 | 15 | http://sleekxmpp.com/ 16 | 17 | """ 18 | 19 | alias = 'xmppsleek' 20 | xmpp = None 21 | title = _('XMPP') 22 | 23 | address_attr = 'jabber' 24 | 25 | _session_started = False 26 | 27 | def __init__( 28 | self, 29 | from_jid: str, 30 | password: str, 31 | host: str = 'localhost', 32 | port: int = 5222, 33 | use_tls: bool = True, 34 | use_ssl: bool = False 35 | ): 36 | """Configures messenger. 37 | 38 | :param from_jid: Jabber ID to send messages from 39 | :param password: password to log into XMPP server 40 | :param host: XMPP server host 41 | :param port: XMPP server port 42 | :param use_tls: whether to use TLS 43 | :param use_ssl: whether to use SSL 44 | 45 | """ 46 | import sleekxmpp 47 | 48 | self.lib = sleekxmpp 49 | 50 | self.from_jid = from_jid 51 | self.password = password 52 | self.host = host 53 | self.port = port 54 | self.use_tls = use_tls 55 | self.use_ssl = use_ssl 56 | 57 | def _test_message(self, to: str, text: str): 58 | return self._send_message(to, text) 59 | 60 | def _send_message(self, to: str, text: str): 61 | return self.xmpp.send_message(mfrom=self.from_jid, mto=to, mbody=text, mtype='chat') 62 | 63 | def before_send(self): 64 | def on_session_start(event): 65 | try: 66 | self.xmpp.send_presence() 67 | self.xmpp.get_roster() 68 | self._session_started = True 69 | 70 | except self.lib.exceptions.XMPPError as e: 71 | raise MessengerWarmupException(f'XMPP Error: {e}') 72 | 73 | self.xmpp = self.lib.ClientXMPP(self.from_jid, self.password) 74 | self.xmpp.add_event_handler('session_start', on_session_start) 75 | 76 | result = self.xmpp.connect( 77 | address=(self.host, self.port), 78 | reattempt=False, 79 | use_tls=self.use_tls, 80 | use_ssl=self.use_ssl 81 | ) 82 | 83 | if result: 84 | self.xmpp.process(block=False) 85 | 86 | def after_send(self): 87 | if self._session_started: 88 | self.xmpp.disconnect(wait=True) # Wait for a send queue. 89 | self._session_started = False 90 | 91 | def send(self, message_cls: Type['MessageBase'], message_model: Message, dispatch_models: List[Dispatch]): 92 | if self._session_started: 93 | for dispatch_model in dispatch_models: 94 | try: 95 | self._send_message(dispatch_model.address, dispatch_model.message_cache) 96 | self.mark_sent(dispatch_model) 97 | except Exception as e: 98 | self.mark_error(dispatch_model, e, message_cls) 99 | -------------------------------------------------------------------------------- /sitemessage/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | from django.conf import settings 6 | import sitemessage.models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Dispatch', 18 | fields=[ 19 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 20 | ('time_created', models.DateTimeField(auto_now_add=True, verbose_name='Time created')), 21 | ('time_dispatched', models.DateTimeField(help_text='Time of the last delivery attempt.', verbose_name='Time dispatched', null=True, editable=False, blank=True)), 22 | ('messenger', models.CharField(help_text='Messenger class identifier.', max_length=250, verbose_name='Messenger', db_index=True)), 23 | ('address', models.CharField(help_text='Recipient address.', max_length=250, verbose_name='Address')), 24 | ('retry_count', models.PositiveIntegerField(default=0, help_text='A number of delivery retries has already been made.', verbose_name='Retry count')), 25 | ('message_cache', models.TextField(verbose_name='Message cache', null=True, editable=False)), 26 | ('dispatch_status', models.PositiveIntegerField(default=1, verbose_name='Dispatch status', choices=[(1, 'Pending'), (2, 'Sent'), (3, 'Error'), (4, 'Failed')])), 27 | ('read_status', models.PositiveIntegerField(default=0, verbose_name='Read status', choices=[(0, 'Unread'), (1, 'Read')])), 28 | ], 29 | options={ 30 | 'verbose_name': 'Dispatch', 31 | 'verbose_name_plural': 'Dispatches', 32 | }, 33 | bases=(models.Model,), 34 | ), 35 | migrations.CreateModel( 36 | name='DispatchError', 37 | fields=[ 38 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 39 | ('time_created', models.DateTimeField(auto_now_add=True, verbose_name='Time created')), 40 | ('error_log', models.TextField(verbose_name='Text')), 41 | ('dispatch', models.ForeignKey(verbose_name='Dispatch', to='sitemessage.Dispatch', on_delete=models.CASCADE)), 42 | ], 43 | options={ 44 | 'verbose_name': 'Dispatch error', 45 | 'verbose_name_plural': 'Dispatch errors', 46 | }, 47 | bases=(models.Model,), 48 | ), 49 | migrations.CreateModel( 50 | name='Message', 51 | fields=[ 52 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 53 | ('time_created', models.DateTimeField(auto_now_add=True, verbose_name='Time created')), 54 | ('cls', models.CharField(help_text='Message logic class identifier.', max_length=250, verbose_name='Message class', db_index=True)), 55 | ('context', sitemessage.models.ContextField(verbose_name='Message context')), 56 | ('priority', models.PositiveIntegerField(default=0, help_text='Number describing message sending priority. Messages with different priorities can be sent with different periodicity.', verbose_name='Priority', db_index=True)), 57 | ('dispatches_ready', models.BooleanField(default=False, help_text='Indicates whether dispatches for this message are already formed and ready to delivery.', db_index=True, verbose_name='Dispatches ready')), 58 | ('sender', models.ForeignKey(verbose_name='Sender', blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)), 59 | ], 60 | options={ 61 | 'verbose_name': 'Message', 62 | 'verbose_name_plural': 'Messages', 63 | }, 64 | bases=(models.Model,), 65 | ), 66 | migrations.AddField( 67 | model_name='dispatch', 68 | name='message', 69 | field=models.ForeignKey(verbose_name='Message', to='sitemessage.Message', on_delete=models.CASCADE), 70 | preserve_default=True, 71 | ), 72 | migrations.AddField( 73 | model_name='dispatch', 74 | name='recipient', 75 | field=models.ForeignKey(verbose_name='Recipient', blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE), 76 | preserve_default=True, 77 | ), 78 | ] 79 | -------------------------------------------------------------------------------- /sitemessage/migrations/0002_subscription.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | from django.conf import settings 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('sitemessage', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Subscription', 18 | fields=[ 19 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 20 | ('time_created', models.DateTimeField(auto_now_add=True, verbose_name='Time created')), 21 | ('message_cls', models.CharField(help_text='Message logic class identifier.', max_length=250, verbose_name='Message class', db_index=True)), 22 | ('messenger_cls', models.CharField(help_text='Messenger class identifier.', max_length=250, verbose_name='Messenger', db_index=True)), 23 | ('address', models.CharField(help_text='Recipient address.', max_length=250, null=True, verbose_name='Address')), 24 | ('recipient', models.ForeignKey(verbose_name='Recipient', blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)), 25 | ], 26 | options={ 27 | 'verbose_name': 'Subscription', 28 | 'verbose_name_plural': 'Subscriptions', 29 | }, 30 | bases=(models.Model,), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /sitemessage/migrations/0003_auto_20210314_1053.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.16 on 2021-03-14 09:53 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('sitemessage', '0002_subscription'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='dispatch', 15 | name='dispatch_status', 16 | field=models.PositiveIntegerField(choices=[(1, 'Pending'), (5, 'Processing'), (2, 'Sent'), (3, 'Error'), (4, 'Failed')], default=1, verbose_name='Dispatch status'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /sitemessage/migrations/0004_message_group_mark.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.6 on 2023-03-18 10:32 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('sitemessage', '0003_auto_20210314_1053'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='message', 15 | name='group_mark', 16 | field=models.CharField(db_column='gmark', db_index=True, default='', help_text='An identifier to group several messages into one.', max_length=128, verbose_name='Group mark'), 17 | preserve_default=False, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /sitemessage/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-sitemessage/28db11c674d3d3eb59396a9b1e88b8033ff88e20/sitemessage/migrations/__init__.py -------------------------------------------------------------------------------- /sitemessage/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | APP_MODULE_NAME = getattr(settings, 'SITEMESSAGE_APP_MODULE_NAME', 'sitemessages') 5 | """Module name to search sitemessage preferences in.""" 6 | 7 | INIT_BUILTIN_MESSAGE_TYPES = getattr(settings, 'SITEMESSAGE_INIT_BUILTIN_MESSAGE_TYPES', True) 8 | """Whether to register builtin message types.""" 9 | 10 | EMAIL_BACKEND_MESSAGES_PRIORITY = getattr(settings, 'SITEMESSAGE_EMAIL_BACKEND_MESSAGES_PRIORITY', None) 11 | """Priority for messages sent by Django Email backend (sitemessage.backends.EmailBackend).""" 12 | 13 | SHORTCUT_EMAIL_MESSENGER_TYPE = getattr(settings, 'SITEMESSAGE_SHORTCUT_EMAIL_MESSENGER_TYPE', 'smtp') 14 | """Messenger type alias for messages sent with `schedule_email` shortcut.""" 15 | 16 | SHORTCUT_EMAIL_MESSAGE_TYPE = getattr(settings, 'SITEMESSAGE_SHORTCUT_EMAIL_MESSAGE_TYPE', None) 17 | """Message type alias to be used for messages sent with `schedule_email` shortcut.""" 18 | 19 | SITE_URL = getattr(settings, 'SITEMESSAGE_SITE_URL', None) 20 | """Site URL to use in messages.""" 21 | -------------------------------------------------------------------------------- /sitemessage/shortcuts.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from .messages.email import EmailHtmlMessage, EmailTextMessage 4 | from .settings import SHORTCUT_EMAIL_MESSENGER_TYPE, SHORTCUT_EMAIL_MESSAGE_TYPE 5 | from .toolbox import schedule_messages, recipients, get_registered_message_type, TypeMessages 6 | from .utils import TypeRecipients, TypeUser 7 | 8 | 9 | def schedule_email( 10 | message: Union[TypeMessages, dict], 11 | to: TypeRecipients, 12 | subject: str = None, 13 | sender: TypeUser = None, 14 | priority: int = None 15 | ): 16 | """Schedules an email message for delivery. 17 | 18 | :param message: str or dict: use str for simple text email; 19 | dict - to compile email from a template (default: `sitemessage/messages/email_html__smtp.html`). 20 | :param to: recipients addresses or Django User model heir instances 21 | :param subject: email subject 22 | :param sender: User model heir instance 23 | :param priority: number describing message priority. If set overrides priority provided with message type. 24 | 25 | """ 26 | if SHORTCUT_EMAIL_MESSAGE_TYPE: 27 | message_cls = get_registered_message_type(SHORTCUT_EMAIL_MESSAGE_TYPE) 28 | 29 | else: 30 | 31 | if isinstance(message, dict): 32 | message_cls = EmailHtmlMessage 33 | else: 34 | message_cls = EmailTextMessage 35 | 36 | schedule_messages( 37 | message_cls(subject, message), 38 | recipients(SHORTCUT_EMAIL_MESSENGER_TYPE, to), 39 | sender=sender, priority=priority 40 | ) 41 | 42 | 43 | def schedule_jabber_message(message: TypeMessages, to: TypeRecipients, sender: TypeUser = None, priority: int = None): 44 | """Schedules Jabber XMPP message for delivery. 45 | 46 | :param message: text to send. 47 | :param to: recipients addresses or Django User model heir instances with `email` attributes. 48 | :param sender: User model heir instance 49 | :param priority: number describing message priority. If set overrides priority provided with message type. 50 | 51 | """ 52 | schedule_messages(message, recipients('xmppsleek', to), sender=sender, priority=priority) 53 | 54 | 55 | def schedule_tweet(message: TypeMessages, to: TypeRecipients = '', sender: TypeUser = None, priority: int = None): 56 | """Schedules a Tweet for delivery. 57 | 58 | :param message: text to send. 59 | :param to: recipients addresses or Django User model heir instances with `telegram` attributes. 60 | If supplied tweets will be @-replies. 61 | :param sender: User model heir instance 62 | :param priority: number describing message priority. If set overrides priority provided with message type. 63 | 64 | """ 65 | schedule_messages(message, recipients('twitter', to), sender=sender, priority=priority) 66 | 67 | 68 | def schedule_telegram_message( 69 | message: TypeMessages, 70 | to: TypeRecipients, 71 | sender: TypeUser = None, 72 | priority: int = None 73 | ): 74 | """Schedules Telegram message for delivery. 75 | 76 | :param message: text to send. 77 | :param to: recipients addresses or Django User model heir instances with `telegram` attributes. 78 | :param sender: User model heir instance 79 | :param priority: number describing message priority. If set overrides priority provided with message type. 80 | 81 | """ 82 | schedule_messages(message, recipients('telegram', to), sender=sender, priority=priority) 83 | 84 | 85 | def schedule_facebook_message(message: TypeMessages, sender: TypeUser = None, priority: int = None): 86 | """Schedules Facebook wall message for delivery. 87 | 88 | :param message: text or URL to publish. 89 | :param sender: User model heir instance 90 | :param priority: number describing message priority. If set overrides priority provided with message type. 91 | 92 | """ 93 | schedule_messages(message, recipients('fb', ''), sender=sender, priority=priority) 94 | 95 | 96 | def schedule_vkontakte_message( 97 | message: TypeMessages, 98 | to: TypeRecipients, 99 | sender: TypeUser = None, 100 | priority: int = None 101 | ): 102 | """Schedules VKontakte message for delivery. 103 | 104 | :param message: text or URL to publish on wall. 105 | :param to: recipients addresses or Django User model heir instances with `vk` attributes. 106 | :param sender: User model heir instance 107 | :param priority: number describing message priority. If set overrides priority provided with message type. 108 | 109 | """ 110 | schedule_messages(message, recipients('vk', to), sender=sender, priority=priority) 111 | -------------------------------------------------------------------------------- /sitemessage/signals.py: -------------------------------------------------------------------------------- 1 | """This file contains signals emitted by sitemessage.""" 2 | import django.dispatch 3 | 4 | 5 | sig_unsubscribe_success = django.dispatch.Signal() 6 | """Emitted when user unsubscribe requested is successful. 7 | providing_args=['request', 'message', 'dispatch'] 8 | 9 | """ 10 | 11 | sig_unsubscribe_failed = django.dispatch.Signal() 12 | """Emitted when user unsubscribe requested fails. 13 | providing_args=['request', 'message', 'dispatch'] 14 | 15 | """ 16 | 17 | sig_mark_read_success = django.dispatch.Signal() 18 | """Emitted when mark read requested is successful. 19 | providing_args=['request', 'message', 'dispatch'] 20 | 21 | """ 22 | 23 | sig_mark_read_failed = django.dispatch.Signal() 24 | """Emitted when mark read requested fails. 25 | providing_args=['request', 'message', 'dispatch'] 26 | 27 | """ 28 | -------------------------------------------------------------------------------- /sitemessage/static/img/sitemessage/blank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-sitemessage/28db11c674d3d3eb59396a9b1e88b8033ff88e20/sitemessage/static/img/sitemessage/blank.png -------------------------------------------------------------------------------- /sitemessage/templates/sitemessage/messages/_base.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 4 | 5 | 6 | {{ subject }} 7 | 8 | 9 |
{{ subject }}
10 |
11 | {% block contents %} 12 | {{ contents|safe }} 13 | {% endblock %} 14 |
15 |
16 | {% block footer %} 17 |
18 | {% if footer %}
{{ footer|safe }}
{% endif %} 19 |
{{ SITE_URL }}
20 | {% if directive_unsubscribe %} 21 |
{% trans "You can unsubscribe from this list using" %} {% trans "this link" %}.
22 | {% endif %} 23 | {% if directive_mark_read %} 24 | 25 | {% endif %} 26 | {% endblock %} 27 |
28 | 29 | -------------------------------------------------------------------------------- /sitemessage/templates/sitemessage/messages/email_html__smtp.html: -------------------------------------------------------------------------------- 1 | {% extends "sitemessage/messages/_base.html" %} 2 | -------------------------------------------------------------------------------- /sitemessage/templates/sitemessage/user_prefs_table-bootstrap.html: -------------------------------------------------------------------------------- 1 | {% include "sitemessage/user_prefs_table.html" with table_class="table table-striped" %} -------------------------------------------------------------------------------- /sitemessage/templates/sitemessage/user_prefs_table.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% for messenger_title in sitemessage_user_prefs.0 %} 6 | 7 | {% endfor %} 8 | 9 | 10 | 11 | {% for message_title, prefs_list in sitemessage_user_prefs.1.items %} 12 | 13 | 14 | {% for pref_tuple in prefs_list %} 15 | 22 | {% endfor %} 23 | 24 | {% endfor %} 25 | 26 |
{{ messenger_title|safe }}
{{ message_title }} 16 | {% if pref_tuple.1 %} 17 | 20 | {% endif %} 21 |
-------------------------------------------------------------------------------- /sitemessage/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-sitemessage/28db11c674d3d3eb59396a9b1e88b8033ff88e20/sitemessage/templatetags/__init__.py -------------------------------------------------------------------------------- /sitemessage/templatetags/sitemessage.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | 3 | from django import template 4 | from django.template.base import FilterExpression, Parser, Token 5 | from django.template.loader import get_template 6 | from django.conf import settings 7 | 8 | from ..exceptions import SiteMessageConfigurationError 9 | 10 | 11 | register = template.Library() 12 | 13 | 14 | @register.tag 15 | def sitemessage_prefs_table(parser: Parser, token: Token): 16 | 17 | tokens = token.split_contents() 18 | use_template = detect_clause(parser, 'template', tokens) 19 | prefs_obj = detect_clause(parser, 'from', tokens) 20 | 21 | tokens_num = len(tokens) 22 | 23 | if tokens_num in (1, 3): 24 | return sitemessage_prefs_tableNode(prefs_obj, use_template) 25 | 26 | raise template.TemplateSyntaxError( 27 | '`sitemessage_prefs_table` tag expects the following notation: ' 28 | '{% sitemessage_prefs_table from user_prefs template "sitemessage/my_pref_table.html" %}.') 29 | 30 | 31 | class sitemessage_prefs_tableNode(template.Node): 32 | 33 | def __init__(self, prefs_obj: FilterExpression, use_template: Optional[str]): 34 | self.use_template = use_template 35 | self.prefs_obj = prefs_obj 36 | 37 | def render(self, context): 38 | resolve = lambda arg: arg.resolve(context) if isinstance(arg, FilterExpression) else arg 39 | 40 | prefs_obj = resolve(self.prefs_obj) 41 | 42 | if not isinstance(prefs_obj, tuple): 43 | 44 | if settings.DEBUG: 45 | raise SiteMessageConfigurationError( 46 | '`sitemessage_prefs_table` template tag expects a tuple generated ' 47 | f'by `get_user_preferences_for_ui` but `{type(prefs_obj)}` is given.') 48 | 49 | return '' # Silent fall. 50 | 51 | context.push() 52 | context['sitemessage_user_prefs'] = prefs_obj 53 | 54 | contents = get_template( 55 | resolve(self.use_template or 'sitemessage/user_prefs_table.html') 56 | ).render(context.flatten()) 57 | 58 | context.pop() 59 | 60 | return contents 61 | 62 | 63 | def detect_clause(parser: Parser, clause_name: str, tokens: List[str]): 64 | """Helper function detects a certain clause in tag tokens list. 65 | Returns its value. 66 | 67 | """ 68 | if clause_name in tokens: 69 | t_index = tokens.index(clause_name) 70 | clause_value = parser.compile_filter(tokens[t_index + 1]) 71 | del tokens[t_index:t_index + 2] 72 | 73 | else: 74 | clause_value = None 75 | 76 | return clause_value 77 | -------------------------------------------------------------------------------- /sitemessage/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # This package is considered both as a django app, and a test package. 2 | -------------------------------------------------------------------------------- /sitemessage/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pytest_djangoapp import configure_djangoapp_plugin 2 | 3 | 4 | pytest_plugins = configure_djangoapp_plugin({ 5 | 'ADMINS': [('one', 'a@a.com'), ('two', 'b@b.com')] 6 | }) 7 | -------------------------------------------------------------------------------- /sitemessage/tests/test_management.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from django.utils import timezone 3 | 4 | from sitemessage.models import Message, Dispatch, GET_DISPATCHES_ARGS, _get_dispatches_for_update, _get_dispatches 5 | from sitemessage.toolbox import recipients 6 | 7 | 8 | def test_sitemessage_cleanup(capsys, command_run, user_create): 9 | 10 | def create_message(dispatches_count): 11 | 12 | message = Message(cls='test_message') 13 | message.save() 14 | 15 | users = [] 16 | 17 | for _ in range(dispatches_count): 18 | users.append(user_create()) 19 | 20 | Dispatch.create(message, recipients('test_messenger', users)) 21 | 22 | dispatches = Dispatch.objects.filter(dispatch_status=Dispatch.DISPATCH_STATUS_PENDING) 23 | 24 | for dispatch in dispatches: 25 | dispatch.dispatch_status = dispatch.DISPATCH_STATUS_SENT 26 | dispatch.save() 27 | 28 | return message, dispatches 29 | 30 | def get_all(): 31 | return list(Message.objects.all()), list(Dispatch.objects.all()) 32 | 33 | def assert_len(msg, dsp): 34 | msg_, dsp_ = get_all() 35 | assert len(msg_) == msg 36 | assert len(dsp_) == dsp 37 | 38 | msg1, dsp1 = create_message(dispatches_count=2) 39 | dsp1[0].time_dispatched = timezone.now() - timedelta(days=3) 40 | dsp1[0].save() 41 | 42 | msg2, dsp2 = create_message(dispatches_count=1) 43 | dsp2[0].time_dispatched = timezone.now() - timedelta(days=2) 44 | dsp2[0].save() 45 | 46 | msg3, dsp3 = create_message(dispatches_count=3) 47 | assert_len(3, 6) 48 | 49 | command_run('sitemessage_cleanup', options={'ago': '4'}) 50 | assert_len(3, 6) 51 | 52 | command_run('sitemessage_cleanup', options={'ago': '2'}) 53 | assert_len(2, 4) 54 | 55 | command_run('sitemessage_cleanup') 56 | 57 | assert_len(0, 0) 58 | 59 | out, err = capsys.readouterr() 60 | 61 | assert 'Cleaning up dispatches and messages' in out 62 | assert err == '' 63 | 64 | 65 | def test_sitemessage_probe(capsys, command_run): 66 | command_run('sitemessage_probe', args=['test_messenger'], options={'to': 'someoner'}) 67 | 68 | out, err = capsys.readouterr() 69 | 70 | assert 'Sending test message using test_messenger' in out 71 | assert 'Probing function result: triggered send to `someoner`.' in out 72 | assert err == '' 73 | 74 | 75 | def test_sitemessage_check_undelivered(capsys, command_run): 76 | 77 | message = Message(cls='test_message') 78 | message.save() 79 | 80 | Dispatch.create(message, recipients('test_messenger', 'someoner')) 81 | 82 | dispatch = Dispatch.objects.all() 83 | assert len(dispatch) == 1 84 | 85 | dispatch = dispatch[0] 86 | dispatch.dispatch_status = dispatch.DISPATCH_STATUS_FAILED 87 | dispatch.save() 88 | 89 | def run_command(count=1): 90 | 91 | command_run('sitemessage_check_undelivered') 92 | 93 | out, err = capsys.readouterr() 94 | 95 | assert f'Undelivered dispatches count: {count}' in out 96 | assert err == '' 97 | 98 | run_command() 99 | 100 | # Now let's check a fallback works. 101 | try: 102 | GET_DISPATCHES_ARGS[0] = lambda kwargs: None 103 | 104 | run_command(3) 105 | assert GET_DISPATCHES_ARGS[0] is _get_dispatches 106 | 107 | finally: 108 | GET_DISPATCHES_ARGS[0] = _get_dispatches_for_update 109 | 110 | -------------------------------------------------------------------------------- /sitemessage/tests/test_messengers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from sitemessage.messengers.base import MessengerBase 4 | from sitemessage.models import Subscription, DispatchError 5 | from sitemessage.toolbox import recipients, schedule_messages, send_scheduled_messages 6 | from sitemessage.utils import get_registered_messenger_objects 7 | from .testapp.sitemessages import ( 8 | WONDERLAND_DOMAIN, MessagePlainForTest, MessengerForTest, BuggyMessenger, 9 | messenger_fb, 10 | messenger_smtp, 11 | messenger_telegram, 12 | messenger_twitter, 13 | messenger_vk, 14 | messenger_xmpp, 15 | ) 16 | 17 | 18 | def test_init_params(): 19 | messengers = get_registered_messenger_objects() 20 | my = messengers['test_messenger'] 21 | assert my.login == 'mylogin' 22 | assert my.password == 'mypassword' 23 | 24 | 25 | def test_alias(): 26 | messenger = type('MyMessenger', (MessengerBase,), {'alias': 'myalias'}) 27 | assert messenger.get_alias() == 'myalias' 28 | 29 | messenger = type('MyMessenger', (MessengerBase,), {}) 30 | assert messenger.get_alias() == 'MyMessenger' 31 | 32 | 33 | def test_get_recipients_data(user_create): 34 | user = user_create(attributes=dict(username='myuser')) 35 | to = ['gogi', 'givi', user] 36 | 37 | r1 = MessengerForTest.structure_recipients_data(to) 38 | 39 | assert len(r1) == len(to) 40 | assert r1[0].address == f'gogi{WONDERLAND_DOMAIN}' 41 | assert r1[0].messenger == 'test_messenger' 42 | assert r1[1].address == f'givi{WONDERLAND_DOMAIN}' 43 | assert r1[1].messenger == 'test_messenger' 44 | assert r1[2].address == f'user_myuser{WONDERLAND_DOMAIN}' 45 | assert r1[2].messenger == 'test_messenger' 46 | 47 | 48 | def test_recipients(): 49 | r = MessagePlainForTest.recipients('smtp', 'someone') 50 | assert len(r) == 1 51 | assert r[0].address == 'someone' 52 | 53 | 54 | def test_send(): 55 | m = MessengerForTest('l', 'p') 56 | m.send('message_cls', 'message_model', 'dispatch_models') 57 | 58 | assert m.last_send['message_cls'] == 'message_cls' 59 | assert m.last_send['message_model'] == 'message_model' 60 | assert m.last_send['dispatch_models'] == 'dispatch_models' 61 | 62 | m = BuggyMessenger() 63 | recipiets_ = recipients('test_messenger', ['a', 'b', 'c', 'd']) 64 | 65 | with pytest.raises(Exception): 66 | m.send('a buggy message', '', recipiets_) 67 | 68 | 69 | def test_subscription(user_create): 70 | user1 = user_create(attributes=dict(username='first')) 71 | user2 = user_create(attributes=dict(username='second')) 72 | user2.is_active = False 73 | user2.save() 74 | 75 | Subscription.create(user1.id, MessagePlainForTest, MessengerForTest) 76 | Subscription.create(user2.id, MessagePlainForTest, MessengerForTest) 77 | assert len(MessagePlainForTest.get_subscribers(active_only=False)) == 2 78 | assert len(MessagePlainForTest.get_subscribers(active_only=True)) == 1 79 | 80 | 81 | def assert_called_n(func, n=1): 82 | assert func.call_count == n 83 | func.call_count = 0 84 | 85 | 86 | def test_exception_propagation(monkeypatch): 87 | schedule_messages('text', recipients('telegram', '')) 88 | schedule_messages('text', recipients('telegram', '')) 89 | 90 | def new_method(*args, **kwargs): 91 | raise Exception('telegram beforesend failed') 92 | 93 | monkeypatch.setattr(messenger_telegram, 'before_send', new_method) 94 | send_scheduled_messages() 95 | 96 | errors = list(DispatchError.objects.all()) 97 | assert len(errors) == 2 98 | assert errors[0].error_log == 'telegram beforesend failed' 99 | assert errors[1].error_log == 'telegram beforesend failed' 100 | 101 | 102 | class TestSMTPMessenger: 103 | 104 | def setup_method(self, method): 105 | messenger_smtp.smtp.sendmail.call_count = 0 106 | 107 | def test_get_address(self): 108 | r = object() 109 | assert messenger_smtp.get_address(r) == r 110 | 111 | r = type('r', (object,), dict(email='somewhere')) 112 | assert messenger_smtp.get_address(r) == 'somewhere' 113 | 114 | def test_send(self): 115 | schedule_messages('text', recipients('smtp', 'someone')) 116 | send_scheduled_messages() 117 | assert_called_n(messenger_smtp.smtp.sendmail) 118 | 119 | def test_send_fail(self): 120 | schedule_messages('text', recipients('smtp', 'someone')) 121 | 122 | def new_method(*args, **kwargs): 123 | raise Exception('smtp failed') 124 | 125 | old_method = messenger_smtp.smtp.sendmail 126 | messenger_smtp.smtp.sendmail = new_method 127 | 128 | try: 129 | send_scheduled_messages() 130 | errors = DispatchError.objects.all() 131 | assert len(errors) == 1 132 | assert errors[0].error_log == 'smtp failed' 133 | assert errors[0].dispatch.address == 'someone' 134 | finally: 135 | messenger_smtp.smtp.sendmail = old_method 136 | 137 | def test_send_test_message(self): 138 | messenger_smtp.send_test_message('someone', 'sometext') 139 | assert_called_n(messenger_smtp.smtp.sendmail) 140 | 141 | 142 | class TestTwitterMessenger: 143 | 144 | def test_get_address(self): 145 | r = object() 146 | assert messenger_twitter.get_address(r) == r 147 | 148 | r = type('r', (object,), dict(twitter='somewhere')) 149 | assert messenger_twitter.get_address(r) == 'somewhere' 150 | 151 | def test_send(self): 152 | schedule_messages('text', recipients('twitter', 'someone')) 153 | send_scheduled_messages() 154 | messenger_twitter.api.statuses.update.assert_called_with(status='@someone text') 155 | 156 | def test_send_test_message(self): 157 | messenger_twitter.send_test_message('someone', 'sometext') 158 | messenger_twitter.api.statuses.update.assert_called_with(status='@someone sometext') 159 | 160 | messenger_twitter.send_test_message('', 'sometext') 161 | messenger_twitter.api.statuses.update.assert_called_with(status='sometext') 162 | 163 | def test_send_fail(self): 164 | schedule_messages('text', recipients('twitter', 'someone')) 165 | 166 | def new_method(*args, **kwargs): 167 | raise Exception('tweet failed') 168 | 169 | old_method = messenger_twitter.api.statuses.update 170 | messenger_twitter.api.statuses.update = new_method 171 | 172 | try: 173 | send_scheduled_messages() 174 | errors = DispatchError.objects.all() 175 | assert len(errors) == 1 176 | assert errors[0].error_log == 'tweet failed' 177 | assert errors[0].dispatch.address == 'someone' 178 | finally: 179 | messenger_twitter.api.statuses.update = old_method 180 | 181 | 182 | class TestXMPPSleekMessenger: 183 | 184 | def test_get_address(self): 185 | r = object() 186 | assert messenger_xmpp.get_address(r) == r 187 | 188 | r = type('r', (object,), dict(jabber='somewhere')) 189 | assert messenger_xmpp.get_address(r) == 'somewhere' 190 | 191 | def test_send(self): 192 | schedule_messages('text', recipients('xmppsleek', 'someone')) 193 | send_scheduled_messages() 194 | messenger_xmpp.xmpp.send_message.assert_called_once_with( 195 | mtype='chat', mbody='text', mfrom='somjid', mto='someone' 196 | ) 197 | 198 | def test_send_test_message(self): 199 | messenger_xmpp.send_test_message('someone', 'sometext') 200 | messenger_xmpp.xmpp.send_message.assert_called_with( 201 | mtype='chat', mbody='sometext', mfrom='somjid', mto='someone' 202 | ) 203 | 204 | def test_send_fail(self): 205 | schedule_messages('text', recipients('xmppsleek', 'someone')) 206 | 207 | def new_method(*args, **kwargs): 208 | raise Exception('xmppsleek failed') 209 | 210 | old_method = messenger_xmpp.xmpp.send_message 211 | messenger_xmpp.xmpp.send_message = new_method 212 | messenger_xmpp._session_started = True 213 | try: 214 | send_scheduled_messages() 215 | errors = DispatchError.objects.all() 216 | assert len(errors) == 1 217 | assert errors[0].error_log == 'xmppsleek failed' 218 | assert errors[0].dispatch.address == 'someone' 219 | finally: 220 | messenger_xmpp.xmpp.send_message = old_method 221 | 222 | 223 | class TestTelegramMessenger: 224 | 225 | def setup_method(self, method): 226 | messenger_telegram._verify_bot() 227 | messenger_telegram.lib.post.call_count = 0 228 | 229 | def test_get_address(self): 230 | r = object() 231 | assert messenger_telegram.get_address(r) == r 232 | 233 | r = type('r', (object,), dict(telegram='chat_id')) 234 | assert messenger_telegram.get_address(r) == 'chat_id' 235 | 236 | def test_send(self): 237 | schedule_messages('text', recipients('telegram', '1234567')) 238 | send_scheduled_messages() 239 | assert_called_n(messenger_telegram.lib.post, 2) 240 | assert messenger_telegram.lib.post.call_args[1]['proxies'] == {'https': 'socks5://user:pass@host:port'} 241 | 242 | def test_send_test_message(self): 243 | messenger_telegram.send_test_message('someone', 'sometext') 244 | assert_called_n(messenger_telegram.lib.post) 245 | 246 | messenger_telegram.send_test_message('', 'sometext') 247 | assert_called_n(messenger_telegram.lib.post) 248 | 249 | def test_get_chat_ids(self): 250 | assert messenger_telegram.get_chat_ids() == [] 251 | assert_called_n(messenger_telegram.lib.post) 252 | 253 | def test_send_fail(self): 254 | schedule_messages('text', recipients('telegram', 'someone')) 255 | 256 | def new_method(*args, **kwargs): 257 | raise Exception('telegram failed') 258 | 259 | old_method = messenger_telegram.lib.post 260 | messenger_telegram.lib.post = new_method 261 | 262 | try: 263 | send_scheduled_messages() 264 | errors = DispatchError.objects.all() 265 | assert len(errors) == 1 266 | assert errors[0].error_log == 'telegram failed' 267 | assert errors[0].dispatch.address == 'someone' 268 | finally: 269 | messenger_telegram.lib.post = old_method 270 | 271 | 272 | class TestFacebookMessenger: 273 | 274 | def setup_method(self, method): 275 | messenger_fb.lib.post.call_count = 0 276 | messenger_fb.lib.get.call_count = 0 277 | 278 | def test_send(self): 279 | schedule_messages('text', recipients('fb', '')) 280 | send_scheduled_messages() 281 | assert_called_n(messenger_fb.lib.post) 282 | assert messenger_fb.lib.post.call_args[1]['proxies'] == {'https': '0.0.0.0'} 283 | 284 | def test_send_test_message(self): 285 | messenger_fb.send_test_message('', 'sometext') 286 | assert_called_n(messenger_fb.lib.post) 287 | 288 | messenger_fb.send_test_message('', 'sometext') 289 | assert_called_n(messenger_fb.lib.post) 290 | 291 | def test_get_page_access_token(self): 292 | assert messenger_fb.get_page_access_token('app_id', 'app_secret', 'user_token') == {} 293 | assert_called_n(messenger_fb.lib.get, 2) 294 | 295 | def test_send_fail(self): 296 | schedule_messages('text', recipients('fb', '')) 297 | 298 | def new_method(*args, **kwargs): 299 | raise Exception('fb failed') 300 | 301 | old_method = messenger_fb.lib.post 302 | messenger_fb.lib.post = new_method 303 | 304 | try: 305 | send_scheduled_messages() 306 | errors = DispatchError.objects.all() 307 | assert len(errors) == 1 308 | assert errors[0].error_log == 'fb failed' 309 | assert errors[0].dispatch.address == '' 310 | finally: 311 | messenger_fb.lib.post = old_method 312 | 313 | 314 | class TestVKontakteMessenger: 315 | 316 | def setup_method(self, method): 317 | messenger_vk.lib.post.call_count = 0 318 | messenger_vk.lib.get.call_count = 0 319 | 320 | def test_send(self): 321 | schedule_messages('text', recipients('vk', '12345')) 322 | send_scheduled_messages() 323 | assert_called_n(messenger_vk.lib.post) 324 | assert messenger_vk.lib.post.call_args[1]['data']['owner_id'] == '12345' 325 | 326 | def test_get_access_token(self, monkeypatch): 327 | monkeypatch.setattr('webbrowser.open', lambda *args: None) 328 | result = messenger_vk.get_access_token(app_id='00000') 329 | assert '00000&scope=wall,' in result 330 | 331 | def test_send_test_message(self): 332 | messenger_vk.send_test_message('12345', 'sometext') 333 | assert_called_n(messenger_vk.lib.post) 334 | 335 | messenger_vk.send_test_message('12345', 'sometext') 336 | assert_called_n(messenger_vk.lib.post) 337 | 338 | def test_send_fail(self): 339 | schedule_messages('text', recipients('vk', '12345')) 340 | 341 | def new_method(*args, **kwargs): 342 | raise Exception('vk failed') 343 | 344 | old_method = messenger_vk.lib.post 345 | messenger_vk.lib.post = new_method 346 | 347 | try: 348 | send_scheduled_messages() 349 | errors = DispatchError.objects.all() 350 | assert len(errors) == 1 351 | assert errors[0].error_log == 'vk failed' 352 | assert errors[0].dispatch.address == '12345' 353 | finally: 354 | messenger_vk.lib.post = old_method 355 | 356 | -------------------------------------------------------------------------------- /sitemessage/tests/test_misc.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.core.mail import send_mail 3 | from django.template import TemplateDoesNotExist 4 | 5 | from sitemessage.messages.base import MessageBase 6 | from sitemessage.models import Message, Dispatch 7 | from sitemessage.shortcuts import ( 8 | schedule_email, 9 | schedule_jabber_message, 10 | schedule_tweet, 11 | schedule_facebook_message, 12 | schedule_telegram_message, 13 | schedule_vkontakte_message, 14 | recipients 15 | ) 16 | from .testapp.sitemessages import MessagePlainForTest, MessageForTest, MessengerForTest, MessageGroupedForTest 17 | 18 | 19 | def test_migrations(check_migrations): 20 | check_migrations() 21 | 22 | 23 | class TestMessageSuite: 24 | 25 | def test_alias(self): 26 | message: MessageBase = type('MyMessage', (MessageBase,), {'alias': 'myalias'}) 27 | assert message.get_alias() == 'myalias' 28 | 29 | message = type('MyMessage', (MessageBase,), {}) 30 | assert message.get_alias() == 'MyMessage' 31 | 32 | message = message() 33 | assert str(message) == 'MyMessage' 34 | 35 | def test_context(self): 36 | msg = MessageForTest({'title': 'My message!', 'name': 'idle'}) 37 | assert msg.context == {'name': 'idle', 'title': 'My message!', 'tpl': None, 'use_tpl': True} 38 | 39 | def test_get_template(self): 40 | assert ( 41 | MessageForTest.get_template(MessageForTest(), MessengerForTest('a', 'b')) == 42 | 'sitemessage/messages/test_message__test_messenger.html' 43 | ) 44 | 45 | def test_compile_string(self): 46 | msg = MessagePlainForTest('simple') 47 | assert MessagePlainForTest.compile(msg, MessengerForTest('a', 'b')) == 'simple' 48 | 49 | def test_compile_dict(self): 50 | msg = MessageForTest({'title': 'My message!', 'name': 'idle'}) 51 | with pytest.raises(TemplateDoesNotExist): 52 | MessageForTest.compile(msg, MessengerForTest('a', 'b')) 53 | 54 | def test_schedule(self, user): 55 | msg = MessagePlainForTest('schedule1') 56 | model, _ = msg.schedule() 57 | 58 | assert model.cls == msg.get_alias() 59 | assert model.context == msg.get_context() 60 | assert model.sender is None 61 | 62 | msg = MessagePlainForTest('schedule2') 63 | model, _ = msg.schedule(sender=user) 64 | assert model.sender == user 65 | 66 | def test_grouped(self, user): 67 | 68 | rec = recipients('smtp', ['three', 'four']) 69 | msg1 = MessageGroupedForTest('text1') 70 | msg1_model, msg1_dispatches = msg1.schedule(recipients=rec) 71 | assert msg1_model.context == {'tpl': None, 'use_tpl': False, 'stext_': 'text1'} 72 | 73 | msg2 = MessageGroupedForTest('moretext') 74 | msg2_model, msg2_dispatches = msg2.schedule(recipients=rec) 75 | assert msg2_model.id == msg1_model.id 76 | assert msg2_model.context == {'tpl': None, 'use_tpl': False, 'stext_': 'text1\nmoretext'} 77 | 78 | 79 | class TestCommands: 80 | 81 | def test_send_scheduled(self, command_run): 82 | command_run('sitemessage_send_scheduled', options=dict(priority=1)) 83 | 84 | 85 | class TestBackends: 86 | 87 | def test_email_backend(self, settings): 88 | settings.EMAIL_BACKEND = 'sitemessage.backends.EmailBackend' 89 | 90 | send_mail('subj', 'message', 'from@example.com', ['to@example.com'], fail_silently=False) 91 | dispatches = list(Dispatch.objects.all()) 92 | assert len(dispatches) == 1 93 | assert dispatches[0].messenger == 'smtp' 94 | assert dispatches[0].address == 'to@example.com' 95 | assert dispatches[0].dispatch_status == Dispatch.DISPATCH_STATUS_PENDING 96 | assert dispatches[0].message.cls == 'email_html' 97 | assert dispatches[0].message.context['subject'] == 'subj' 98 | assert dispatches[0].message.context['contents'] == 'message' 99 | 100 | 101 | class TestShortcuts: 102 | 103 | def test_schedule_email(self): 104 | schedule_email('some text', 'some@one.here') 105 | 106 | assert Message.objects.all()[0].cls == 'email_plain' 107 | assert Message.objects.count() == 1 108 | assert Dispatch.objects.count() == 1 109 | 110 | schedule_email({'header': 'one', 'body': 'two'}, 'some@one.here') 111 | 112 | assert Message.objects.all()[1].cls == 'email_html' 113 | assert Message.objects.count() == 2 114 | assert Dispatch.objects.count() == 2 115 | 116 | def test_schedule_jabber_message(self): 117 | schedule_jabber_message('message', 'noone') 118 | 119 | def test_schedule_tweet(self): 120 | schedule_tweet('message', '') 121 | 122 | def test_schedule_tele(self): 123 | schedule_telegram_message('message', '') 124 | 125 | def test_schedule_fb(self): 126 | schedule_facebook_message('message') 127 | 128 | def test_schedule_vk(self): 129 | schedule_vkontakte_message('message', '12345') 130 | -------------------------------------------------------------------------------- /sitemessage/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from sitemessage.models import Message, Dispatch, Subscription, DispatchError 2 | from sitemessage.toolbox import recipients 3 | from sitemessage.utils import Recipient 4 | 5 | from .testapp.sitemessages import MessageForTest 6 | 7 | 8 | class TestSubscriptionModel: 9 | 10 | def test_create(self, user): 11 | 12 | s = Subscription.create('abc', 'message', 'messenger') 13 | 14 | assert s.time_created is not None 15 | assert s.recipient is None 16 | assert s.address == 'abc' 17 | assert s.message_cls == 'message' 18 | assert s.messenger_cls == 'messenger' 19 | 20 | s = Subscription.create(user, 'message', 'messenger') 21 | 22 | assert s.time_created is not None 23 | assert s.address is None 24 | assert s.recipient_id == user.id 25 | assert s.message_cls == 'message' 26 | assert s.messenger_cls == 'messenger' 27 | 28 | def test_cancel(self, user): 29 | 30 | Subscription.create('abc', 'message', 'messenger') 31 | Subscription.create('abc', 'message1', 'messenger') 32 | Subscription.create('abc', 'message', 'messenger1') 33 | assert Subscription.objects.filter(address='abc').count() == 3 34 | 35 | Subscription.cancel('abc', 'message', 'messenger') 36 | assert Subscription.objects.filter(address='abc').count() == 2 37 | 38 | Subscription.create(user, 'message', 'messenger') 39 | assert Subscription.objects.filter(recipient=user).count() == 1 40 | 41 | Subscription.cancel(user, 'message', 'messenger') 42 | assert Subscription.objects.filter(recipient=user).count() == 0 43 | 44 | def test_replace_for_user(self, user): 45 | new_prefs = [('message3', 'messenger3')] 46 | 47 | assert Subscription.replace_for_user(user, new_prefs) 48 | 49 | Subscription.create(user, 'message', 'messenger') 50 | Subscription.create(user, 'message2', 'messenger2') 51 | 52 | assert Subscription.get_for_user(user).count() == 3 53 | 54 | Subscription.replace_for_user(user, new_prefs) 55 | 56 | s = Subscription.get_for_user(user) 57 | assert s.count() == 1 58 | s = s[0] 59 | assert s.message_cls == 'message3' 60 | assert s.messenger_cls == 'messenger3' 61 | 62 | def test_get_for_user(self, user): 63 | r = Subscription.get_for_user(user) 64 | assert list(r) == [] 65 | 66 | assert Subscription.get_for_user(user).count() == 0 67 | 68 | Subscription.create(user, 'message', 'messenger') 69 | 70 | assert Subscription.get_for_user(user).count() == 1 71 | 72 | def test_get_for_message_cls(self): 73 | assert Subscription.get_for_message_cls('mymsg').count() == 0 74 | 75 | Subscription.create('aaa', 'mymsg', 'messenger') 76 | Subscription.create('bbb', 'mymsg', 'messenger2') 77 | 78 | assert Subscription.get_for_message_cls('mymsg').count() == 2 79 | 80 | def test_str(self): 81 | s = Subscription() 82 | s.address = 'aaa' 83 | 84 | assert 'aaa' in str(s) 85 | 86 | 87 | class TestDispatchErrorModel: 88 | 89 | def test_str(self): 90 | e = DispatchError() 91 | e.dispatch_id = 444 92 | 93 | assert '444' in str(e) 94 | 95 | 96 | class TestDispatchModel: 97 | 98 | def test_create(self, user): 99 | 100 | message = Message(cls='test_message') 101 | message.save() 102 | 103 | recipients_ = recipients('test_messenger', [user]) 104 | recipients_ += recipients('buggy', 'idle') 105 | 106 | dispatches = Dispatch.create(message, recipients_) 107 | assert isinstance(dispatches[0], Dispatch) 108 | assert isinstance(dispatches[1], Dispatch) 109 | assert dispatches[0].message_id == message.id 110 | assert dispatches[1].message_id == message.id 111 | assert dispatches[0].messenger == 'test_messenger' 112 | assert dispatches[1].messenger == 'buggy' 113 | 114 | dispatches = Dispatch.create(message, Recipient('msgr', None, 'address')) 115 | assert len(dispatches) == 1 116 | 117 | def test_log_dispatches_errors(self): 118 | 119 | assert DispatchError.objects.count() == 0 120 | 121 | m = Message() 122 | m.save() 123 | 124 | d1 = Dispatch(message_id=m.id) 125 | d1.save() 126 | 127 | d1.error_log = 'some_text' 128 | 129 | Dispatch.log_dispatches_errors([d1]) 130 | errors = DispatchError.objects.all() 131 | assert len(errors) == 1 132 | assert errors[0].error_log == 'some_text' 133 | 134 | def test_get_unread(self): 135 | 136 | m = Message() 137 | m.save() 138 | 139 | d1 = Dispatch(message_id=m.id) 140 | d1.save() 141 | 142 | d2 = Dispatch(message_id=m.id) 143 | d2.save() 144 | assert Dispatch.get_unread().count() == 2 145 | 146 | d2.read_status = Dispatch.READ_STATUS_READ 147 | d2.save() 148 | assert Dispatch.get_unread().count() == 1 149 | 150 | def test_set_dispatches_statuses(self): 151 | 152 | m = Message() 153 | m.save() 154 | 155 | d = Dispatch(message_id=m.id) 156 | d.save() 157 | 158 | Dispatch.set_dispatches_statuses(**{'sent': [d]}) 159 | d_ = Dispatch.objects.get(pk=d.id) 160 | assert d_.dispatch_status == Dispatch.DISPATCH_STATUS_SENT 161 | assert d_.retry_count == 1 162 | 163 | Dispatch.set_dispatches_statuses(**{'error': [d]}) 164 | d_ = Dispatch.objects.get(pk=d.id) 165 | assert d_.dispatch_status == Dispatch.DISPATCH_STATUS_ERROR 166 | assert d_.retry_count == 2 167 | 168 | def test_str(self): 169 | d = Dispatch() 170 | d.address = 'tttt' 171 | 172 | assert 'tttt' in str(d) 173 | 174 | def test_mark_read(self): 175 | d = Dispatch() 176 | assert d.read_status == d.READ_STATUS_UNREAD 177 | d.mark_read() 178 | assert d.read_status == d.READ_STATUS_READ 179 | 180 | 181 | class TestMessageModel: 182 | 183 | def test_create(self, user): 184 | m, _ = Message.create('some', {'abc': 'abc'}, sender=user, priority=22) 185 | assert m.cls == 'some' 186 | assert m.context == {'abc': 'abc'} 187 | assert m.sender == user 188 | assert m.priority == 22 189 | assert not m.dispatches_ready 190 | 191 | ctx = {'a': 'a', 'b': 'b'} 192 | m, _ = Message.create('some2', ctx) 193 | assert m.cls == 'some2' 194 | assert m.context == ctx 195 | assert m.sender is None 196 | assert not m.dispatches_ready 197 | 198 | def test_deserialize_context(self): 199 | m = Message(cls='some_cls', context={'a': 'a', 'b': 'b', 'c': 'c'}) 200 | m.save() 201 | 202 | m2 = Message.objects.get(pk=m.pk) 203 | assert m2.context == {'a': 'a', 'b': 'b', 'c': 'c'} 204 | 205 | def test_get_type(self): 206 | m = Message(cls='test_message') 207 | 208 | assert m.get_type() is MessageForTest 209 | 210 | def test_str(self): 211 | m = Message() 212 | m.cls = 'aaa' 213 | 214 | assert str(m) == 'aaa' 215 | -------------------------------------------------------------------------------- /sitemessage/tests/test_toolbox.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.template import TemplateSyntaxError 3 | 4 | from sitemessage.exceptions import UnknownMessengerError, SiteMessageConfigurationError 5 | from sitemessage.models import Message, Dispatch, Subscription 6 | from sitemessage.toolbox import send_scheduled_messages, get_user_preferences_for_ui, \ 7 | set_user_preferences_from_request, _ALIAS_SEP, _PREF_POST_KEY 8 | 9 | 10 | def test_get_user_preferences_for_ui(template_render_tag, template_context, user): 11 | messengers_titles, prefs = get_user_preferences_for_ui(user) 12 | 13 | assert len(prefs.keys()) == 3 14 | assert len(messengers_titles) == 8 15 | 16 | from .testapp.sitemessages import MessageForTest, MessengerForTest 17 | 18 | Subscription.create(user, MessageForTest, MessengerForTest) 19 | 20 | user_prefs = get_user_preferences_for_ui( 21 | user, 22 | message_filter=lambda m: m.alias == 'test_message', 23 | messenger_filter=lambda m: m.alias in ['smtp', 'test_messenger'] 24 | ) 25 | messengers_titles, prefs = user_prefs 26 | 27 | assert len(prefs.keys()) == 1 28 | assert len(messengers_titles) == 2 29 | assert 'E-mail' in messengers_titles 30 | assert 'Test messenger' in messengers_titles 31 | 32 | html = template_render_tag( 33 | 'sitemessage', 34 | 'sitemessage_prefs_table from user_prefs', 35 | template_context({'user_prefs': user_prefs}) 36 | ) 37 | 38 | assert 'class="sitemessage_prefs' in html 39 | assert 'E-mail' in html 40 | assert 'Test messenger' in html 41 | assert 'value="test_message|smtp"' in html 42 | assert 'value="test_message|test_messenger" checked' in html 43 | 44 | prefs_row = prefs.popitem() 45 | assert prefs_row[0] == 'Test message type' 46 | assert ('test_message|smtp', True, False) in prefs_row[1] 47 | assert ('test_message|test_messenger', True, True) in prefs_row[1] 48 | 49 | 50 | def test_templatetag_fails_silent(template_render_tag, template_context): 51 | html = template_render_tag( 52 | 'sitemessage', 53 | 'sitemessage_prefs_table from user_prefs', 54 | template_context({'user_prefs': 'a'}) 55 | ) 56 | 57 | assert html == '' 58 | 59 | 60 | def test_templatetag_fails_loud(template_render_tag, template_context, settings): 61 | 62 | settings.DEBUG = True 63 | 64 | with pytest.raises(SiteMessageConfigurationError): 65 | template_render_tag( 66 | 'sitemessage', 'sitemessage_prefs_table from user_prefs', 67 | template_context({'user_prefs': 'a'})) 68 | 69 | with pytest.raises(TemplateSyntaxError): 70 | template_render_tag('sitemessage', 'sitemessage_prefs_table user_prefs') 71 | 72 | 73 | def test_send_scheduled_messages_unknown_messenger(): 74 | message = Message() 75 | message.save() 76 | dispatch = Dispatch(message=message, messenger='unknownname') 77 | dispatch.save() 78 | 79 | with pytest.raises(UnknownMessengerError): 80 | send_scheduled_messages() 81 | 82 | send_scheduled_messages(ignore_unknown_messengers=True) 83 | 84 | 85 | def test_set_user_preferences_from_request(request_post, user): 86 | set_user_preferences_from_request( 87 | request_post('/', data={_PREF_POST_KEY: f'aaa{_ALIAS_SEP}qqq'}, user=user)) 88 | 89 | subs = Subscription.objects.all() 90 | assert len(subs) == 0 91 | 92 | set_user_preferences_from_request( 93 | request_post('/', data={_PREF_POST_KEY: f'test_message{_ALIAS_SEP}test_messenger'}, user=user)) 94 | 95 | subs = Subscription.objects.all() 96 | assert len(subs) == 1 97 | -------------------------------------------------------------------------------- /sitemessage/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from sitemessage.messages.base import MessageBase 2 | from sitemessage.messengers.base import MessengerBase 3 | from sitemessage.models import Message, Subscription 4 | from sitemessage.toolbox import schedule_messages, recipients, send_scheduled_messages, prepare_dispatches 5 | from sitemessage.utils import register_message_types, register_messenger_objects, \ 6 | get_registered_messenger_objects, get_registered_messenger_object, get_registered_message_types, \ 7 | override_message_type_for_app, get_message_type_for_app 8 | 9 | from .testapp.sitemessages import WONDERLAND_DOMAIN, MessagePlainForTest, MessagePlainDynamicForTest, MessageForTest, \ 10 | MessengerForTest 11 | 12 | 13 | def test_register_messengers(): 14 | messenger = type('MyMessenger', (MessengerBase,), {}) # type: MessengerBase 15 | register_messenger_objects(messenger) 16 | assert messenger.get_alias() in get_registered_messenger_objects() 17 | 18 | 19 | def test_register_message_types(): 20 | message = type('MyMessage', (MessageBase,), {}) # type: MessageBase 21 | register_message_types(message) 22 | assert message.get_alias() in get_registered_message_types() 23 | 24 | 25 | def test_recipients(user_create): 26 | user = user_create(attributes=dict(username='myuser')) 27 | to = ['gogi', 'givi', user] 28 | 29 | r1 = recipients('test_messenger', to) 30 | 31 | assert len(r1) == len(to) 32 | assert r1[0].address == f'gogi{WONDERLAND_DOMAIN}' 33 | assert r1[0].messenger == 'test_messenger' 34 | assert r1[1].address == f'givi{WONDERLAND_DOMAIN}' 35 | assert r1[1].messenger == 'test_messenger' 36 | assert r1[2].address == f'user_myuser{WONDERLAND_DOMAIN}' 37 | assert r1[2].messenger == 'test_messenger' 38 | 39 | 40 | def test_prepare_undispatched(): 41 | m, d = Message.create('testplain', {MessageBase.SIMPLE_TEXT_ID: 'abc'}) 42 | 43 | Subscription.create('fred', 'testplain', 'test_messenger') 44 | Subscription.create('colon', 'testplain', 'test_messenger') 45 | 46 | dispatches = prepare_dispatches() 47 | assert len(dispatches) == 2 48 | assert dispatches[0].address == 'fred' 49 | assert dispatches[1].address == 'colon' 50 | 51 | 52 | def test_send_scheduled_messages(): 53 | # This one won't count, as won't fit into message priority filter. 54 | schedule_messages( 55 | MessagePlainDynamicForTest('my_dyn_msg'), 56 | recipients('test_messenger', ['three', 'four'])) 57 | 58 | msgr = get_registered_messenger_object('test_messenger') # type: MessengerForTest 59 | msg = MessagePlainForTest('my_message') 60 | schedule_messages(msg, recipients(msgr, ['one', 'two'])) 61 | send_scheduled_messages(priority=MessagePlainForTest.priority) 62 | 63 | assert len(msgr.last_send['dispatch_models']) == 2 64 | assert msgr.last_send['message_model'].cls == 'testplain' 65 | assert msgr.last_send['message_cls'] == MessagePlainForTest 66 | assert msgr.last_send['dispatch_models'][0].message_cache == 'my_message' 67 | assert msgr.last_send['dispatch_models'][1].message_cache == 'my_message' 68 | 69 | 70 | def test_send_scheduled_messages_dynamic_context(): 71 | msgr = get_registered_messenger_object('test_messenger') # type: MessengerForTest 72 | msg_dyn = MessagePlainDynamicForTest('my_dyn_msg') 73 | schedule_messages(msg_dyn, recipients(msgr, ['three', 'four'])) 74 | send_scheduled_messages() 75 | 76 | assert len(msgr.last_send['dispatch_models']) == 2 77 | assert msgr.last_send['message_model'].cls == 'testplain_dyn' 78 | assert msgr.last_send['message_cls'] == MessagePlainDynamicForTest 79 | assert msgr.last_send['dispatch_models'][0].message_cache == f'my_dyn_msg -- three{WONDERLAND_DOMAIN}' 80 | assert msgr.last_send['dispatch_models'][1].message_cache == f'my_dyn_msg -- four{WONDERLAND_DOMAIN}' 81 | 82 | 83 | def test_schedule_message(user): 84 | msg = MessagePlainForTest('schedule_func') 85 | model, _ = schedule_messages(msg)[0] 86 | 87 | assert model.cls == msg.get_alias() 88 | assert model.context == msg.get_context() 89 | assert model.priority == MessagePlainForTest.priority 90 | assert not model.dispatches_ready 91 | 92 | msg = MessagePlainForTest('schedule_func') 93 | model, _ = schedule_messages(msg, priority=33)[0] 94 | 95 | assert model.cls == msg.get_alias() 96 | assert model.context == msg.get_context() 97 | assert model.priority == 33 98 | assert not model.dispatches_ready 99 | 100 | model, dispatch_models = \ 101 | schedule_messages( 102 | 'simple message', 103 | recipients('test_messenger', ['gogi', 'givi']), sender=user)[0] 104 | 105 | assert model.cls == 'plain' 106 | assert model.context == {'use_tpl': False, MessageBase.SIMPLE_TEXT_ID: 'simple message', 'tpl': None} 107 | assert model.sender == user 108 | assert model.dispatches_ready 109 | 110 | assert len(dispatch_models) == 2 111 | assert dispatch_models[0].address == f'gogi{WONDERLAND_DOMAIN}' 112 | assert dispatch_models[0].messenger == 'test_messenger' 113 | 114 | 115 | def test_override_message_type_for_app(): 116 | mt = get_message_type_for_app('myapp', 'testplain') 117 | assert mt is MessagePlainForTest 118 | 119 | override_message_type_for_app('myapp', 'sometype', 'test_message') 120 | mt = get_message_type_for_app('myapp', 'sometype') 121 | assert mt is MessageForTest 122 | -------------------------------------------------------------------------------- /sitemessage/tests/test_views.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.urls import reverse 3 | 4 | from sitemessage.models import Dispatch 5 | from sitemessage.models import Subscription 6 | from sitemessage.signals import ( 7 | sig_unsubscribe_success, 8 | sig_unsubscribe_failed, 9 | sig_mark_read_success, 10 | sig_mark_read_failed, 11 | ) 12 | from sitemessage.toolbox import schedule_messages, recipients 13 | from .testapp.sitemessages import MessagePlainForTest, MessengerForTest 14 | 15 | 16 | class TestViews: 17 | 18 | STATUS_SUCCESS = 'success' 19 | STATUS_FAIL = 'fail' 20 | 21 | @pytest.fixture 22 | def setup(self, user, request_client): 23 | 24 | def catcher_success(*args, **kwargs): 25 | self.status = self.STATUS_SUCCESS 26 | self.catcher_success = catcher_success 27 | 28 | def catcher_fail(*args, **kwargs): 29 | self.status = self.STATUS_FAIL 30 | self.catcher_fail = catcher_fail 31 | 32 | self.user = user 33 | self.request_client = request_client() 34 | 35 | msg_type = MessagePlainForTest('sometext') 36 | self.msg_type = msg_type 37 | 38 | msg_model, _ = schedule_messages(msg_type, recipients(MessengerForTest, user))[0] 39 | dispatch = Dispatch.objects.all()[0] 40 | self.msg_model = msg_model 41 | self.dispatch = dispatch 42 | 43 | dispatch_hash = msg_type.get_dispatch_hash(dispatch.id, msg_model.id) 44 | self.dispatch_hash = dispatch_hash 45 | 46 | def send_request(self, msg_id, dispatch_id, dispatch_hash, expected_status): 47 | self.request_client.get(reverse(self.view_name, args=[msg_id, dispatch_id, dispatch_hash])) 48 | assert self.status == expected_status 49 | self.status = None 50 | 51 | def generic_view_test(self): 52 | # Unknown dispatch ID. 53 | self.send_request(self.msg_model.id, 999999, self.dispatch_hash, self.STATUS_FAIL) 54 | # Invalid hash. 55 | self.send_request(self.msg_model.id, self.dispatch.id, 'nothash', self.STATUS_FAIL) 56 | # Message ID mismatch. 57 | self.send_request(999999, self.dispatch.id, self.dispatch_hash, self.STATUS_FAIL) 58 | 59 | def test_unsubscribe(self, setup): 60 | self.view_name = 'sitemessage_unsubscribe' 61 | 62 | sig_unsubscribe_success.connect(self.catcher_success, weak=False) 63 | sig_unsubscribe_failed.connect(self.catcher_fail, weak=False) 64 | 65 | self.generic_view_test() 66 | 67 | subscr = Subscription( 68 | message_cls=self.msg_type, messenger_cls=MessengerForTest.alias, recipient=self.user 69 | ) 70 | subscr.save() 71 | assert len(Subscription.objects.all()) == 1 72 | 73 | self.send_request(self.msg_model.id, self.dispatch.id, self.dispatch_hash, self.STATUS_SUCCESS) 74 | assert len(Subscription.objects.all()) == 0 75 | 76 | def test_mark_read(self, setup): 77 | 78 | self.view_name = 'sitemessage_mark_read' 79 | 80 | sig_mark_read_success.connect(self.catcher_success, weak=False) 81 | sig_mark_read_failed.connect(self.catcher_fail, weak=False) 82 | 83 | self.generic_view_test() 84 | assert not Dispatch.objects.get(pk=self.dispatch.pk).is_read() 85 | 86 | self.send_request(self.msg_model.id, self.dispatch.id, self.dispatch_hash, self.STATUS_SUCCESS) 87 | assert Dispatch.objects.get(pk=self.dispatch.pk).is_read() 88 | -------------------------------------------------------------------------------- /sitemessage/tests/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-sitemessage/28db11c674d3d3eb59396a9b1e88b8033ff88e20/sitemessage/tests/testapp/__init__.py -------------------------------------------------------------------------------- /sitemessage/tests/testapp/sitemessages.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, patch 2 | 3 | from sitemessage.messages.base import MessageBase 4 | from sitemessage.messages.plain import PlainTextMessage 5 | from sitemessage.messengers.base import MessengerBase 6 | from sitemessage.messengers.facebook import FacebookMessenger 7 | from sitemessage.messengers.smtp import SMTPMessenger 8 | from sitemessage.messengers.telegram import TelegramMessenger 9 | from sitemessage.messengers.twitter import TwitterMessenger 10 | from sitemessage.messengers.vkontakte import VKontakteMessenger 11 | from sitemessage.messengers.xmpp import XMPPSleekMessenger 12 | from sitemessage.toolbox import register_builtin_message_types 13 | from sitemessage.utils import register_message_types 14 | from sitemessage.utils import register_messenger_objects 15 | 16 | 17 | class MockException(Exception): 18 | """This will prevent `catching classes that do not inherit from BaseException is not allowed` errors 19 | when `mock_thirdparty` is used. 20 | 21 | """ 22 | 23 | 24 | def mock_thirdparty(name, func, mock=None): 25 | if mock is None: 26 | mock = MagicMock() 27 | 28 | with patch.dict('sys.modules', {name: mock}): 29 | result = func() 30 | 31 | return result 32 | 33 | 34 | messenger_smtp = mock_thirdparty('smtplib', lambda: SMTPMessenger(login='someone', use_tls=True)) 35 | 36 | messenger_xmpp = mock_thirdparty('sleekxmpp', lambda: XMPPSleekMessenger('somjid', 'somepasswd')) 37 | messenger_xmpp._session_started = True 38 | 39 | messenger_twitter = mock_thirdparty('twitter', lambda: TwitterMessenger('key', 'secret', 'token', 'token_secret')) 40 | messenger_twitter.lib = MagicMock() 41 | 42 | messenger_telegram = mock_thirdparty('requests', lambda: TelegramMessenger( 43 | 'bottoken', proxy={'https': 'socks5://user:pass@host:port'})) 44 | messenger_telegram.lib = MagicMock() 45 | messenger_telegram.lib.exceptions.RequestException = MockException 46 | 47 | messenger_fb = mock_thirdparty('requests', lambda: FacebookMessenger('pagetoken', proxy=lambda: {'https': '0.0.0.0'})) 48 | messenger_fb.lib = MagicMock() 49 | messenger_fb.lib.exceptions.RequestException = MockException 50 | 51 | messenger_vk = mock_thirdparty('requests', lambda: VKontakteMessenger('apptoken')) 52 | messenger_vk.lib = MagicMock() 53 | messenger_vk.lib.exceptions.RequestException = MockException 54 | 55 | register_messenger_objects( 56 | messenger_smtp, 57 | messenger_xmpp, 58 | messenger_twitter, 59 | messenger_telegram, 60 | messenger_fb, 61 | messenger_vk, 62 | ) 63 | 64 | register_builtin_message_types() 65 | 66 | 67 | WONDERLAND_DOMAIN = '@wonderland' 68 | 69 | 70 | class MessengerForTest(MessengerBase): 71 | 72 | title = 'Test messenger' 73 | alias = 'test_messenger' 74 | last_send = None 75 | 76 | def __init__(self, login, password): 77 | self.login = login 78 | self.password = password 79 | 80 | def _test_message(self, to, text): 81 | return f'triggered send to `{to}`' 82 | 83 | @classmethod 84 | def get_address(cls, recipient): 85 | from django.contrib.auth.models import User 86 | 87 | if isinstance(recipient, User): 88 | recipient = f'user_{recipient.username}' 89 | 90 | return f'{recipient}{WONDERLAND_DOMAIN}' 91 | 92 | def send(self, message_cls, message_model, dispatch_models): 93 | self.last_send = { 94 | 'message_cls': message_cls, 95 | 'dispatch_models': dispatch_models, 96 | 'message_model': message_model, 97 | } 98 | 99 | 100 | class BuggyMessenger(MessengerBase): 101 | 102 | title = 'Buggy messenger' 103 | alias = 'buggy' 104 | 105 | def send(self, message_cls, message_model, dispatch_models): 106 | raise Exception('Damn it.') 107 | 108 | 109 | class MessageForTest(MessageBase): 110 | 111 | alias = 'test_message' 112 | template_ext = 'html' 113 | title = 'Test message type' 114 | supported_messengers = ['smtp', 'test_messenger'] 115 | 116 | 117 | class MessagePlainForTest(PlainTextMessage): 118 | 119 | alias = 'testplain' 120 | priority = 10 121 | 122 | 123 | class MessageGroupedForTest(PlainTextMessage): 124 | 125 | alias = 'testgroupped' 126 | group_mark = 'groupme' 127 | 128 | 129 | class MessagePlainDynamicForTest(PlainTextMessage): 130 | 131 | alias = 'testplain_dyn' 132 | has_dynamic_context = True 133 | 134 | @classmethod 135 | def compile(cls, message, messenger, dispatch=None): 136 | return f'{message.context[MessageBase.SIMPLE_TEXT_ID]} -- {dispatch.address}' 137 | 138 | 139 | register_messenger_objects( 140 | MessengerForTest('mylogin', 'mypassword'), 141 | BuggyMessenger(), 142 | ) 143 | 144 | register_message_types( 145 | PlainTextMessage, 146 | MessageForTest, 147 | MessageGroupedForTest, 148 | MessagePlainForTest, 149 | MessagePlainDynamicForTest, 150 | ) 151 | -------------------------------------------------------------------------------- /sitemessage/tests/testapp/urls.py: -------------------------------------------------------------------------------- 1 | from sitemessage.toolbox import get_sitemessage_urls 2 | 3 | 4 | urlpatterns = get_sitemessage_urls() 5 | -------------------------------------------------------------------------------- /sitemessage/toolbox.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from datetime import timedelta 3 | from itertools import chain 4 | from operator import itemgetter 5 | from typing import Optional, List, Tuple, Union, Iterable, Any, Callable, Dict, Mapping 6 | 7 | from django.conf import settings 8 | from django.contrib.auth.base_user import AbstractBaseUser 9 | from django.http import HttpRequest 10 | from django.urls import re_path 11 | from django.utils import timezone 12 | from django.utils.translation import gettext as _ 13 | 14 | from .exceptions import UnknownMessengerError, UnknownMessageTypeError 15 | # NB: Some of these unused imports are exposed as part of toolbox API. 16 | from .messages import register_builtin_message_types # noqa 17 | from .messages.base import MessageBase 18 | from .messages.plain import PlainTextMessage 19 | from .models import Message, Dispatch, Subscription, MessageTuple 20 | from .utils import ( # noqa 21 | is_iterable, import_project_sitemessage_modules, get_site_url, recipients, 22 | register_messenger_objects, get_registered_messenger_object, get_registered_messenger_objects, 23 | register_message_types, get_registered_message_type, get_registered_message_types, 24 | get_message_type_for_app, override_message_type_for_app, Recipient, TypeUser 25 | ) 26 | from .views import mark_read, unsubscribe 27 | 28 | _ALIAS_SEP = '|' 29 | _PREF_POST_KEY = 'sm_user_pref' 30 | 31 | TypeMessages = Union[str, MessageBase, List[Union[str, MessageBase]]] 32 | 33 | 34 | def schedule_messages( 35 | messages: TypeMessages, 36 | recipients: Union[Iterable[Recipient], Recipient] = None, 37 | sender: TypeUser = None, 38 | priority: Optional[int] = None 39 | ) -> List[MessageTuple]: 40 | """Schedules a message or messages. 41 | 42 | :param messages: str or MessageBase heir or list - use str to create PlainTextMessage. 43 | 44 | :param recipients: recipients addresses or Django User model heir instances 45 | If `None` Dispatches should be created before send using `prepare_dispatches()`. 46 | 47 | :param sender: User model heir instance 48 | 49 | :param priority: number describing message priority. If set overrides priority provided with message type. 50 | 51 | """ 52 | if not is_iterable(messages): 53 | messages = (messages,) 54 | 55 | results = [] 56 | for message in messages: 57 | if isinstance(message, str): 58 | message = PlainTextMessage(message) 59 | 60 | resulting_priority = message.priority 61 | if priority is not None: 62 | resulting_priority = priority 63 | 64 | results.append(message.schedule( 65 | sender=sender, 66 | recipients=recipients, 67 | priority=resulting_priority, 68 | )) 69 | 70 | return results 71 | 72 | 73 | def send_scheduled_messages( 74 | priority: Optional[int] = None, 75 | ignore_unknown_messengers: bool = False, 76 | ignore_unknown_message_types: bool = False 77 | ): 78 | """Sends scheduled messages. 79 | 80 | :param priority: number to limit sending message by this priority. 81 | 82 | :param ignore_unknown_messengers: to silence UnknownMessengerError 83 | 84 | :param ignore_unknown_message_types: to silence UnknownMessageTypeError 85 | 86 | :raises UnknownMessengerError: 87 | :raises UnknownMessageTypeError: 88 | 89 | """ 90 | dispatches_by_messengers = Dispatch.group_by_messengers(Dispatch.get_unsent(priority=priority)) 91 | 92 | for messenger_id, messages in dispatches_by_messengers.items(): 93 | try: 94 | messenger_obj = get_registered_messenger_object(messenger_id) 95 | messenger_obj.process_messages(messages, ignore_unknown_message_types=ignore_unknown_message_types) 96 | 97 | except UnknownMessengerError: 98 | if ignore_unknown_messengers: 99 | continue 100 | raise 101 | 102 | 103 | def send_test_message(messenger_id: str, to: Optional[str] = None) -> Any: 104 | """Sends a test message using the given messenger. 105 | 106 | :param messenger_id: Messenger alias. 107 | :param to: Recipient address (if applicable). 108 | 109 | """ 110 | messenger_obj = get_registered_messenger_object(messenger_id) 111 | return messenger_obj.send_test_message(to=to, text='Test message from sitemessages.') 112 | 113 | 114 | def check_undelivered(to: Optional[str] = None) -> int: 115 | """Sends a notification email if any undelivered dispatches. 116 | 117 | Returns undelivered (failed) dispatches count. 118 | 119 | :param to: Recipient address. If not set Django ADMINS setting is used. 120 | 121 | """ 122 | failed_count = Dispatch.objects.filter(dispatch_status=Dispatch.DISPATCH_STATUS_FAILED).count() 123 | 124 | if failed_count: 125 | from sitemessage.shortcuts import schedule_email 126 | from sitemessage.messages.email import EmailTextMessage 127 | 128 | if to is None: 129 | admins = settings.ADMINS 130 | 131 | if admins: 132 | to = list(dict(admins).values()) 133 | 134 | if to: 135 | priority = 999 136 | 137 | register_message_types(EmailTextMessage) 138 | 139 | schedule_email( 140 | _('You have %(count)s undelivered dispatch(es) at %(url)s') % { 141 | 'count': failed_count, 142 | 'url': get_site_url(), 143 | }, 144 | subject=_('[SITEMESSAGE] Undelivered dispatches'), 145 | to=to, priority=priority) 146 | 147 | send_scheduled_messages(priority=priority) 148 | 149 | return failed_count 150 | 151 | 152 | def cleanup_sent_messages(ago: Optional[int] = None, dispatches_only: bool = False): 153 | """Cleans up DB : removes delivered dispatches (and messages). 154 | 155 | :param ago: Days. Allows cleanup messages sent X days ago. Defaults to None (cleanup all sent). 156 | :param dispatches_only: Remove dispatches only (messages objects will stay intact). 157 | 158 | """ 159 | filter_kwargs = { 160 | 'dispatch_status': Dispatch.DISPATCH_STATUS_SENT, 161 | } 162 | 163 | objects = Dispatch.objects 164 | 165 | if ago: 166 | filter_kwargs['time_dispatched__lte'] = timezone.now() - timedelta(days=int(ago)) 167 | 168 | dispatch_map = dict(objects.filter(**filter_kwargs).values_list('pk', 'message_id')) 169 | 170 | # Remove dispatches 171 | objects.filter(pk__in=list(dispatch_map.keys())).delete() 172 | 173 | if not dispatches_only: 174 | # Remove messages also. 175 | messages_ids = set(dispatch_map.values()) 176 | 177 | if messages_ids: 178 | messages_blocked = set(chain.from_iterable( 179 | objects.filter(message_id__in=messages_ids).values_list('message_id'))) 180 | 181 | messages_stale = messages_ids.difference(messages_blocked) 182 | 183 | if messages_stale: 184 | Message.objects.filter(pk__in=messages_stale).delete() 185 | 186 | 187 | def prepare_dispatches() -> List[Dispatch]: 188 | """Automatically creates dispatches for messages without them.""" 189 | 190 | dispatches = [] 191 | target_messages = Message.get_without_dispatches() 192 | 193 | cache = {} 194 | 195 | for message_model in target_messages: 196 | 197 | if message_model.cls not in cache: 198 | message_cls = get_registered_message_type(message_model.cls) 199 | subscribers = message_cls.get_subscribers() 200 | cache[message_model.cls] = (message_cls, subscribers) 201 | else: 202 | message_cls, subscribers = cache[message_model.cls] 203 | 204 | dispatches.extend(message_cls.prepare_dispatches(message_model)) 205 | 206 | return dispatches 207 | 208 | 209 | def get_user_preferences_for_ui( 210 | user: AbstractBaseUser, 211 | message_filter: Optional[Callable] = None, 212 | messenger_filter: Optional[Callable] = None, 213 | new_messengers_titles: Optional[Dict[str, str]] = None 214 | ) -> Tuple[List[str], Mapping]: 215 | """Returns a two element tuple with user subscription preferences to render in UI. 216 | 217 | Message types with the same titles are merged into one row. 218 | 219 | First element: 220 | A list of messengers titles. 221 | 222 | Second element: 223 | User preferences dictionary indexed by message type titles. 224 | Preferences (dictionary values) are lists of tuples: 225 | (preference_alias, is_supported_by_messenger_flag, user_subscribed_flag) 226 | 227 | Example: 228 | {'My message type': [('test_message|smtp', True, False), ...]} 229 | 230 | :param user: 231 | 232 | :param message_filter: A callable accepting a message object to filter out message types 233 | 234 | :param messenger_filter: A callable accepting a messenger object to filter out messengers 235 | 236 | :param new_messengers_titles: Mapping of messenger aliases to a new titles. 237 | 238 | """ 239 | if new_messengers_titles is None: 240 | new_messengers_titles = {} 241 | 242 | msgr_to_msg = defaultdict(set) 243 | msg_titles = {} 244 | msgr_titles = {} 245 | 246 | for msgr in get_registered_messenger_objects().values(): 247 | if not (messenger_filter is None or messenger_filter(msgr)) or not msgr.allow_user_subscription: 248 | continue 249 | 250 | msgr_alias = msgr.alias 251 | msgr_title = new_messengers_titles.get(msgr.alias) or msgr.title 252 | 253 | for msg in get_registered_message_types().values(): 254 | if not (message_filter is None or message_filter(msg)) or not msg.allow_user_subscription: 255 | continue 256 | 257 | msgr_supported = msg.supported_messengers 258 | is_supported = (not msgr_supported or msgr.alias in msgr_supported) 259 | 260 | if not is_supported: 261 | continue 262 | 263 | msg_alias = msg.alias 264 | msg_titles.setdefault(f'{msg.title}', []).append(msg_alias) 265 | 266 | msgr_to_msg[msgr_alias].update((msg_alias,)) 267 | msgr_titles[msgr_title] = msgr_alias 268 | 269 | def sort_titles(titles): 270 | return dict(sorted([(k, v) for k, v in titles.items()], key=itemgetter(0))) 271 | 272 | msgr_titles = sort_titles(msgr_titles) 273 | 274 | user_prefs = {} 275 | 276 | user_subscriptions = [ 277 | f'{pref.message_cls}{_ALIAS_SEP}{pref.messenger_cls}' 278 | for pref in Subscription.get_for_user(user)] 279 | 280 | for msg_title, msg_aliases in sort_titles(msg_titles).items(): 281 | 282 | for __, msgr_alias in msgr_titles.items(): 283 | msg_candidates = msgr_to_msg[msgr_alias].intersection(msg_aliases) 284 | 285 | alias = '' 286 | msg_supported = False 287 | subscribed = False 288 | 289 | if msg_candidates: 290 | alias = f'{msg_candidates.pop()}{_ALIAS_SEP}{msgr_alias}' 291 | msg_supported = True 292 | subscribed = alias in user_subscriptions 293 | 294 | user_prefs.setdefault(msg_title, []).append((alias, msg_supported, subscribed)) 295 | 296 | return list(msgr_titles.keys()), user_prefs 297 | 298 | 299 | def set_user_preferences_from_request(request: HttpRequest) -> bool: 300 | """Sets user subscription preferences using data from a request. 301 | 302 | Expects data sent by form built with `sitemessage_prefs_table` template tag. 303 | Returns a flag, whether prefs were found in the request. 304 | 305 | :param request: 306 | 307 | """ 308 | prefs = [] 309 | 310 | for pref in request.POST.getlist(_PREF_POST_KEY): 311 | message_alias, messenger_alias = pref.split(_ALIAS_SEP) 312 | 313 | try: 314 | get_registered_message_type(message_alias) 315 | get_registered_messenger_object(messenger_alias) 316 | 317 | except (UnknownMessengerError, UnknownMessageTypeError): 318 | pass 319 | 320 | else: 321 | prefs.append((message_alias, messenger_alias)) 322 | 323 | Subscription.replace_for_user(request.user, prefs) 324 | 325 | return bool(prefs) 326 | 327 | 328 | def get_sitemessage_urls() -> List: 329 | """Returns sitemessage urlpatterns, that can be attached to urlpatterns of a project: 330 | 331 | # Example from urls.py. 332 | 333 | from sitemessage.toolbox import get_sitemessage_urls 334 | 335 | urlpatterns = patterns('', 336 | # Your URL Patterns belongs here. 337 | 338 | ) + get_sitemessage_urls() # Now attaching additional URLs. 339 | 340 | """ 341 | url_unsubscribe = re_path( 342 | r'^messages/unsubscribe/(?P\d+)/(?P\d+)/(?P[^/]+)/$', 343 | unsubscribe, 344 | name='sitemessage_unsubscribe' 345 | ) 346 | 347 | url_mark_read = re_path( 348 | r'^messages/ping/(?P\d+)/(?P\d+)/(?P[^/]+)/$', 349 | mark_read, 350 | name='sitemessage_mark_read' 351 | ) 352 | 353 | return [url_unsubscribe, url_mark_read] 354 | -------------------------------------------------------------------------------- /sitemessage/utils.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from threading import local 3 | from typing import Union, List, Type, Dict, NamedTuple 4 | 5 | from django.contrib.auth.base_user import AbstractBaseUser 6 | from etc.toolbox import get_site_url as get_site_url_, import_app_module, import_project_modules 7 | 8 | from .exceptions import UnknownMessageTypeError, UnknownMessengerError 9 | from .settings import APP_MODULE_NAME, SITE_URL 10 | 11 | if False: # pragma: nocover 12 | from .messages.base import MessageBase # noqa 13 | from .messengers.base import MessengerBase 14 | 15 | TypeUser = AbstractBaseUser 16 | TypeRecipient = Union[int, str, TypeUser] 17 | TypeRecipients = Union[TypeRecipient, List[TypeRecipient]] 18 | TypeMessage = Union[str, Type['MessageBase']] 19 | TypeMessenger = Union[str, Type['MessengerBase']] 20 | 21 | _MESSENGERS_REGISTRY: Dict[str, 'MessengerBase'] = {} 22 | _MESSAGES_REGISTRY: Dict[str, Type['MessageBase']] = {} 23 | 24 | _MESSAGES_FOR_APPS: Dict[str, Dict[str, str]] = defaultdict(dict) 25 | 26 | _THREAD_LOCAL = local() 27 | _THREAD_SITE_URL = 'sitemessage_site_url' 28 | 29 | 30 | def get_site_url() -> str: 31 | """Returns a URL for current site.""" 32 | 33 | site_url = getattr(_THREAD_LOCAL, _THREAD_SITE_URL, None) 34 | 35 | if site_url is None: 36 | site_url = SITE_URL or get_site_url_() 37 | setattr(_THREAD_LOCAL, _THREAD_SITE_URL, site_url) 38 | 39 | return site_url 40 | 41 | 42 | def get_message_type_for_app(app_name: str, default_message_type_alias: str) -> Type['MessageBase']: 43 | """Returns a registered message type object for a given application. 44 | 45 | Supposed to be used by reusable applications authors, 46 | to get message type objects which may be overridden by project authors 47 | using `override_message_type_for_app`. 48 | 49 | :param app_name: 50 | :param default_message_type_alias: 51 | 52 | """ 53 | message_type = default_message_type_alias 54 | try: 55 | message_type = _MESSAGES_FOR_APPS[app_name][message_type] 56 | except KeyError: 57 | pass 58 | return get_registered_message_type(message_type) 59 | 60 | 61 | def override_message_type_for_app(app_name: str, app_message_type_alias: str, new_message_type_alias: str): 62 | """Overrides a given message type used by a certain application with another one. 63 | 64 | Intended for projects authors, who need to customize messaging behaviour 65 | of a certain thirdparty app (supporting this feature). 66 | To be used in conjunction with `get_message_type_for_app`. 67 | 68 | :param app_name: 69 | :param app_message_type_alias: 70 | :param new_message_type_alias: 71 | 72 | """ 73 | global _MESSAGES_FOR_APPS 74 | 75 | _MESSAGES_FOR_APPS[app_name][app_message_type_alias] = new_message_type_alias 76 | 77 | 78 | def register_messenger_objects(*messengers: 'MessengerBase'): 79 | """Registers (configures) messengers. 80 | 81 | :param messengers: MessengerBase heirs instances. 82 | 83 | """ 84 | global _MESSENGERS_REGISTRY 85 | 86 | for messenger in messengers: 87 | _MESSENGERS_REGISTRY[messenger.get_alias()] = messenger 88 | 89 | 90 | def get_registered_messenger_objects() -> Dict[str, 'MessengerBase']: 91 | """Returns registered (configured) messengers dict 92 | indexed by messenger aliases. 93 | 94 | """ 95 | return _MESSENGERS_REGISTRY 96 | 97 | 98 | def get_registered_messenger_object(messenger: str) -> 'MessengerBase': 99 | """Returns registered (configured) messenger by alias, 100 | 101 | :param messenger: messenger alias 102 | 103 | """ 104 | try: 105 | return _MESSENGERS_REGISTRY[messenger] 106 | 107 | except KeyError: 108 | raise UnknownMessengerError(f'`{messenger}` messenger is not registered') 109 | 110 | 111 | def register_message_types(*message_types: Type['MessageBase']): 112 | """Registers message types (classes). 113 | 114 | :param message_types: MessageBase heir classes. 115 | 116 | """ 117 | global _MESSAGES_REGISTRY 118 | 119 | for message in message_types: 120 | _MESSAGES_REGISTRY[message.get_alias()] = message 121 | 122 | 123 | def get_registered_message_types() -> Dict[str, Type['MessageBase']]: 124 | """Returns registered message types dict indexed by their aliases.""" 125 | return _MESSAGES_REGISTRY 126 | 127 | 128 | def get_registered_message_type(message_type: str) -> Type['MessageBase']: 129 | """Returns registered message type (class) by alias, 130 | 131 | :param message_type: message type alias 132 | 133 | """ 134 | try: 135 | return _MESSAGES_REGISTRY[message_type] 136 | 137 | except KeyError: 138 | raise UnknownMessageTypeError(f'`{message_type}` message class is not registered') 139 | 140 | 141 | def import_app_sitemessage_module(app: str): 142 | """Returns a submodule of a given app or None. 143 | 144 | :param app: application name 145 | 146 | :rtype: module or None 147 | 148 | """ 149 | return import_app_module(app, APP_MODULE_NAME) 150 | 151 | 152 | def import_project_sitemessage_modules(): 153 | """Imports sitemessages modules from registered apps.""" 154 | return import_project_modules(APP_MODULE_NAME) 155 | 156 | 157 | def is_iterable(v): 158 | """Tells whether the thing is an iterable. 159 | NB: strings do not count even on Py3. 160 | 161 | """ 162 | return hasattr(v, '__iter__') and not isinstance(v, str) 163 | 164 | 165 | class Recipient(NamedTuple): 166 | """Class is used to represent a message recipient.""" 167 | 168 | messenger: str 169 | """Messenger alias.""" 170 | 171 | user: TypeUser 172 | """User object.""" 173 | 174 | address: str 175 | """Recipient address.""" 176 | 177 | 178 | def recipients(messenger: Union[str, 'MessengerBase'], addresses: TypeRecipients) -> List[Recipient]: 179 | """Structures recipients data. 180 | 181 | Returns Recipient objects. 182 | 183 | :param messenger: MessengerBase heir 184 | 185 | :param addresses: recipients addresses or Django User 186 | model heir instances (NOTE: if supported by a messenger) 187 | 188 | """ 189 | if isinstance(messenger, str): 190 | messenger = get_registered_messenger_object(messenger) 191 | 192 | return messenger.structure_recipients_data(addresses) 193 | -------------------------------------------------------------------------------- /sitemessage/views.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | from django.http import HttpRequest, HttpResponse 3 | from django.shortcuts import redirect 4 | from django.templatetags.static import static as get_static_url 5 | 6 | from .exceptions import UnknownMessageTypeError 7 | from .models import Dispatch 8 | from .signals import sig_unsubscribe_failed, sig_mark_read_failed 9 | 10 | 11 | def _generic_view( 12 | message_method: str, 13 | fail_signal: Signal, 14 | request: HttpRequest, 15 | message_id: int, 16 | dispatch_id: int, 17 | hashed: str, 18 | redirect_to: str = None 19 | ): 20 | 21 | if redirect_to is None: 22 | redirect_to = '/' 23 | 24 | try: 25 | dispatch = Dispatch.objects.select_related('message').get(pk=dispatch_id) 26 | 27 | if int(message_id) != dispatch.message_id: 28 | raise ValueError() 29 | 30 | message = dispatch.message 31 | 32 | except (Dispatch.DoesNotExist, ValueError): 33 | pass 34 | 35 | else: 36 | 37 | try: 38 | message_type = message.get_type() 39 | expected_hash = message_type.get_dispatch_hash(dispatch_id, message_id) 40 | 41 | method = getattr(message_type, message_method) 42 | 43 | return method( 44 | request, message, dispatch, 45 | hash_is_valid=(expected_hash == hashed), 46 | redirect_to=redirect_to 47 | ) 48 | 49 | except UnknownMessageTypeError: 50 | pass 51 | 52 | fail_signal.send(None, request=request, message=message_id, dispatch=dispatch_id) 53 | 54 | return redirect(redirect_to) 55 | 56 | 57 | def unsubscribe( 58 | request: HttpRequest, 59 | message_id: int, 60 | dispatch_id: int, 61 | hashed: str, 62 | redirect_to: str = None 63 | ) -> HttpResponse: 64 | """Handles an unsubscribe request. 65 | 66 | :param request: 67 | :param message_id: 68 | :param dispatch_id: 69 | :param hashed: 70 | :param redirect_to: 71 | 72 | """ 73 | return _generic_view( 74 | 'handle_unsubscribe_request', sig_unsubscribe_failed, 75 | request, message_id, dispatch_id, hashed, redirect_to=redirect_to 76 | ) 77 | 78 | 79 | def mark_read( 80 | request: HttpRequest, 81 | message_id: int, 82 | dispatch_id: int, 83 | hashed: str, 84 | redirect_to: str = None 85 | ) -> HttpResponse: 86 | """Handles mark message as read request. 87 | 88 | :param request: 89 | :param message_id: 90 | :param dispatch_id: 91 | :param hashed: 92 | :param redirect_to: 93 | 94 | """ 95 | if redirect_to is None: 96 | redirect_to = get_static_url('img/sitemessage/blank.png') 97 | 98 | return _generic_view( 99 | 'handle_mark_read_request', sig_mark_read_failed, 100 | request, message_id, dispatch_id, hashed, redirect_to=redirect_to 101 | ) 102 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{37,38,39,310}-django{20,21,22,30,31,32,40,41} 4 | 5 | install_command = pip install {opts} {packages} 6 | skip_missing_interpreters = True 7 | 8 | [testenv] 9 | commands = python setup.py test 10 | 11 | deps = 12 | django20: Django>=2.0,<2.1 13 | django21: Django>=2.1,<2.2 14 | django22: Django>=2.2,<2.3 15 | django30: Django>=3.0,<3.1 16 | django31: Django>=3.1,<3.2 17 | django32: Django>=3.2,<3.3 18 | django40: Django>=4.0,<4.1 19 | django41: Django>=4.1,<4.2 20 | --------------------------------------------------------------------------------