├── .circleci └── config.yml ├── .editorconfig ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CODE_OF_CONDUCT.rst ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.rst ├── SUPPORT.md ├── TODO.md ├── django_nyt ├── __init__.py ├── admin.py ├── apps.py ├── conf.py ├── consumers.py ├── decorators.py ├── forms.py ├── locale │ ├── da │ │ └── LC_MESSAGES │ │ │ └── django.po │ ├── de │ │ └── LC_MESSAGES │ │ │ └── django.po │ ├── fi │ │ └── LC_MESSAGES │ │ │ └── django.po │ └── ru │ │ └── LC_MESSAGES │ │ └── django.po ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── notifymail.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_notification_settings.py │ ├── 0003_subscription.py │ ├── 0004_notification_subscription.py │ ├── 0005__v_0_9_2.py │ ├── 0006_auto_20141229_1630.py │ ├── 0007_add_modified_and_default_settings.py │ ├── 0008_auto_20161023_1641.py │ ├── 0009_alter_notification_subscription_and_more.py │ ├── 0010_settings_created_settings_modified_and_more.py │ └── __init__.py ├── models.py ├── routing.py ├── subscribers.py ├── templates │ └── notifications │ │ └── emails │ │ ├── default.txt │ │ └── default_subject.txt ├── urls.py ├── utils.py └── views.py ├── docs ├── Makefile ├── conf.py ├── howto │ ├── channels.rst │ ├── emails.rst │ ├── index.rst │ ├── javascript.rst │ └── object_relations.rst ├── index.rst ├── installation.rst ├── make.bat ├── misc │ └── screenshot_dropdown.png ├── reference │ ├── configuration.rst │ └── index.rst ├── release_notes.rst └── usage.rst ├── pyproject.toml ├── test-project ├── django_nyt ├── manage.py ├── prepopulated.db ├── requirements.txt ├── testproject │ ├── __init__.py │ ├── settings │ │ ├── __init__.py │ │ ├── local.py │ │ └── travis.py │ ├── urls.py │ └── wsgi.py └── tests └── tests ├── __init__.py ├── core ├── __init__.py ├── test_basic.py ├── test_email.py ├── test_management.py └── test_views.py ├── settings.py └── testapp ├── __init__.py ├── admin.py ├── forms.py ├── migrations ├── 0001_initial.py ├── 0002_createtestusers.py └── __init__.py ├── models.py ├── static └── testapp │ ├── css │ └── custom.css │ ├── js │ └── jquery.js │ └── skeleton │ ├── css │ ├── normalize.css │ └── skeleton.css │ ├── images │ └── favicon.png │ └── index.html ├── templates └── testapp │ ├── index.html │ └── notifications │ ├── admin.txt │ ├── admin_subject.txt │ ├── email.txt │ └── email_subject.txt ├── tests.py ├── urls.py └── views.py /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | python: circleci/python@0.2.1 5 | codecov: codecov/codecov@3.2.5 6 | 7 | jobs: 8 | hatch: 9 | parameters: 10 | hatch_env: 11 | description: "Name of Hatch environment to run" 12 | default: "py3.8-dj3.2" 13 | type: string 14 | python_version: 15 | description: "Python version string" 16 | default: "3.8" 17 | type: string 18 | description: "Reusable job for invoking hatch" 19 | docker: 20 | - image: cimg/python:<> 21 | steps: 22 | - checkout 23 | - run: pip install coverage[toml] hatch 24 | - run: python -m hatch --env test.<> run all 25 | - codecov/upload 26 | lint: 27 | description: "Simple job for linting the pushed code" 28 | docker: 29 | - image: cimg/python:3.9 30 | steps: 31 | - checkout 32 | - run: pip install hatch && python -m hatch --env test.py3.9-dj4.0 run lint 33 | 34 | workflows: 35 | main: 36 | jobs: 37 | - hatch: 38 | hatch_env: "py3.8-dj2.2" 39 | python_version: "3.8" 40 | - hatch: 41 | hatch_env: "py3.8-dj3.0" 42 | python_version: "3.8" 43 | - hatch: 44 | hatch_env: "py3.8-dj3.1" 45 | - hatch: 46 | hatch_env: "py3.8-dj3.2" 47 | python_version: "3.8" 48 | - hatch: 49 | hatch_env: "py3.8-dj4.0" 50 | python_version: "3.8" 51 | - hatch: 52 | hatch_env: "py3.8-dj4.1" 53 | python_version: "3.8" 54 | - hatch: 55 | hatch_env: "py3.8-dj4.2" 56 | python_version: "3.8" 57 | - hatch: 58 | hatch_env: "py3.9-dj2.2" 59 | python_version: "3.9" 60 | - hatch: 61 | hatch_env: "py3.9-dj3.0" 62 | python_version: "3.9" 63 | - hatch: 64 | hatch_env: "py3.9-dj3.1" 65 | python_version: "3.9" 66 | - hatch: 67 | hatch_env: "py3.9-dj3.2" 68 | python_version: "3.9" 69 | - hatch: 70 | hatch_env: "py3.9-dj4.0" 71 | python_version: "3.9" 72 | - hatch: 73 | hatch_env: "py3.9-dj4.1" 74 | python_version: "3.9" 75 | - hatch: 76 | hatch_env: "py3.9-dj4.2" 77 | python_version: "3.9" 78 | - hatch: 79 | hatch_env: "py3.10-dj3.2" 80 | python_version: "3.10" 81 | - hatch: 82 | hatch_env: "py3.10-dj4.0" 83 | python_version: "3.10" 84 | - hatch: 85 | hatch_env: "py3.10-dj4.1" 86 | python_version: "3.10" 87 | - hatch: 88 | hatch_env: "py3.10-dj4.2" 89 | python_version: "3.10" 90 | - hatch: 91 | hatch_env: "py3.10-dj5.0" 92 | python_version: "3.10" 93 | - hatch: 94 | hatch_env: "py3.10-dj5.1" 95 | python_version: "3.10" 96 | - hatch: 97 | hatch_env: "py3.10-dj5.2" 98 | python_version: "3.10" 99 | - hatch: 100 | hatch_env: "py3.11-dj4.1" 101 | python_version: "3.11" 102 | - hatch: 103 | hatch_env: "py3.11-dj4.2" 104 | python_version: "3.11" 105 | - hatch: 106 | hatch_env: "py3.11-dj5.0" 107 | python_version: "3.11" 108 | - hatch: 109 | hatch_env: "py3.11-dj5.1" 110 | python_version: "3.11" 111 | - hatch: 112 | hatch_env: "py3.12-dj5.0" 113 | python_version: "3.12" 114 | - hatch: 115 | hatch_env: "py3.12-dj5.1" 116 | python_version: "3.12" 117 | - hatch: 118 | hatch_env: "py3.12-dj5.2" 119 | python_version: "3.12" 120 | - hatch: 121 | hatch_env: "py3.13-dj5.0" 122 | python_version: "3.13" 123 | - hatch: 124 | hatch_env: "py3.13-dj5.1" 125 | python_version: "3.13" 126 | - hatch: 127 | hatch_env: "py3.13-dj5.2" 128 | python_version: "3.13" 129 | - lint 130 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | max_line_length = 160 13 | max_line_wrap = false 14 | 15 | [*.py] 16 | max_line_length = 160 17 | quote_type = double 18 | 19 | [*.{scss,js,html}] 20 | indent_style = space 21 | indent_size = 2 22 | quote_type = double 23 | 24 | [*.js] 25 | indent_size = 2 26 | quote_type = double 27 | curly_bracket_next_line = false 28 | 29 | [**.min.{js,css}] 30 | indent_style = ignore 31 | insert_final_newline = ignore 32 | 33 | [Makefile] 34 | indent_style = tab 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # Tox 4 | .cache 5 | coverage.xml 6 | 7 | # C extensions 8 | *.so 9 | 10 | docs/_build 11 | docs/_static 12 | docs/django_nyt*rst 13 | docs/modules.rst 14 | 15 | test-project/testproject/prepopulated.db 16 | 17 | # Packages 18 | *.egg 19 | *.egg-info 20 | dist 21 | build 22 | eggs 23 | parts 24 | bin 25 | var 26 | sdist 27 | develop-eggs 28 | .installed.cfg 29 | lib 30 | lib64 31 | __pycache__ 32 | 33 | # Installer logs 34 | pip-log.txt 35 | 36 | # Unit test / coverage reports 37 | .coverage 38 | .tox 39 | nosetests.xml 40 | coverage.xml 41 | 42 | # Translations 43 | *.mo 44 | 45 | # Mr Developer 46 | .idea 47 | .mr.developer.cfg 48 | .project 49 | .pydevproject 50 | .venv 51 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/PyCQA/flake8 3 | rev: '5.0.4' # pick a git hash / tag to point to 4 | hooks: 5 | - id: flake8 6 | args: ["--max-line-length=213", "--extend-ignore=E203", "--max-complexity=10"] 7 | exclude: "^(.*/migrations/|testproject/testproject/settings/)" 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: v4.3.0 10 | hooks: 11 | - id: trailing-whitespace 12 | exclude: | 13 | (?x)^( 14 | .tx/| 15 | test-project/testapp/static/testapp/js/.* 16 | test-project/testapp/static/testapp/css/.* 17 | )$ 18 | - id: check-added-large-files 19 | - id: debug-statements 20 | - id: end-of-file-fixer 21 | exclude: | 22 | (?x)^( 23 | .tx/| 24 | test-project/testapp/static/testapp/js/.* 25 | test-project/testapp/static/testapp/css/.* 26 | .*\.map 27 | )$ 28 | - repo: https://github.com/psf/black 29 | rev: 22.10.0 30 | hooks: 31 | - id: black 32 | language_version: python3 33 | 34 | - repo: https://github.com/asottile/reorder_python_imports 35 | rev: v3.9.0 36 | hooks: 37 | - id: reorder-python-imports 38 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-20.04 4 | tools: 5 | python: "3.11" 6 | sphinx: 7 | configuration: docs/conf.py 8 | python: 9 | install: 10 | - method: pip 11 | path: . 12 | extra_requirements: 13 | - docs 14 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.rst: -------------------------------------------------------------------------------- 1 | Code of Conduct 2 | =============== 3 | 4 | Please refer to the 5 | `Code of Conduct of django-wiki `__ 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | If you are a developer, please refer to the [Developer Guide of 5 | django-wiki](http://django-wiki.readthedocs.io/en/latest/development/index.html) 6 | 7 | Support 8 | ------- 9 | 10 | **DO NOT USE GITHUB FOR SUPPORT INQUIRIES! USE IRC OR MAILING LIST!** 11 | 12 | Django-nyt is community based, please try to be active. If you want 13 | help, plan to give help, too. For instance, if you join IRC, then stay 14 | around and help others. 15 | 16 | Issues 17 | ------ 18 | 19 | Contributions are appreciated! The following guide is a rough draft, but 20 | please feel free to contribute to this contribution doc as well :D 21 | 22 | When submitting an Issue, please provide the following: 23 | 24 | - If it's a **feature request**, then write why *you* want it, but 25 | also which other cases you find it useful for. Best way to get a new 26 | feature made by others is to motivate. 27 | - Think about challenges. 28 | - Have you read the Manifesto (below) ? New features should maintain 29 | the focus of the project. 30 | - If you encounter a **bug**, keep in mind that it's probably easiest 31 | to fix if a developer sat in front of your computer... but in lack 32 | of that option: 33 | - `django-admin.py --version` 34 | - `python --version` 35 | - `uname -a` 36 | - An example of how to reproduce the bug. 37 | - The expected result. 38 | - Does the bug happen with a checkout of django-wiki's master branch? 39 | To upgrade: 40 | `pip install --upgrade git+https://github.com/django-wiki/django-wiki.git` 41 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help clean clean-pyc clean-build list test test-all coverage docs release sdist 2 | 3 | help: 4 | @echo "clean-build - remove build artifacts" 5 | @echo "clean-pyc - remove Python file artifacts" 6 | @echo "lint - check style with flake8" 7 | @echo "test - run tests quickly with the default Python" 8 | @echo "testall - run tests on every Python version with tox" 9 | @echo "coverage - check code coverage quickly with the default Python" 10 | @echo "docs - generate Sphinx HTML documentation, including API docs" 11 | @echo "release - package and upload a release" 12 | @echo "sdist - package" 13 | 14 | clean: clean-build clean-pyc 15 | 16 | clean-build: 17 | rm -fr build/ 18 | rm -fr dist/ 19 | rm -fr *.egg-info 20 | 21 | clean-pyc: 22 | find . -name '*.pyc' -exec rm -f {} + 23 | find . -name '*.pyo' -exec rm -f {} + 24 | find . -name '*~' -exec rm -f {} + 25 | 26 | lint: 27 | pep8 django_nyt 28 | 29 | test: 30 | ./runtests.py 31 | 32 | test-all: 33 | tox 34 | 35 | coverage: 36 | coverage run --source django_nyt runtests.py 37 | coverage report -m 38 | 39 | docs: 40 | rm -f docs/modules.rst 41 | rm -f docs/django_nyt*.rst 42 | $(MAKE) -C docs clean 43 | sphinx-apidoc -d 10 -H "Python Reference" -o docs/ django_nyt django_nyt/tests django_nyt/migrations 44 | $(MAKE) -C docs html 45 | # sphinx-build -b linkcheck ./docs docs/_build/ 46 | sphinx-build -b html ./docs docs/_build/ 47 | 48 | release: clean sdist 49 | echo "Packing source dist..." 50 | twine upload -s dist/* 51 | 52 | sdist: clean 53 | cd django_nyt && django-admin compilemessages 54 | python setup.py sdist 55 | python setup.py bdist_wheel 56 | ls -l dist 57 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-nyt 2 | ========== 3 | 4 | .. |Build status| image:: https://circleci.com/gh/django-wiki/django-nyt.svg?style=shield 5 | :target: https://app.circleci.com/pipelines/github/django-wiki/django-nyt 6 | .. image:: https://readthedocs.org/projects/django-nyt/badge/?version=latest 7 | :target: https://django-nyt.readthedocs.io/en/latest/?badge=latest 8 | :alt: Documentation Status 9 | .. image:: https://badge.fury.io/py/django-nyt.svg 10 | :target: https://pypi.org/project/django-nyt/ 11 | .. image:: https://codecov.io/github/django-wiki/django-nyt/coverage.svg?branch=main 12 | :target: https://app.codecov.io/github/django-wiki/django-nyt?branch=main 13 | 14 | Concept 15 | ------- 16 | 17 | django-nyt is a notification framework for Django. It does this: 18 | 19 | .. code:: python 20 | 21 | from django_nyt.utils import notify 22 | 23 | EVENT_KEY = "my_key" 24 | notify(_("OMG! Something happened"), EVENT_KEY) 25 | 26 | All users subscribing to ``"my_key"`` will have a notification created when ``notify()`` is called. 27 | How the notification is handled depends on the user's settings. 28 | 29 | If you have emails enabled, subscribers receive a summary of notifications immediately or at an interval of their choice. 30 | 31 | Data can be accessed easily from Django models or from the included JSON views. 32 | 33 | By using generic object relations, custom URLs, and custom email templates, 34 | you can expand your notification logic to create email messages that both marks the notification as read when clicking a link and at the same time redirects users to a final destination: 35 | 36 | .. code:: python 37 | 38 | from django_nyt.utils import notify 39 | 40 | product = product 41 | EVENT_KEY = "product_is_in_stock" 42 | notify( 43 | _(f"{product.name} is in stock"), 44 | EVENT_KEY, 45 | url=f"/products/{product.id}/", 46 | target_object=product 47 | ) 48 | 49 | 50 | Roadmap 51 | ------- 52 | 53 | This project makes sense if people start using it and maturing it to their use-cases. 54 | 55 | Here are some aspects that aren't covered but are most welcome: 56 | 57 | * Support for async 58 | * Support for notifications through django-channels 4+ 59 | * Support for HTML emails (and user setting) 60 | 61 | Docs 62 | ---- 63 | 64 | https://django-nyt.readthedocs.io/en/latest/ 65 | 66 | 67 | Why should you do this? 68 | ----------------------- 69 | 70 | Users need a cleverly sifted stream of events that's highly customizable 71 | as well. By using django-nyt, your users can subscribe to global events 72 | or specific events pertaining specific objects. 73 | 74 | Instead of inventing your own notification system, use this and you won't have 75 | to design your own models, and you will have a nice guide that goes through 76 | the various steps of implementing notifications for your project. 77 | 78 | Let's try to summarize the reasons you want to be using django-nyt: 79 | 80 | - Simple API: call ``notify()`` where-ever you want. 81 | - CLI for sending emails (as cron job, daemon or Celery task) 82 | - Support for django-channels and Web Sockets (optional, fallback for JSON-based polling) 83 | - Basic JavaScript / HTML example code 84 | - Multi-lingual 85 | - Individual subscription settings for each type of event, for instance: 86 | - Event type A spawns instant email notifications, but Event B only gets emailed weekly. 87 | - Customizable intervals for which users can receive notifications 88 | - Optional URL for action target for each notification 89 | - Avoid clutter: Notifications don't get repeated, instead a counter is incremented. 90 | 91 | This project exists with ``django.contrib.messages`` in mind, to serve a simple, 92 | best-practice, scalable solution for notifications. There are loads of other 93 | notification apps for Django, some focus on integration of specific communication 94 | protocols 95 | 96 | What do you need to do? 97 | ----------------------- 98 | 99 | django-nyt does everything it can to meet as many needs as possible and 100 | have sane defaults. 101 | 102 | But you need to do a lot! Firstly, you need to write some JavaScript that will 103 | fetch the latest notifications and display them in some area of the 104 | screen. Upon clicking that icon, the latest notifications are displayed, and 105 | clicking an individual notification will redirect the user through a page 106 | that marks the notification as read. 107 | 108 | Something like this: 109 | 110 | .. image:: https://raw.githubusercontent.com/django-wiki/django-nyt/master/docs/misc/screenshot_dropdown.png 111 | :alt: Javascript drop-down 112 | 113 | JavaScript drop-down: Some examples are provided in the docs, but there 114 | is no real easy way to place this nifty little thing at the top of your 115 | site, you're going to have to work it out on your own. 116 | 117 | Other items for your TODO list: 118 | 119 | - Provide your users with options to customize their subscriptions and 120 | notification preferences. Create your own ``Form`` inheriting from 121 | ``django_nyt.forms.SettingsForm``. 122 | - Customize contents of notification emails by overwriting templates in 123 | ``django_nyt/emails/notification_email_message.txt`` and 124 | ``django_nyt/emails/notification_email_subject.txt``. 125 | - You can also have separate email templates per notification key. 126 | This includes using glob patterns. 127 | For instance, you can add this in your settings: 128 | 129 | .. code-block:: python 130 | 131 | NYT_EMAIL_TEMPLATE_NAMES = OrderedDict({ 132 | "ADMIN_*": "myapp/notifications/email/admins.txt" 133 | "*": "myapp/notifications/email/default.txt" 134 | }) 135 | NYT_EMAIL_TEMPLATE_SUBJECT_NAMES = OrderedDict({ 136 | "ADMIN_*": "myapp/notifications/email/admins_subject.txt" 137 | "*": "myapp/notifications/email/default_subject.txt" 138 | }) 139 | 140 | - Make the mail notification daemon script run either constantly 141 | ``python manage.py notifymail --daemon`` or with some interval by invoking 142 | ``python manage.py notifymail --cron`` as a cronjob. You can also call it 143 | from a Celery task or similar with ``call_command('notifymail', cron=True)``. 144 | 145 | 146 | Development / demo project 147 | -------------------------- 148 | 149 | In your Git fork, run ``pip install -r requirements.txt`` to install the 150 | requirements. 151 | 152 | Install pre-commit hooks to verify your commits:: 153 | 154 | pip install pre-commit 155 | pre-commit install 156 | 157 | The folder **test-project/** contains a pre-configured django project and 158 | an SQlite database. Login for django admin is *admin:admin*:: 159 | 160 | cd test-project 161 | python manage.py runserver 162 | 163 | After this, navigate to `http://localhost:8000 `_ 164 | 165 | 166 | Community 167 | --------- 168 | 169 | As many django-wiki users are also familiar with Django, 170 | please visit the channel #django-wiki on Libera. 171 | Click here for a web client `__). 172 | 173 | Otherwise, use the `Discussions `__ tab on GitHub. 174 | -------------------------------------------------------------------------------- /SUPPORT.md: -------------------------------------------------------------------------------- 1 | Getting support 2 | =============== 3 | 4 | Please refer to our documentation before asking questions: 5 | 6 | 7 | 8 | **DO NOT USE GITHUB FOR SUPPORT INQUIRIES! USE IRC OR MAILING LIST!** 9 | 10 | django-nyt is community based, please try to be active. If you want 11 | help, plan to give help, too. For instance, if you join IRC, then stay 12 | around and help others. 13 | 14 | Please use django-wiki's IRC for getting in touch on development and 15 | support. Please do not email or PM developers asking for personal 16 | support. 17 | 18 | > - `#django-wiki` on irc.freenode.net 19 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | TODO 2 | ==== 3 | 4 | * Tests 5 | * Implement optional support for celery 6 | * A custom Model Manager to easily retrive notifications and mark them as read 7 | * Some easy-to-use template tags and templates to override. 8 | * Examples of how to extend 9 | -------------------------------------------------------------------------------- /django_nyt/__init__.py: -------------------------------------------------------------------------------- 1 | _disable_notifications = False 2 | 3 | __version__ = "1.4.2" 4 | 5 | default_app_config = "django_nyt.apps.DjangoNytConfig" 6 | -------------------------------------------------------------------------------- /django_nyt/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.utils.translation import gettext as _ 3 | 4 | from django_nyt import models 5 | from django_nyt.conf import app_settings 6 | 7 | 8 | class SettingsAdmin(admin.ModelAdmin): 9 | raw_id_fields = ("user",) 10 | list_display = ( 11 | "user", 12 | "interval", 13 | ) 14 | 15 | 16 | class SubscriptionAdmin(admin.ModelAdmin): 17 | raw_id_fields = ("settings",) 18 | list_display = ( 19 | "display_user", 20 | "notification_type", 21 | "display_interval", 22 | ) 23 | 24 | def display_user(self, instance): 25 | return instance.settings.user 26 | 27 | display_user.short_description = _("user") 28 | 29 | def display_interval(self, instance): 30 | return instance.settings.interval 31 | 32 | display_interval.short_description = _("interval") 33 | 34 | 35 | class NotificationAdmin(admin.ModelAdmin): 36 | 37 | raw_id_fields = ("user", "subscription") 38 | 39 | 40 | if app_settings.NYT_ENABLE_ADMIN: 41 | admin.site.register(models.NotificationType) 42 | admin.site.register(models.Notification, NotificationAdmin) 43 | admin.site.register(models.Settings, SettingsAdmin) 44 | admin.site.register(models.Subscription, SubscriptionAdmin) 45 | -------------------------------------------------------------------------------- /django_nyt/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class DjangoNytConfig(AppConfig): 6 | name = "django_nyt" 7 | verbose_name = _("Django Nyt") 8 | -------------------------------------------------------------------------------- /django_nyt/conf.py: -------------------------------------------------------------------------------- 1 | """ 2 | These are the available settings, accessed through ``django_nyt.conf.app_settings``. 3 | All attributes prefixed ``NYT_*`` can be overridden from your Django project's settings module by defining a setting with the same name. 4 | 5 | For instance, to enable the admin, add the following to your project settings: 6 | 7 | .. code-block:: python 8 | 9 | NYT_ENABLE_ADMIN = True 10 | """ 11 | from __future__ import annotations 12 | 13 | from collections import OrderedDict 14 | from dataclasses import dataclass 15 | from dataclasses import field 16 | from typing import Any 17 | 18 | from django.conf import settings as django_settings 19 | from django.utils.translation import gettext_lazy as _ 20 | 21 | # All attributes accessed with this prefix are possible to overwrite 22 | # through django.conf.settings. 23 | settings_prefix = "NYT_" 24 | 25 | INSTANTLY = 0 26 | # Subtract 1, because the job finishes less than 24h before the next... 27 | DAILY = (24 - 1) * 60 28 | WEEKLY = 7 * (24 - 1) * 60 29 | 30 | 31 | @dataclass(frozen=True) 32 | class AppSettings: 33 | """Access this instance as ``django_nyt.conf.app_settings``.""" 34 | 35 | NYT_DB_TABLE_PREFIX: str = "nyt" 36 | """The table prefix for tables in the database. Do not change this unless you know what you are doing.""" 37 | 38 | NYT_ENABLE_ADMIN: bool = False 39 | """Enable django-admin registration for django-nyt's ModelAdmin classes.""" 40 | 41 | NYT_SEND_EMAILS: bool = True 42 | """Email notifications global setting, can be used to globally switch off 43 | emails, both instant and scheduled digests. 44 | Remember that emails are sent with ``python manage.py notifymail``.""" 45 | 46 | NYT_EMAIL_SUBJECT: str = None 47 | """Hard-code a subject for all emails sent (overrides the default subject templates).""" 48 | 49 | NYT_EMAIL_SENDER: str = "notifications@example.com" 50 | """Default sender email for notification emails. You should definitely make this match 51 | an email address that your email gateway will allow you to send from. You may also 52 | consider a no-reply kind of email if your notification system has a UI for changing 53 | notification settings.""" 54 | 55 | NYT_EMAIL_TEMPLATE_DEFAULT: str = "notifications/emails/default.txt" 56 | """Default template used for rendering email contents. 57 | Should contain a valid template name. 58 | If a lookup in ``NYT_EMAIL_TEMPLATE_NAMES`` doesn't return a result, this fallback is used.""" 59 | 60 | NYT_EMAIL_SUBJECT_TEMPLATE_DEFAULT: str = "notifications/emails/default_subject.txt" 61 | """Default template used for rendering the email subject. 62 | Should contain a valid template name. 63 | If a lookup in ``NYT_EMAIL_SUBJECT_TEMPLATE_NAMES`` doesn't return a result, this fallback is used.""" 64 | 65 | NYT_EMAIL_TEMPLATE_NAMES: dict = field(default_factory=OrderedDict) 66 | """Default dictionary, mapping notification keys to template names. Can be overwritten by database values. 67 | Keys can have a glob pattern, like ``USER_*`` or ``user/*``. 68 | 69 | When notification emails are generated, 70 | they are grouped by their templates such that notifications sharing the same template can be sent in a combined email. 71 | 72 | Example: 73 | 74 | .. code-block:: python 75 | 76 | NYT_EMAIL_TEMPLATE_NAMES = OrderedDict( 77 | [ 78 | ("admin/product/created", "myapp/notifications/email/admin_product_added.txt"), 79 | ("admin/**", "myapp/notifications/email/admin_default.txt"), 80 | ] 81 | ) 82 | """ 83 | 84 | NYT_EMAIL_SUBJECT_TEMPLATE_NAMES: dict = field(default_factory=OrderedDict) 85 | """Default dictionary, mapping notification keys to template names. The templates are used to generate a single-line email subject. 86 | Can be overwritten by database values. 87 | Keys can have a glob pattern, like ``USER_*`` or ``user/*``. 88 | 89 | When notification emails are generated, 90 | they are grouped by their templates such that notifications sharing the same template can be sent in a combined email. 91 | 92 | Example: 93 | 94 | .. code-block:: python 95 | 96 | NYT_EMAIL_SUBJECT_TEMPLATE_NAMES = OrderedDict( 97 | [ 98 | ("admin/product/created", "myapp/notifications/email/admin_product_added.txt"), 99 | ("admin/**", "myapp/notifications/email/admin_default.txt"), 100 | ] 101 | ) 102 | """ 103 | 104 | NYT_INTERVALS: list[tuple[int, Any]] | tuple[tuple[int, Any]] = ( 105 | (INSTANTLY, _("instantly")), 106 | (DAILY, _("daily")), 107 | (WEEKLY, _("weekly")), 108 | ) 109 | """List of intervals available for user selections. In minutes""" 110 | 111 | NYT_INTERVALS_DEFAULT: int = INSTANTLY 112 | """Default selection for new subscriptions""" 113 | 114 | NYT_USER_MODEL: str = getattr(django_settings, "AUTH_USER_MODEL", "auth.User") 115 | """The swappable user model of Django Nyt. The default is to use the contents of ``AUTH_USER_MODEL``.""" 116 | 117 | ############ 118 | # CHANNELS # 119 | ############ 120 | 121 | NYT_ENABLE_CHANNELS: str = ( 122 | "channels" in django_settings.INSTALLED_APPS 123 | and not getattr(django_settings, "NYT_CHANNELS_DISABLE", False) 124 | ) 125 | """Channels are enabled automatically when 'channels' application is installed, 126 | however you can explicitly disable it with NYT_CHANNELS_DISABLE.""" 127 | 128 | # Name of the global channel (preliminary stuff) that alerts everyone that there 129 | # is a new notification 130 | NYT_NOTIFICATION_CHANNEL: str = "nyt_all-{notification_key:s}" 131 | 132 | def __getattribute__(self, __name: str) -> Any: 133 | """ 134 | Check if a Django project settings should override the app default. 135 | 136 | In order to avoid returning any random properties of the django settings, we inspect the prefix firstly. 137 | """ 138 | 139 | if __name.startswith(settings_prefix) and hasattr(django_settings, __name): 140 | return getattr(django_settings, __name) 141 | 142 | return super().__getattribute__(__name) 143 | 144 | 145 | app_settings = AppSettings() 146 | -------------------------------------------------------------------------------- /django_nyt/consumers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from channels import Group 4 | from channels.auth import channel_session_user 5 | from channels.auth import channel_session_user_from_http 6 | 7 | from . import models 8 | from .conf import app_settings 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def get_subscriptions(message): 14 | """ 15 | :return: Subscription query for a given message's user 16 | """ 17 | if message.user.is_authenticated: 18 | return models.Subscription.objects.filter(settings__user=message.user) 19 | else: 20 | return models.Subscription.objects.none() 21 | 22 | 23 | @channel_session_user_from_http 24 | def ws_connect(message): 25 | """ 26 | Connected to websocket.connect 27 | """ 28 | logger.debug("Adding new connection for user {}".format(message.user)) 29 | message.reply_channel.send({"accept": True}) 30 | 31 | for subscription in get_subscriptions(message): 32 | Group( 33 | app_settings.NOTIFICATION_CHANNEL.format( 34 | notification_key=subscription.notification_type.key 35 | ) 36 | ).add(message.reply_channel) 37 | 38 | 39 | @channel_session_user 40 | def ws_disconnect(message): 41 | """ 42 | Connected to websocket.disconnect 43 | """ 44 | logger.debug("Removing connection for user {} (disconnect)".format(message.user)) 45 | for subscription in get_subscriptions(message): 46 | Group( 47 | app_settings.NOTIFICATION_CHANNEL.format( 48 | notification_key=subscription.notification_type.key 49 | ) 50 | ).discard(message.reply_channel) 51 | 52 | 53 | def ws_receive(message): 54 | """ 55 | Receives messages, this is currently just for debugging purposes as there 56 | is no communication API for the websockets. 57 | """ 58 | logger.debug("Received a message, responding with a non-API message") 59 | message.reply_channel.send({"text": "OK"}) 60 | -------------------------------------------------------------------------------- /django_nyt/decorators.py: -------------------------------------------------------------------------------- 1 | import json 2 | from functools import wraps 3 | 4 | from django.contrib.auth.decorators import login_required 5 | from django.http import HttpResponse 6 | 7 | import django_nyt 8 | 9 | 10 | def disable_notify(f): 11 | """Disable notifications. 12 | 13 | Does not work for async stuff, only disables notify in the same process. 14 | 15 | Example:: 16 | 17 | @disable_notify 18 | def your_function(): 19 | notify("no one will be notified", ...) 20 | """ 21 | 22 | @wraps(f) 23 | def wrapper(*args, **kwargs): 24 | django_nyt._disable_notifications = True 25 | response = f(*args, **kwargs) 26 | django_nyt._disable_notifications = False 27 | return response 28 | 29 | return wrapper 30 | 31 | 32 | def login_required_ajax(f): 33 | """Similar to login_required. But if the request is an ajax request, then 34 | it returns an error in json with a 403 status code.""" 35 | 36 | @wraps(f) 37 | def wrapper(request, *args, **kwargs): 38 | if request.headers.get("x-requested-with") == "XMLHttpRequest": 39 | if not request.user or not request.user.is_authenticated: 40 | return json_view(lambda *a, **kw: {"error": "not logged in"})( 41 | request, status=403 42 | ) 43 | return f(request, *args, **kwargs) 44 | else: 45 | return login_required(f)(request, *args, **kwargs) 46 | 47 | return wrapper 48 | 49 | 50 | def data2jsonresponse(data, **kwargs): 51 | json_data = json.dumps(data, ensure_ascii=False) 52 | status = kwargs.get("status", 200) 53 | response = HttpResponse(content_type="application/json", status=status) 54 | response.write(json_data) 55 | return response 56 | 57 | 58 | def json_view(f): 59 | @wraps(f) 60 | def wrapper(request, *args, **kwargs): 61 | data = f(request, *args, **kwargs) 62 | return data2jsonresponse(data, **kwargs) 63 | 64 | return wrapper 65 | -------------------------------------------------------------------------------- /django_nyt/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from . import models 4 | 5 | 6 | class SettingsForm(forms.ModelForm): 7 | class Meta: 8 | model = models.Settings 9 | fields = ( 10 | "interval", 11 | "is_default", 12 | ) 13 | -------------------------------------------------------------------------------- /django_nyt/locale/da/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: 2014-07-15 16:17+0200\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 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: admin.py:19 models.py:63 models.py:137 22 | msgid "user" 23 | msgstr "bruger" 24 | 25 | #: admin.py:23 models.py:67 26 | msgid "interval" 27 | msgstr "interval" 28 | 29 | #: models.py:23 30 | msgid "unique key" 31 | msgstr "unik nøgle" 32 | 33 | #: models.py:28 34 | msgid "optional label" 35 | msgstr "evt. label" 36 | 37 | #: models.py:39 38 | msgid "type" 39 | msgstr "type" 40 | 41 | #: models.py:40 42 | msgid "types" 43 | msgstr "typer" 44 | 45 | #: models.py:72 46 | #, python-format 47 | msgid "Settings for %s" 48 | msgstr "Indstillinger for %s" 49 | 50 | #: models.py:77 models.py:78 models.py:85 51 | msgid "settings" 52 | msgstr "indstillinger" 53 | 54 | #: models.py:90 55 | msgid "notification type" 56 | msgstr "notifikationstype" 57 | 58 | #: models.py:96 59 | msgid "Leave this blank to subscribe to any kind of object" 60 | msgstr "Efterlad denne tom for at abonnere på alle typer objekter" 61 | 62 | #: models.py:97 63 | msgid "object ID" 64 | msgstr "objekt ID" 65 | 66 | #: models.py:101 67 | msgid "send emails" 68 | msgstr "send e-mails" 69 | 70 | #: models.py:108 71 | msgid "latest notification" 72 | msgstr "seneste notifikation" 73 | 74 | #: models.py:112 75 | #, python-format 76 | msgid "Subscription for: %s" 77 | msgstr "Tilmelding for: %s" 78 | 79 | #: models.py:117 models.py:129 80 | msgid "subscription" 81 | msgstr "tilmelding" 82 | 83 | #: models.py:118 84 | msgid "subscriptions" 85 | msgstr "tilmeldinger" 86 | 87 | #: models.py:141 88 | msgid "link for notification" 89 | msgstr "link for notifikation" 90 | 91 | #: models.py:151 92 | msgid "occurrences" 93 | msgstr "hændelser" 94 | 95 | #: models.py:153 96 | msgid "" 97 | "If the same notification was fired multiple times with no intermediate " 98 | "notifications" 99 | msgstr "" 100 | "Hvis den samme notifikation blev sendt flere gange uden andre typer " 101 | "mellemliggende notifikationer" 102 | 103 | #: models.py:223 104 | msgid "notification" 105 | msgstr "notifikation" 106 | 107 | #: models.py:224 108 | msgid "notifications" 109 | msgstr "notifikationer" 110 | 111 | #: settings.py:19 112 | msgid "You have new notifications" 113 | msgstr "Do har nye notifikationer" 114 | 115 | #: settings.py:39 116 | msgid "instantly" 117 | msgstr "med det samme" 118 | 119 | #: settings.py:40 120 | msgid "daily" 121 | msgstr "dagligt" 122 | 123 | #: settings.py:41 124 | msgid "weekly" 125 | msgstr "ugentligt" 126 | 127 | #: utils.py:35 128 | msgid "You supplied a target_object that's not an instance of a django Model." 129 | msgstr "Du angav et target_object der ikke er en Django Model for." 130 | 131 | #: views.py:40 132 | #, python-format 133 | msgid "%d times" 134 | msgstr "%d gange" 135 | 136 | #: templates/emails/notification_email_message.txt:1 137 | #, python-format 138 | msgid "Dear %(username)s," 139 | msgstr "Kære %(username)s," 140 | 141 | #: templates/emails/notification_email_message.txt:3 142 | #, python-format 143 | msgid "These are the %(digest)s notifications from %(site)s." 144 | msgstr "Dette er de seneste %(digest)s notifikationer fra %(site)s." 145 | 146 | #: templates/emails/notification_email_message.txt:9 147 | msgid "Thanks for using our site!" 148 | msgstr "Tak for, at du bruger vores hjemmeside!" 149 | 150 | #: templates/emails/notification_email_message.txt:11 151 | msgid "Sincerely" 152 | msgstr "Venlig hilsen" 153 | 154 | #: templates/emails/notification_email_subject.txt:2 155 | #, python-format 156 | msgid " %(digest)s Notifications %(site)s." 157 | msgstr " %(digest)s Notifikationer %(site)s." 158 | -------------------------------------------------------------------------------- /django_nyt/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # THE GERMAN TRANSLATION OF DJANGO-NOTIFY. 2 | # Copyright (C) 2013 THOMAS LOTTERMANN 3 | # This file is distributed under the same license as the DJANGO-WIKI package. 4 | # THOMAS LOTTERMANN , 2013. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: 0.18\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2013-06-06 20:37+0200\n" 12 | "Last-Translator: Thomas Lottermann \n" 13 | "Language-Team: \n" 14 | "Language: German\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 19 | 20 | #: __init__.py:52 21 | msgid "You supplied a target_object that's not an instance of a django Model." 22 | msgstr "Das gegebene \"target_object\" ist keine Instanz eines Django Models." 23 | 24 | #: models.py:14 25 | msgid "unique key" 26 | msgstr "eindeutiger Schlüssel" 27 | 28 | #: models.py:16 29 | msgid "verbose name" 30 | msgstr "sprechender Name" 31 | 32 | #: models.py:25 33 | msgid "type" 34 | msgstr "Typ" 35 | 36 | #: models.py:26 37 | msgid "types" 38 | msgstr "Typen" 39 | 40 | #: models.py:31 41 | msgid "interval" 42 | msgstr "Interval" 43 | 44 | #: models.py:35 45 | #, python-format 46 | msgid "Settings for %s" 47 | msgstr "Einstellungen für %s" 48 | 49 | #: models.py:40 models.py:41 50 | msgid "settings" 51 | msgstr "Einstellungen" 52 | 53 | #: models.py:48 54 | msgid "Leave this blank to subscribe to any kind of object" 55 | msgstr "Leerlassen um beliebiges Object zu abonnieren" 56 | 57 | #: models.py:53 58 | #, python-format 59 | msgid "Subscription for: %s" 60 | msgstr "Abonnement von: %s" 61 | 62 | #: models.py:58 63 | msgid "subscription" 64 | msgstr "Abonnement" 65 | 66 | #: models.py:59 67 | msgid "subscriptions" 68 | msgstr "Abonnements" 69 | 70 | #: models.py:65 71 | msgid "link for notification" 72 | msgstr "Link zur Benachrichtigung" 73 | 74 | #: models.py:71 75 | msgid "occurrences" 76 | msgstr "Häufigkeit" 77 | 78 | #: models.py:72 79 | msgid "" 80 | "If the same notification was fired multiple times with no intermediate " 81 | "notifications" 82 | msgstr "" 83 | "Wenn die gleiche Benachrichtigung öfter aufgetreten ist ohne " 84 | "zwischenzeitiges senden" 85 | 86 | #: models.py:125 87 | msgid "notification" 88 | msgstr "Benachrichtigung" 89 | 90 | #: models.py:126 91 | msgid "notifications" 92 | msgstr "Benachrichtigungen" 93 | 94 | #: settings.py:19 95 | msgid "You have new notifications" 96 | msgstr "Du hast neue Benachrichtigungen" 97 | 98 | #: settings.py:38 99 | msgid "instant" 100 | msgstr "sofort" 101 | 102 | #: settings.py:39 103 | msgid "daily" 104 | msgstr "täglich" 105 | 106 | #: settings.py:40 107 | msgid "weekly" 108 | msgstr "wöchentlich" 109 | 110 | #: views.py:32 111 | #, python-format 112 | msgid "%d times" 113 | msgstr "%d mal" 114 | 115 | #: templates/emails/notification_email_message.txt:2 116 | #, python-format 117 | msgid "Dear %(username)s," 118 | msgstr "Hallo %(username)s," 119 | 120 | #: templates/emails/notification_email_message.txt:4 121 | #, python-format 122 | msgid " These are the %(digest)s notifications from %(site)s." 123 | msgstr " Dies sind die %(digest)s versendeten Benachrichtigungen von %(site)s." 124 | 125 | #: templates/emails/notification_email_message.txt:10 126 | msgid "Thanks for using our site!" 127 | msgstr "Danke, dass du unsere Seite nutzt!" 128 | 129 | #: templates/emails/notification_email_message.txt:12 130 | msgid "Sincerely" 131 | msgstr "Viele Grüße" 132 | 133 | #: templates/emails/notification_email_subject.txt:2 134 | #, python-format 135 | msgid " %(digest)s Notifications %(site)s." 136 | msgstr " Benachrichtigungen von %(site)s (%(digest)s versendet)." 137 | -------------------------------------------------------------------------------- /django_nyt/locale/fi/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # THE FINNISH TRANSLATION OF DJANGO-NYT 2 | # Copyright (C) 2014 JAAKKO LUTTINEN 3 | # This file is distributed under the same license as the DJANGO-WIKI package. 4 | # JAAKKO LUTTINEN , 2014. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: 0.9.2\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2014-10-27 06:25+0200\n" 12 | "PO-Revision-Date: 2014-10-27 06:53+0200\n" 13 | "Last-Translator: Jaakko Luttinen \n" 14 | "Language-Team: \n" 15 | "Language: Finnish\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: admin.py:19 models.py:63 models.py:137 22 | msgid "user" 23 | msgstr "käyttäjä" 24 | 25 | #: admin.py:23 models.py:67 26 | msgid "interval" 27 | msgstr "aikaväli" 28 | 29 | #: models.py:23 30 | msgid "unique key" 31 | msgstr "uniikki avain" 32 | 33 | #: models.py:28 34 | msgid "optional label" 35 | msgstr "vapaaehtoinen nimiö" 36 | 37 | #: models.py:39 38 | msgid "type" 39 | msgstr "tyyppi" 40 | 41 | #: models.py:40 42 | msgid "types" 43 | msgstr "tyypit" 44 | 45 | #: models.py:72 46 | #, python-format 47 | msgid "Settings for %s" 48 | msgstr "Asetukset %s" 49 | 50 | #: models.py:77 models.py:78 models.py:85 51 | msgid "settings" 52 | msgstr "asetukset" 53 | 54 | #: models.py:90 55 | msgid "notification type" 56 | msgstr "ilmoituksen tyyppi" 57 | 58 | #: models.py:96 59 | msgid "Leave this blank to subscribe to any kind of object" 60 | msgstr "Jätä tämä tyhjäksi tilataksesi kaikenlaiset ilmoitukset" 61 | 62 | #: models.py:97 63 | msgid "object ID" 64 | msgstr "objektin tunniste" 65 | 66 | #: models.py:101 67 | msgid "send emails" 68 | msgstr "lähetä sähköposteja" 69 | 70 | #: models.py:108 71 | msgid "latest notification" 72 | msgstr "viimeisin ilmoitus" 73 | 74 | #: models.py:112 75 | #, python-format 76 | msgid "Subscription for: %s" 77 | msgstr "Tilaus: %s" 78 | 79 | #: models.py:117 models.py:129 80 | msgid "subscription" 81 | msgstr "tilaus" 82 | 83 | #: models.py:118 84 | msgid "subscriptions" 85 | msgstr "tilaukset" 86 | 87 | #: models.py:141 88 | msgid "link for notification" 89 | msgstr "ilmoituksen linkki" 90 | 91 | #: models.py:151 92 | msgid "occurrences" 93 | msgstr "esiintymät" 94 | 95 | #: models.py:153 96 | msgid "" 97 | "If the same notification was fired multiple times with no intermediate " 98 | "notifications" 99 | msgstr "" 100 | "Jos sama ilmoitus esiintyy useamman kerran ilman välissä olevia ilmoituksia" 101 | 102 | #: models.py:228 103 | msgid "notification" 104 | msgstr "ilmoitus" 105 | 106 | #: models.py:229 107 | msgid "notifications" 108 | msgstr "ilmoitukset" 109 | 110 | #: settings.py:19 111 | msgid "You have new notifications" 112 | msgstr "Sinulla on uusia ilmoituksia" 113 | 114 | #: settings.py:39 115 | msgid "instantly" 116 | msgstr "heti" 117 | 118 | #: settings.py:40 119 | msgid "daily" 120 | msgstr "päivittäin" 121 | 122 | #: settings.py:41 123 | msgid "weekly" 124 | msgstr "viikottain" 125 | 126 | #: utils.py:39 127 | msgid "You supplied a target_object that's not an instance of a django Model." 128 | msgstr "Annoit kohteen (target_object), joka ei ole Djangon Model-luokan ilmentymä." 129 | 130 | #: views.py:45 131 | #, python-format 132 | msgid "%d times" 133 | msgstr "%d kertaa" 134 | 135 | #: templates/emails/notification_email_message.txt:1 136 | #, python-format 137 | msgid "Dear %(username)s," 138 | msgstr "Hei %(username)s" 139 | 140 | #: templates/emails/notification_email_message.txt:3 141 | #, python-format 142 | msgid "These are the %(digest)s notifications from %(site)s." 143 | msgstr "Tässä ovat %(digest)s toimitettavat ilmoitukset sivustolta %(site)s." 144 | 145 | #: templates/emails/notification_email_message.txt:9 146 | msgid "Thanks for using our site!" 147 | msgstr "Kiitos kun käytät sivustoamme!" 148 | 149 | #: templates/emails/notification_email_message.txt:11 150 | msgid "Sincerely" 151 | msgstr "Ystävällisin terveisin," 152 | 153 | #: templates/emails/notification_email_subject.txt:2 154 | #, python-format 155 | msgid " %(digest)s Notifications %(site)s." 156 | msgstr " %(site)s - %(digest)s toimitettavat ilmoitukset" 157 | -------------------------------------------------------------------------------- /django_nyt/locale/ru/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # THE RUSSIAN TRANSLATION OF DJANGO-NOTIFY. 2 | # Copyright (C) 2013 ROSTISLAV GRIGORIEV 3 | # This file is distributed under the same license as the DJANGO-WIKI package. 4 | # Rostislav Grigoriev , 2013. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: 0.18\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2013-10-25 15:45+0400\n" 12 | "PO-Revision-Date: 2013-03-17 19:30+CET\n" 13 | "Last-Translator: Rostislav Grigoriev \n" 14 | "Language-Team: \n" 15 | "Language: Russian\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" 20 | "%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" 21 | 22 | #: __init__.py:55 23 | msgid "You supplied a target_object that's not an instance of a django Model." 24 | msgstr "Указанный вами target_object не экземляр django-модели" 25 | 26 | #: models.py:18 27 | msgid "unique key" 28 | msgstr "уникальный ключ" 29 | 30 | #: models.py:23 31 | msgid "verbose name" 32 | msgstr "наименование" 33 | 34 | #: models.py:34 35 | msgid "type" 36 | msgstr "тип" 37 | 38 | #: models.py:35 39 | msgid "types" 40 | msgstr "типы" 41 | 42 | #: models.py:47 43 | msgid "interval" 44 | msgstr "интервал" 45 | 46 | #: models.py:52 47 | #, python-format 48 | msgid "Settings for %s" 49 | msgstr "Настройки для %s" 50 | 51 | #: models.py:57 models.py:58 52 | msgid "settings" 53 | msgstr "настройки" 54 | 55 | #: models.py:68 56 | msgid "Leave this blank to subscribe to any kind of object" 57 | msgstr "Оставьте это поле пустым, чтобы подписаться на любой объект" 58 | 59 | #: models.py:78 60 | #, python-format 61 | msgid "Subscription for: %s" 62 | msgstr "Подписка для: %s" 63 | 64 | #: models.py:83 65 | msgid "subscription" 66 | msgstr "подписка" 67 | 68 | #: models.py:84 69 | msgid "subscriptions" 70 | msgstr "подписки" 71 | 72 | #: models.py:96 73 | msgid "link for notification" 74 | msgstr "ссылка уведомления" 75 | 76 | #: models.py:106 77 | msgid "occurrences" 78 | msgstr "вхождения" 79 | 80 | #: models.py:108 81 | msgid "" 82 | "If the same notification was fired multiple times with no intermediate " 83 | "notifications" 84 | msgstr "" 85 | "Если уведомление было вызвано несколько раз без промежуточного уведомления." 86 | 87 | #: models.py:170 88 | msgid "notification" 89 | msgstr "уведомление" 90 | 91 | #: models.py:171 92 | msgid "notifications" 93 | msgstr "уведомления" 94 | 95 | #: settings.py:19 96 | msgid "You have new notifications" 97 | msgstr "Нет новых уведомлений" 98 | 99 | #: settings.py:38 100 | msgid "instantly" 101 | msgstr "немедленно" 102 | 103 | #: settings.py:39 104 | msgid "daily" 105 | msgstr "раз в день" 106 | 107 | #: settings.py:40 108 | msgid "weekly" 109 | msgstr "раз в неделю" 110 | 111 | #: views.py:32 112 | #, python-format 113 | msgid "%d times" 114 | msgstr "%d раз" 115 | 116 | #: templates/emails/notification_email_message.txt:1 117 | #, python-format 118 | msgid "Dear %(username)s," 119 | msgstr "Дорогой %(username)s," 120 | 121 | #: templates/emails/notification_email_message.txt:3 122 | #, python-format 123 | msgid "These are the %(digest)s notifications from %(site)s." 124 | msgstr " Для вас %(digest)s уведомлений от %(site)s." 125 | 126 | #: templates/emails/notification_email_message.txt:9 127 | msgid "Thanks for using our site!" 128 | msgstr "Спасибо, что пользуетесь сайтом!" 129 | 130 | #: templates/emails/notification_email_message.txt:11 131 | msgid "Sincerely" 132 | msgstr "Искренне" 133 | 134 | #: templates/emails/notification_email_subject.txt:2 135 | #, python-format 136 | msgid " %(digest)s Notifications %(site)s." 137 | msgstr " %(digest)s уведомлений от %(site)s." 138 | -------------------------------------------------------------------------------- /django_nyt/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-wiki/django-nyt/af833c9be6e8af9527c421d811b384f12461650a/django_nyt/management/__init__.py -------------------------------------------------------------------------------- /django_nyt/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-wiki/django-nyt/af833c9be6e8af9527c421d811b384f12461650a/django_nyt/management/commands/__init__.py -------------------------------------------------------------------------------- /django_nyt/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | from django.db import models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | ("contenttypes", "__first__"), 9 | ] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="NotificationType", 14 | fields=[ 15 | ( 16 | "key", 17 | models.CharField( 18 | verbose_name="unique key", 19 | serialize=False, 20 | max_length=128, 21 | primary_key=True, 22 | unique=True, 23 | ), 24 | ), 25 | ( 26 | "label", 27 | models.CharField( 28 | verbose_name="verbose name", 29 | null=True, 30 | max_length=128, 31 | blank=True, 32 | ), 33 | ), 34 | ( 35 | "content_type", 36 | models.ForeignKey( 37 | to_field="id", 38 | to="contenttypes.ContentType", 39 | blank=True, 40 | null=True, 41 | on_delete=models.CASCADE, 42 | ), 43 | ), 44 | ], 45 | options={ 46 | "verbose_name": "type", 47 | "db_table": "nyt_notificationtype", 48 | "verbose_name_plural": "types", 49 | }, 50 | bases=(models.Model,), 51 | ), 52 | ] 53 | -------------------------------------------------------------------------------- /django_nyt/migrations/0002_notification_settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import migrations 3 | from django.db import models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 10 | ("django_nyt", "0001_initial"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="Settings", 16 | fields=[ 17 | ( 18 | "id", 19 | models.AutoField( 20 | auto_created=True, 21 | verbose_name="ID", 22 | serialize=False, 23 | primary_key=True, 24 | ), 25 | ), 26 | ( 27 | "user", 28 | models.ForeignKey( 29 | to_field="id", 30 | to=settings.AUTH_USER_MODEL, 31 | on_delete=models.CASCADE, 32 | ), 33 | ), 34 | ( 35 | "interval", 36 | models.SmallIntegerField( 37 | choices=[(0, "instantly"), (1380, "daily"), (9660, "weekly")], 38 | default=0, 39 | verbose_name="interval", 40 | ), 41 | ), 42 | ], 43 | options={ 44 | "verbose_name": "settings", 45 | "db_table": "nyt_settings", 46 | "verbose_name_plural": "settings", 47 | }, 48 | bases=(models.Model,), 49 | ), 50 | migrations.CreateModel( 51 | name="Notification", 52 | fields=[ 53 | ( 54 | "id", 55 | models.AutoField( 56 | auto_created=True, 57 | verbose_name="ID", 58 | serialize=False, 59 | primary_key=True, 60 | ), 61 | ), 62 | ("message", models.TextField()), 63 | ( 64 | "url", 65 | models.CharField( 66 | verbose_name="link for notification", 67 | null=True, 68 | max_length=200, 69 | blank=True, 70 | ), 71 | ), 72 | ("is_viewed", models.BooleanField(default=False)), 73 | ("is_emailed", models.BooleanField(default=False)), 74 | ("created", models.DateTimeField(auto_now_add=True)), 75 | ( 76 | "occurrences", 77 | models.PositiveIntegerField( 78 | help_text="If the same notification was fired multiple times with no intermediate notifications", 79 | default=1, 80 | verbose_name="occurrences", 81 | ), 82 | ), 83 | ], 84 | options={ 85 | "verbose_name": "notification", 86 | "db_table": "nyt_notification", 87 | "ordering": ("-id",), 88 | "verbose_name_plural": "notifications", 89 | }, 90 | bases=(models.Model,), 91 | ), 92 | ] 93 | -------------------------------------------------------------------------------- /django_nyt/migrations/0003_subscription.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | from django.db import models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | ("django_nyt", "0002_notification_settings"), 9 | ] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="Subscription", 14 | fields=[ 15 | ( 16 | "id", 17 | models.AutoField( 18 | auto_created=True, 19 | verbose_name="ID", 20 | serialize=False, 21 | primary_key=True, 22 | ), 23 | ), 24 | ( 25 | "settings", 26 | models.ForeignKey( 27 | to_field="id", 28 | to="django_nyt.Settings", 29 | on_delete=models.CASCADE, 30 | ), 31 | ), 32 | ( 33 | "notification_type", 34 | models.ForeignKey( 35 | to_field="key", 36 | to="django_nyt.NotificationType", 37 | on_delete=models.CASCADE, 38 | ), 39 | ), 40 | ( 41 | "object_id", 42 | models.CharField( 43 | help_text="Leave this blank to subscribe to any kind of object", 44 | null=True, 45 | max_length=64, 46 | blank=True, 47 | ), 48 | ), 49 | ("send_emails", models.BooleanField(default=True)), 50 | ( 51 | "latest", 52 | models.ForeignKey( 53 | to_field="id", 54 | to="django_nyt.Notification", 55 | blank=True, 56 | null=True, 57 | on_delete=models.CASCADE, 58 | ), 59 | ), 60 | ], 61 | options={ 62 | "verbose_name": "subscription", 63 | "db_table": "nyt_subscription", 64 | "verbose_name_plural": "subscriptions", 65 | }, 66 | bases=(models.Model,), 67 | ), 68 | ] 69 | -------------------------------------------------------------------------------- /django_nyt/migrations/0004_notification_subscription.py: -------------------------------------------------------------------------------- 1 | import django.db.models.deletion 2 | from django.db import migrations 3 | from django.db import models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("django_nyt", "0003_subscription"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="notification", 15 | name="subscription", 16 | field=models.ForeignKey( 17 | to_field="id", 18 | to="django_nyt.Subscription", 19 | blank=True, 20 | on_delete=django.db.models.deletion.SET_NULL, 21 | null=True, 22 | ), 23 | preserve_default=True, 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /django_nyt/migrations/0005__v_0_9_2.py: -------------------------------------------------------------------------------- 1 | import django.db.models.deletion 2 | from django.conf import settings 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ("django_nyt", "0004_notification_subscription"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="notification", 17 | name="user", 18 | field=models.ForeignKey( 19 | verbose_name="user", 20 | to_field="id", 21 | blank=True, 22 | to=settings.AUTH_USER_MODEL, 23 | null=True, 24 | on_delete=models.CASCADE, 25 | ), 26 | preserve_default=True, 27 | ), 28 | migrations.AlterField( 29 | model_name="subscription", 30 | name="latest", 31 | field=models.ForeignKey( 32 | verbose_name="latest notification", 33 | to_field="id", 34 | blank=True, 35 | to="django_nyt.Notification", 36 | null=True, 37 | on_delete=models.CASCADE, 38 | ), 39 | ), 40 | migrations.AlterField( 41 | model_name="settings", 42 | name="user", 43 | field=models.ForeignKey( 44 | to=settings.AUTH_USER_MODEL, 45 | to_field="id", 46 | verbose_name="user", 47 | on_delete=models.CASCADE, 48 | ), 49 | ), 50 | migrations.AlterField( 51 | model_name="subscription", 52 | name="settings", 53 | field=models.ForeignKey( 54 | to="django_nyt.Settings", 55 | to_field="id", 56 | verbose_name="settings", 57 | on_delete=models.CASCADE, 58 | ), 59 | ), 60 | migrations.AlterField( 61 | model_name="subscription", 62 | name="notification_type", 63 | field=models.ForeignKey( 64 | to="django_nyt.NotificationType", 65 | to_field="key", 66 | verbose_name="notification type", 67 | on_delete=models.CASCADE, 68 | ), 69 | ), 70 | migrations.AlterField( 71 | model_name="notificationtype", 72 | name="label", 73 | field=models.CharField( 74 | max_length=128, null=True, verbose_name="optional label", blank=True 75 | ), 76 | ), 77 | migrations.AlterField( 78 | model_name="subscription", 79 | name="object_id", 80 | field=models.CharField( 81 | help_text="Leave this blank to subscribe to any kind of object", 82 | max_length=64, 83 | null=True, 84 | verbose_name="object ID", 85 | blank=True, 86 | ), 87 | ), 88 | migrations.AlterField( 89 | model_name="subscription", 90 | name="send_emails", 91 | field=models.BooleanField(default=True, verbose_name="send emails"), 92 | ), 93 | migrations.AlterField( 94 | model_name="notification", 95 | name="subscription", 96 | field=models.ForeignKey( 97 | on_delete=django.db.models.deletion.SET_NULL, 98 | verbose_name="subscription", 99 | to_field="id", 100 | blank=True, 101 | to="django_nyt.Subscription", 102 | null=True, 103 | ), 104 | ), 105 | ] 106 | -------------------------------------------------------------------------------- /django_nyt/migrations/0006_auto_20141229_1630.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | from django.db import models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | ("django_nyt", "0005__v_0_9_2"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="subscription", 14 | name="latest", 15 | field=models.ForeignKey( 16 | related_name="latest_for", 17 | verbose_name="latest notification", 18 | blank=True, 19 | to="django_nyt.Notification", 20 | null=True, 21 | on_delete=models.CASCADE, 22 | ), 23 | preserve_default=True, 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /django_nyt/migrations/0007_add_modified_and_default_settings.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | from django.db import models 3 | from django.utils import timezone 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("django_nyt", "0006_auto_20141229_1630"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="notification", 15 | name="modified", 16 | field=models.DateTimeField(auto_now=True, default=timezone.now()), 17 | preserve_default=False, 18 | ), 19 | migrations.AddField( 20 | model_name="settings", 21 | name="is_default", 22 | field=models.BooleanField( 23 | default=False, verbose_name="Default for new subscriptions" 24 | ), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /django_nyt/migrations/0008_auto_20161023_1641.py: -------------------------------------------------------------------------------- 1 | import django.db.models.deletion 2 | from django.conf import settings 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("django_nyt", "0007_add_modified_and_default_settings"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="notification", 16 | name="user", 17 | field=models.ForeignKey( 18 | blank=True, 19 | null=True, 20 | on_delete=django.db.models.deletion.CASCADE, 21 | related_name="nyt_notifications", 22 | to=settings.AUTH_USER_MODEL, 23 | verbose_name="user", 24 | ), 25 | ), 26 | migrations.AlterField( 27 | model_name="notificationtype", 28 | name="content_type", 29 | field=models.ForeignKey( 30 | blank=True, 31 | null=True, 32 | on_delete=django.db.models.deletion.SET_NULL, 33 | to="contenttypes.ContentType", 34 | ), 35 | ), 36 | migrations.AlterField( 37 | model_name="settings", 38 | name="user", 39 | field=models.ForeignKey( 40 | on_delete=django.db.models.deletion.CASCADE, 41 | related_name="nyt_settings", 42 | to=settings.AUTH_USER_MODEL, 43 | verbose_name="user", 44 | ), 45 | ), 46 | ] 47 | -------------------------------------------------------------------------------- /django_nyt/migrations/0009_alter_notification_subscription_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.2 on 2022-02-12 22:40 2 | import django.db.models.deletion 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("django_nyt", "0008_auto_20161023_1641"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="notification", 16 | name="subscription", 17 | field=models.ForeignKey( 18 | blank=True, 19 | null=True, 20 | on_delete=django.db.models.deletion.SET_NULL, 21 | to="django_nyt.subscription", 22 | verbose_name="subscription", 23 | ), 24 | ), 25 | migrations.AlterField( 26 | model_name="subscription", 27 | name="notification_type", 28 | field=models.ForeignKey( 29 | on_delete=django.db.models.deletion.CASCADE, 30 | to="django_nyt.notificationtype", 31 | verbose_name="notification type", 32 | ), 33 | ), 34 | migrations.AlterField( 35 | model_name="subscription", 36 | name="settings", 37 | field=models.ForeignKey( 38 | on_delete=django.db.models.deletion.CASCADE, 39 | to="django_nyt.settings", 40 | verbose_name="settings", 41 | ), 42 | ), 43 | ] 44 | -------------------------------------------------------------------------------- /django_nyt/migrations/0010_settings_created_settings_modified_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.1 on 2024-01-06 22:12 2 | import django.utils.timezone 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("django_nyt", "0009_alter_notification_subscription_and_more"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="settings", 16 | name="created", 17 | field=models.DateTimeField( 18 | auto_now_add=True, 19 | default=django.utils.timezone.now, 20 | verbose_name="created", 21 | ), 22 | preserve_default=False, 23 | ), 24 | migrations.AddField( 25 | model_name="settings", 26 | name="modified", 27 | field=models.DateTimeField(auto_now=True, verbose_name="modified"), 28 | ), 29 | migrations.AddField( 30 | model_name="subscription", 31 | name="created", 32 | field=models.DateTimeField( 33 | auto_now_add=True, 34 | default=django.utils.timezone.now, 35 | verbose_name="created", 36 | ), 37 | preserve_default=False, 38 | ), 39 | migrations.AddField( 40 | model_name="subscription", 41 | name="last_sent", 42 | field=models.DateTimeField(blank=True, null=True, verbose_name="last sent"), 43 | ), 44 | migrations.AddField( 45 | model_name="subscription", 46 | name="modified", 47 | field=models.DateTimeField(auto_now=True, verbose_name="modified"), 48 | ), 49 | migrations.AlterField( 50 | model_name="notification", 51 | name="created", 52 | field=models.DateTimeField(auto_now_add=True, verbose_name="created"), 53 | ), 54 | migrations.AlterField( 55 | model_name="notification", 56 | name="is_emailed", 57 | field=models.BooleanField(default=False, verbose_name="mail sent"), 58 | ), 59 | migrations.AlterField( 60 | model_name="notification", 61 | name="is_viewed", 62 | field=models.BooleanField( 63 | default=False, verbose_name="notification viewed" 64 | ), 65 | ), 66 | migrations.AlterField( 67 | model_name="notification", 68 | name="modified", 69 | field=models.DateTimeField(auto_now=True, verbose_name="modified"), 70 | ), 71 | migrations.AlterField( 72 | model_name="notification", 73 | name="url", 74 | field=models.CharField( 75 | blank=True, 76 | max_length=2000, 77 | null=True, 78 | verbose_name="link for notification", 79 | ), 80 | ), 81 | migrations.AlterField( 82 | model_name="settings", 83 | name="interval", 84 | field=models.SmallIntegerField( 85 | choices=[(0, "instantly"), (1380, "daily"), (9660, "weekly")], 86 | default=0, 87 | help_text="interval in minutes (0=instant, 60=notify once per hour)", 88 | verbose_name="interval", 89 | ), 90 | ), 91 | ] 92 | -------------------------------------------------------------------------------- /django_nyt/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-wiki/django-nyt/af833c9be6e8af9527c421d811b384f12461650a/django_nyt/migrations/__init__.py -------------------------------------------------------------------------------- /django_nyt/routing.py: -------------------------------------------------------------------------------- 1 | from channels.routing import route 2 | 3 | from . import consumers 4 | 5 | channel_routing = [ 6 | route("websocket.connect", consumers.ws_connect, path=r"^/nyt/?$"), 7 | route("websocket.disconnect", consumers.ws_disconnect), 8 | route("websocket.receive", consumers.ws_receive), 9 | ] 10 | -------------------------------------------------------------------------------- /django_nyt/subscribers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from channels import Group 4 | 5 | from . import models 6 | from .conf import app_settings 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | def notify_subscribers(notifications, key): 12 | """ 13 | Notify all open channels about new notifications 14 | """ 15 | 16 | logger.debug("Broadcasting to subscribers") 17 | 18 | notification_type_ids = models.NotificationType.objects.values("key").filter( 19 | key=key 20 | ) 21 | 22 | for notification_type in notification_type_ids: 23 | g = Group( 24 | app_settings.NOTIFICATION_CHANNEL.format( 25 | notification_key=notification_type["key"] 26 | ) 27 | ) 28 | g.send({"text": "new-notification"}) 29 | -------------------------------------------------------------------------------- /django_nyt/templates/notifications/emails/default.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% autoescape off %}{% blocktrans with username as username %}Dear {{ username }},{% endblocktrans %} 2 | 3 | {% blocktrans with site.name|default:domain as site %}These are notifications sent {{ digest }} from {{ site }}.{% endblocktrans %} 4 | {% for n in notifications %} 5 | * {{ n.message|safe }}{% if n.url %} 6 | {% if "://" in n.url %}{{ n.url }}{% else %}{{ http_scheme }}://{{ domain }}{{ n.url }}{% endif %}{% endif %} 7 | {% endfor %} 8 | 9 | {% trans "Thanks for using our site!" %} 10 | 11 | {% trans "Sincerely" %}, 12 | {{ site.name|default:domain }} 13 | {% endautoescape %} 14 | -------------------------------------------------------------------------------- /django_nyt/templates/notifications/emails/default_subject.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% autoescape off %} 2 | {% blocktrans %} You have new notifications from {{ domain }} (type: {{ digest }}){% endblocktrans %} 3 | {% endautoescape %} 4 | -------------------------------------------------------------------------------- /django_nyt/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include 2 | from django.urls import re_path as url 3 | 4 | from . import views 5 | 6 | app_name = "nyt" 7 | 8 | urlpatterns = [ 9 | url(r"^json/get/$", views.get_notifications, name="json_get"), 10 | url(r"^json/get/(?P\d+)/$", views.get_notifications, name="json_get"), 11 | url(r"^json/mark-read/$", views.mark_read, name="json_mark_read_base"), 12 | url(r"^json/mark-read/(\d+)/$", views.mark_read, name="json_mark_read"), 13 | url( 14 | r"^json/mark-read/(?P\d+)/(?P\d+)/$", 15 | views.mark_read, 16 | name="json_mark_read", 17 | ), 18 | url(r"^goto/(?P\d+)/$", views.goto, name="goto"), 19 | url(r"^goto/$", views.goto, name="goto_base"), 20 | ] 21 | 22 | 23 | def get_pattern(app_name=app_name, namespace="nyt"): 24 | """Every url resolution takes place as "nyt:view_name". 25 | https://docs.djangoproject.com/en/dev/topics/http/urls/#topics-http-reversing-url-namespaces 26 | """ 27 | import warnings 28 | 29 | warnings.warn( 30 | "django_nyt.urls.get_pattern is deprecated and will be removed in next version," 31 | " just use include('django_nyt.urls')", 32 | DeprecationWarning, 33 | ) 34 | return include("django_nyt.urls") 35 | -------------------------------------------------------------------------------- /django_nyt/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from typing import List 3 | from typing import Tuple 4 | from typing import Union 5 | 6 | from django.contrib.contenttypes.models import ContentType 7 | from django.db.models import Model 8 | from django.utils.translation import gettext as _ 9 | 10 | import django_nyt 11 | from . import models 12 | from .conf import app_settings 13 | 14 | 15 | def notify( 16 | message: str, 17 | key: str, 18 | target_object: Any = None, 19 | url: str = None, 20 | filter_exclude: dict = None, 21 | recipient_users: list = None, 22 | ) -> List[models.Notification]: 23 | """ 24 | Notify subscribing users of a new event. Key can be any kind of string, 25 | just make sure to reuse it where applicable. 26 | 27 | Here is the most basic example: Everyone subscribing to the `"new_comments"` key 28 | are sent a notification with the message "New comment posted":: 29 | 30 | notify("New comment posted", "new_comments") 31 | 32 | Here is an example that will create a Notification object for everyone who 33 | has a subscription for the key `"comment/response"` and the model instance `comment_instance`. 34 | The idea would be that the poster of `comment_instance` will receive notifications when someone responds to that comment. 35 | 36 | .. code-block:: python 37 | 38 | notify( 39 | "there was a response to your comment", 40 | "comment/response", 41 | target_object=comment_instance, 42 | url=reverse('comments:view', args=(comment_instance.id,)) 43 | ) 44 | 45 | :param message: A string containing the message that should be sent to all subscribed users. 46 | :param key: A key object which is matched to ``NotificationType.key``. 47 | Users with a Subscription for that NotificationType will have a Notification object created. 48 | :param url: A URL pointing to your notification. 49 | If the URL is pointing to your Django project's unique website, 50 | then add an ``/absolute/url``. However, if the URL should take the user to a different website, 51 | use a full HTTP scheme, i.e. ``https://example.org/url/``. 52 | :param recipient_users: A possible iterable of users that should be notified 53 | instead of notifying all subscribers of the event. 54 | Notice that users still have to be actually subscribed 55 | to the event key! 56 | :param target_object: Any Django model instance that this notification 57 | relates to. Uses Django content types. 58 | Subscriptions with a matching content_type and object_id will be notified. 59 | :param filter_exclude: Keyword arguments passed to filter out Subscriptions. 60 | Will be handed to ``Subscription.objects.exclude(**filter_exclude)``. 61 | """ 62 | 63 | if django_nyt._disable_notifications: 64 | return [] 65 | 66 | if target_object: 67 | if not isinstance(target_object, Model): 68 | raise TypeError( 69 | _( 70 | "You supplied a target_object that's not an instance of a django Model." 71 | ) 72 | ) 73 | object_id = target_object.id 74 | else: 75 | object_id = None 76 | 77 | notifications = models.Notification.create_notifications( 78 | key, 79 | object_id=object_id, 80 | message=message, 81 | url=url, 82 | filter_exclude=filter_exclude, 83 | recipient_users=recipient_users, 84 | ) 85 | 86 | # Notify channel subscribers if we have channels enabled 87 | if app_settings.NYT_ENABLE_CHANNELS: 88 | from django_nyt import subscribers 89 | 90 | subscribers.notify_subscribers(notifications, key) 91 | 92 | return notifications 93 | 94 | 95 | def subscribe( 96 | settings: models.Settings, 97 | key: str, 98 | content_type: Union[str, ContentType] = None, 99 | object_id: Union[int, str] = None, 100 | **kwargs 101 | ) -> models.Subscription: 102 | """ 103 | Creates a new subscription to a given key. If the key does not exist 104 | as a NotificationType, it will be created 105 | 106 | Uses `get_or_create `__ 107 | to avoid double creation. 108 | 109 | :param settings: A models.Settings instance 110 | :param key: The unique key that the Settings should subscribe to 111 | :param content_type: If notifications are regarding a specific ContentType, it should be set 112 | :param object_id: If the notifications should only regard a specific object_id 113 | :param **kwargs: Additional models.Subscription field values 114 | """ 115 | notification_type = models.NotificationType.get_by_key( 116 | key, content_type=content_type 117 | ) 118 | 119 | return models.Subscription.objects.get_or_create( 120 | settings=settings, 121 | notification_type=notification_type, 122 | object_id=object_id, 123 | **kwargs 124 | )[0] 125 | 126 | 127 | def unsubscribe( 128 | key: str, 129 | user: Any = None, 130 | settings: models.Settings = None, 131 | content_type: Union[str, ContentType] = None, 132 | object_id: Union[int, str] = None, 133 | ) -> Tuple[int, dict]: 134 | """ 135 | Shortcut function to remove all subscriptions related to a notification key and either a user or a settings object. 136 | 137 | Unsubscribing does NOT delete old notifications, however the subscription relation is nullified. 138 | This means that any objects accessed through that relation will become inaccessible. 139 | This is a particular feature chosen to avoid accidentally allowing access to data that may be otherwise have credential-based access. 140 | 141 | :param key: The notification key to unsubscribe a user/user settings. 142 | :param user: User to unsubscribe 143 | :param settings: ...or a UserSettings object 144 | :param content_type: Further narrow down subscriptions to only this content_type 145 | :param object_id: Further narrow down subscriptions to only this object_id (provided a content_type) 146 | :return: (int, dict) - the return value of Django's queryset.delete() method. 147 | """ 148 | 149 | assert not (user and settings), "Cannot apply both User and UserSettings object." 150 | assert ( 151 | user or settings 152 | ), "Need at least a User and a UserSettings object, refusing to unsubscribe all." 153 | assert bool(content_type) == bool( 154 | object_id 155 | ), "You have to supply both a content_type and object_id or none of them." 156 | 157 | subscriptions = models.Subscription.objects.filter( 158 | notification_type__key=key, 159 | ) 160 | 161 | if object_id and content_type: 162 | subscriptions = models.Subscription.objects.filter( 163 | notification_type__content_type=content_type, 164 | object_id=object_id, 165 | ) 166 | else: 167 | subscriptions = models.Subscription.objects.filter( 168 | notification_type__content_type=None, 169 | object_id=None, 170 | ) 171 | 172 | if user: 173 | subscriptions = subscriptions.filter( 174 | settings__user=user, 175 | ) 176 | 177 | if settings: 178 | subscriptions = subscriptions.filter( 179 | settings=settings, 180 | ) 181 | 182 | return subscriptions.delete() 183 | -------------------------------------------------------------------------------- /django_nyt/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.decorators import login_required 2 | from django.db.models import Q 3 | from django.shortcuts import get_object_or_404 4 | from django.shortcuts import redirect 5 | from django.utils.translation import gettext as _ 6 | 7 | from django_nyt import models 8 | from django_nyt.decorators import json_view 9 | from django_nyt.decorators import login_required_ajax 10 | 11 | 12 | @login_required_ajax 13 | @json_view 14 | def get_notifications(request, latest_id=None, is_viewed=False, max_results=10): 15 | """ 16 | View that returns a JSON list of notifications for the current user as according 17 | to ``request.user``. 18 | 19 | :param: latest_id: The latest id of a notification. Use this to avoid 20 | retrieving the same notifications multiple times. 21 | :param: is_viewed: Set this to ``True`` if you also want to retrieve 22 | notifications that have already been viewed. 23 | 24 | :returns: An HTTPResponse object with JSON data:: 25 | 26 | {'success': True, 27 | 'total_count': total_count, 28 | 'objects': [{'pk': n.pk, 29 | 'message': n.message, 30 | 'url': n.url, 31 | 'occurrences': n.occurrences, 32 | 'occurrences_msg': _('%d times') % n.occurrences, 33 | 'type': n.subscription.notification_type.key if n.subscription else None, 34 | 'since': naturaltime(n.created)} for n in notifications[:max_results]]} 35 | """ 36 | 37 | notifications = models.Notification.objects.filter( 38 | Q(subscription__settings__user=request.user) | Q(user=request.user), 39 | ) 40 | 41 | if is_viewed is not None: 42 | notifications = notifications.filter(is_viewed=is_viewed) 43 | 44 | total_count = notifications.count() 45 | 46 | if latest_id is not None: 47 | notifications = notifications.filter(id__gt=latest_id) 48 | 49 | notifications = notifications.order_by("-id") 50 | notifications = notifications.prefetch_related( 51 | "subscription", "subscription__notification_type" 52 | ) 53 | 54 | from django.contrib.humanize.templatetags.humanize import naturaltime 55 | 56 | return { 57 | "success": True, 58 | "total_count": total_count, 59 | "objects": [ 60 | { 61 | "pk": n.pk, 62 | "message": n.message, 63 | "url": n.url, 64 | "occurrences": n.occurrences, 65 | "occurrences_msg": _("%d times") % n.occurrences, 66 | "type": n.subscription.notification_type.key 67 | if n.subscription 68 | else None, 69 | "since": naturaltime(n.created), 70 | } 71 | for n in notifications[:max_results] 72 | ], 73 | } 74 | 75 | 76 | @login_required 77 | def goto(request, notification_id=None): 78 | referer = request.META.get("HTTP_REFERER", "") 79 | if not notification_id: 80 | return redirect(referer) 81 | notification = get_object_or_404( 82 | models.Notification, 83 | Q(subscription__settings__user=request.user) | Q(user=request.user), 84 | id=notification_id, 85 | ) 86 | notification.is_viewed = True 87 | notification.save() 88 | if notification.url: 89 | return redirect(notification.url) 90 | return redirect(referer) 91 | 92 | 93 | @login_required_ajax 94 | @json_view 95 | def mark_read(request, id_lte, notification_type_id=None, id_gte=None): 96 | 97 | notifications = models.Notification.objects.filter( 98 | Q(subscription__settings__user=request.user) | Q(user=request.user), 99 | id__lte=id_lte, 100 | ) 101 | 102 | if notification_type_id: 103 | notifications = notifications.filter(notification_type__id=notification_type_id) 104 | 105 | if id_gte: 106 | notifications = notifications.filter(id__gte=id_gte) 107 | 108 | notifications.update(is_viewed=True) 109 | 110 | return {"success": True} 111 | -------------------------------------------------------------------------------- /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) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-nyt.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-nyt.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-nyt" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-nyt" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-nyt documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Jan 10 21:56:57 2014. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | import os 14 | import sys 15 | from datetime import datetime 16 | 17 | import django 18 | 19 | # If extensions (or modules to document with autodoc) are in another directory, 20 | # add these directories to sys.path here. If the directory is relative to the 21 | # documentation root, use os.path.abspath to make it absolute, like shown here. 22 | 23 | sys.path.insert(0, os.path.abspath("..")) 24 | sys.path.insert(0, os.path.abspath("../test-project")) 25 | 26 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproject.settings") 27 | 28 | django.setup() 29 | 30 | # -- General configuration ----------------------------------------------------- 31 | 32 | # If your documentation needs a minimal Sphinx version, state it here. 33 | # needs_sphinx = '1.0' 34 | 35 | # Add any Sphinx extension module names here, as strings. They can be extensions 36 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 37 | extensions = ["sphinx.ext.autodoc", "sphinx.ext.extlinks"] 38 | 39 | extlinks = { 40 | "url-issue": ("https://github.com/django-wiki/django-nyt/issues/%s", "#%s"), 41 | "url-pr": ("https://github.com/django-wiki/django-nyt/pull/%s", "#%s"), 42 | } 43 | 44 | # Add any paths that contain templates here, relative to this directory. 45 | templates_path = ["_templates"] 46 | 47 | # The suffix of source filenames. 48 | source_suffix = ".rst" 49 | 50 | # The encoding of source files. 51 | # source_encoding = 'utf-8-sig' 52 | 53 | # The master toctree document. 54 | master_doc = "index" 55 | 56 | # General information about the project. 57 | project = "django-nyt" 58 | copyright = "{}, Benjamin Bach".format(datetime.now().year) # @ReservedAssignment 59 | 60 | # The version info for the project you're documenting, acts as replacement for 61 | # |version| and |release|, also used in various other places throughout the 62 | # built documents. 63 | # 64 | # The short X.Y version. 65 | 66 | from django_nyt import __version__ # NOQA 67 | 68 | version = __version__ 69 | # The full version, including alpha/beta/rc tags. 70 | release = __version__ 71 | 72 | # The language for content autogenerated by Sphinx. Refer to documentation 73 | # for a list of supported languages. 74 | # language = None 75 | 76 | # There are two options for replacing |today|: either, you set today to some 77 | # non-false value, then it is used: 78 | # today = '' 79 | # Else, today_fmt is used as the format for a strftime call. 80 | # today_fmt = '%B %d, %Y' 81 | 82 | # List of patterns, relative to source directory, that match files and 83 | # directories to ignore when looking for source files. 84 | exclude_patterns = ["_build"] 85 | 86 | # The reST default role (used for this markup: `text`) to use for all documents. 87 | # default_role = None 88 | 89 | # If true, '()' will be appended to :func: etc. cross-reference text. 90 | # add_function_parentheses = True 91 | 92 | # If true, the current module name will be prepended to all description 93 | # unit titles (such as .. function::). 94 | # add_module_names = True 95 | 96 | # If true, sectionauthor and moduleauthor directives will be shown in the 97 | # output. They are ignored by default. 98 | # show_authors = False 99 | 100 | # The name of the Pygments (syntax highlighting) style to use. 101 | pygments_style = "sphinx" 102 | 103 | # A list of ignored prefixes for module index sorting. 104 | # modindex_common_prefix = [] 105 | 106 | 107 | # -- Options for HTML output --------------------------------------------------- 108 | 109 | on_rtd = os.environ.get("READTHEDOCS", None) == "True" 110 | 111 | 112 | if on_rtd: 113 | os.system( 114 | "sphinx-apidoc --doc-project='Python Reference' -f -o . ../django_nyt ../django_nyt/tests ../django_nyt/migrations" 115 | ) 116 | 117 | html_theme = "sphinx_rtd_theme" 118 | 119 | # This is in order to have a simpler display for autodoc'ed pages 120 | add_module_names = False 121 | 122 | # Theme options are theme-specific and customize the look and feel of a theme 123 | # further. For a list of options available for each theme, see the 124 | # documentation. 125 | # html_theme_options = {} 126 | 127 | # Add any paths that contain custom themes here, relative to this directory. 128 | # html_theme_path = [] 129 | 130 | # The name for this set of Sphinx documents. If None, it defaults to 131 | # " v documentation". 132 | # html_title = None 133 | 134 | # A shorter title for the navigation bar. Default is the same as html_title. 135 | # html_short_title = None 136 | 137 | # The name of an image file (relative to this directory) to place at the top 138 | # of the sidebar. 139 | # html_logo = None 140 | 141 | # The name of an image file (within the static path) to use as favicon of the 142 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 143 | # pixels large. 144 | # html_favicon = None 145 | 146 | # Add any paths that contain custom static files (such as style sheets) here, 147 | # relative to this directory. They are copied after the builtin static files, 148 | # so a file named "default.css" will overwrite the builtin "default.css". 149 | html_static_path = [] 150 | 151 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 152 | # using the given strftime format. 153 | # html_last_updated_fmt = '%b %d, %Y' 154 | 155 | # If true, SmartyPants will be used to convert quotes and dashes to 156 | # typographically correct entities. 157 | # html_use_smartypants = True 158 | 159 | # Custom sidebar templates, maps document names to template names. 160 | # html_sidebars = {} 161 | 162 | # Additional templates that should be rendered to pages, maps page names to 163 | # template names. 164 | # html_additional_pages = {} 165 | 166 | # If false, no module index is generated. 167 | # html_domain_indices = True 168 | 169 | # If false, no index is generated. 170 | # html_use_index = True 171 | 172 | # If true, the index is split into individual pages for each letter. 173 | # html_split_index = False 174 | 175 | # If true, links to the reST sources are added to the pages. 176 | # html_show_sourcelink = True 177 | 178 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 179 | # html_show_sphinx = True 180 | 181 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 182 | # html_show_copyright = True 183 | 184 | # If true, an OpenSearch description file will be output, and all pages will 185 | # contain a tag referring to it. The value of this option must be the 186 | # base URL from which the finished HTML is served. 187 | # html_use_opensearch = '' 188 | 189 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 190 | # html_file_suffix = None 191 | 192 | # Output file base name for HTML help builder. 193 | htmlhelp_basename = "django-nytdoc" 194 | 195 | 196 | # -- Options for LaTeX output -------------------------------------------------- 197 | 198 | latex_elements = { 199 | # The paper size ('letterpaper' or 'a4paper'). 200 | # 'papersize': 'letterpaper', 201 | # The font size ('10pt', '11pt' or '12pt'). 202 | # 'pointsize': '10pt', 203 | # Additional stuff for the LaTeX preamble. 204 | # 'preamble': '', 205 | } 206 | 207 | # Grouping the document tree into LaTeX files. List of tuples 208 | # (source start file, target name, title, author, documentclass [howto/manual]). 209 | latex_documents = [ 210 | ("index", "django-nyt.tex", "django-nyt Documentation", "Benjamin Bach", "manual"), 211 | ] 212 | 213 | # The name of an image file (relative to this directory) to place at the top of 214 | # the title page. 215 | # latex_logo = None 216 | 217 | # For "manual" documents, if this is true, then toplevel headings are parts, 218 | # not chapters. 219 | # latex_use_parts = False 220 | 221 | # If true, show page references after internal links. 222 | # latex_show_pagerefs = False 223 | 224 | # If true, show URL addresses after external links. 225 | # latex_show_urls = False 226 | 227 | # Documents to append as an appendix to all manuals. 228 | # latex_appendices = [] 229 | 230 | # If false, no module index is generated. 231 | # latex_domain_indices = True 232 | 233 | 234 | # -- Options for manual page output -------------------------------------------- 235 | 236 | # One entry per manual page. List of tuples 237 | # (source start file, name, description, authors, manual section). 238 | man_pages = [("index", "django-nyt", "django-nyt Documentation", ["Benjamin Bach"], 1)] 239 | 240 | # If true, show URL addresses after external links. 241 | # man_show_urls = False 242 | 243 | 244 | # -- Options for Texinfo output ------------------------------------------------ 245 | 246 | # Grouping the document tree into Texinfo files. List of tuples 247 | # (source start file, target name, title, author, 248 | # dir menu entry, description, category) 249 | texinfo_documents = [ 250 | ( 251 | "index", 252 | "django-nyt", 253 | "django-nyt Documentation", 254 | "Benjamin Bach", 255 | "django-nyt", 256 | "One line description of project.", 257 | "Miscellaneous", 258 | ), 259 | ] 260 | 261 | # Documents to append as an appendix to all manuals. 262 | # texinfo_appendices = [] 263 | 264 | # If false, no module index is generated. 265 | # texinfo_domain_indices = True 266 | 267 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 268 | # texinfo_show_urls = 'footnote' 269 | -------------------------------------------------------------------------------- /docs/howto/channels.rst: -------------------------------------------------------------------------------- 1 | How to setup django-channels & websockets 2 | ========================================= 3 | 4 | Avoiding constant polling is important because: 5 | 6 | #. polling is repetitive and unnecessary 7 | #. each request occupies the whole django application stack 8 | #. whatever your polling interval, it's not real time! 9 | 10 | Now, django-nyt comes with pre-configured tasks for django-channels and 11 | JavaScript snippets that will help you to quickly get started. Just follow the 12 | steps below. 13 | 14 | Configure channels 15 | ------------------ 16 | 17 | You need channels to be running a websocket server. The below example can be 18 | used for a development server, but other than that, you would have to configure 19 | a real integration with a channel worker server and Redis. 20 | 21 | .. code-block:: python 22 | 23 | INSTALLED_APPS.append('channels') 24 | CHANNEL_LAYERS = { 25 | "default": { 26 | "BACKEND": "asgiref.inmemory.ChannelLayer", 27 | "ROUTING": "django_nyt.routing.channel_routing", 28 | }, 29 | } 30 | 31 | 32 | For more on deployment, read `the Channels documentation `_. 33 | 34 | 35 | Communicating with websockets 36 | ----------------------------- 37 | 38 | On the client-side, meaning in your Django templates and static files, you 39 | need to add some JavaScript to make everything work. This is the essential 40 | stuff, copy it where ever you deem fit (it needs JQuery loaded): 41 | 42 | The essential bit below is to customize the various HTML DOM elements that are 43 | mentioned, but you can also use the same CSS class names as defined in 44 | :doc:`html`. 45 | 46 | .. code-block:: html+django 47 | 48 | 147 | -------------------------------------------------------------------------------- /docs/howto/emails.rst: -------------------------------------------------------------------------------- 1 | How to send email digests 2 | ------------------------- 3 | 4 | A management script is supplied for sending out emails. 5 | 6 | Here are 3 alternative ways of getting email notifications sent out: 7 | 8 | #. As a Celery task 9 | #. As a daemon 10 | #. As a cronjob 11 | 12 | 13 | Celery integration 14 | ~~~~~~~~~~~~~~~~~~ 15 | 16 | If you use Celery, you could probably see it easily done to add a simple 17 | scheduled task with Celery Beat that calls 18 | 19 | If you don't use Celery already, we cannot recommend to deploy Celery simply for 20 | the sake of sending out these emails. Instead, it's probably easier to use one 21 | of the other methods. 22 | 23 | You need to run something like this: 24 | 25 | .. code-block:: python 26 | 27 | from django.core.management import call_command 28 | 29 | @shared_task 30 | def send_nyt_emails() 31 | call_command('notifymail', cron=True) 32 | 33 | 34 | Schedule the task as often as you want instant email notifications to be 35 | guaranteed to reach the user. If you are using channels, you don't need to 36 | worry about instant notifications as they're sent asynchronously in this case. 37 | 38 | You can also hook the scheduling of the task to the ``post_save`` signal on 39 | ``django_nyt.models.Notification`` (TODO: write example code). 40 | 41 | 42 | Crontab 43 | ~~~~~~~ 44 | 45 | Schedule the task as often as you want instant email notifications to be 46 | guaranteed to reach the user. If you are using channels, you don't need to 47 | worry about instant notifications as they're sent asynchronously in this case. 48 | 49 | .. code-block:: bash 50 | 51 | sudo su YOUR_HTTPD_USER -c bash # e.g. www-data 52 | crontab -e # Edit the cron tab 53 | 54 | This is the code you should add to your crontab: 55 | 56 | .. code-block:: bash 57 | 58 | /path/to/your/virtualenv/bin/python /path/to/project/manage.py notifymail --cron 59 | 60 | 61 | Daemon 62 | ~~~~~~ 63 | 64 | Instead of adding a crontab, you can also have the ``notifymail`` script run as 65 | a daemon. 66 | 67 | .. code-block:: bash 68 | 69 | /path/to/your/virtualenv/bin/python /path/to/project/manage.py notifymail --daemon 70 | 71 | For more configurability of the daemon, run ``manage.py help notifymail``. 72 | -------------------------------------------------------------------------------- /docs/howto/index.rst: -------------------------------------------------------------------------------- 1 | How-to guides 2 | ============= 3 | 4 | .. toctree:: 5 | :maxdepth: 1 6 | :glob: 7 | 8 | * 9 | 10 | Adding a notification 11 | --------------------- 12 | 13 | .. code-block:: python 14 | 15 | from django_nyt.utils import notify 16 | 17 | EVENT_KEY = "my_key" 18 | notify(_("OMG! Something happened"), EVENT_KEY) 19 | 20 | 21 | Subscribing to a specific object 22 | -------------------------------- 23 | 24 | The Subscription model has a generic relation which can link it to any other object. 25 | This means that aside from subscribing users to a universal event (i.e. "a comment was updated"), 26 | users can also be subscribed to specific object (i.e. "*your* comment was updated"). 27 | 28 | .. code-block:: python 29 | 30 | notify(_("OMG! Something happened"), EVENT_KEY, target_object=my_model_instance) 31 | 32 | In order to subscribe a user to receive notifications only for a specific object, 33 | you can create the subscription like this: 34 | 35 | .. code-block:: python 36 | 37 | subscribe( 38 | user_setting, 39 | EVENT_KEY, 40 | content_type=ContentType.objects.get_for_model(MyModel), 41 | object_id=my_model_instance.id, 42 | ) 43 | 44 | 45 | Excluding certain recipients 46 | ---------------------------- 47 | 48 | By setting the kwarg ``filter_exclude`` to a dictionary of lookup fields for 49 | ``models.Subscription``, you may exclude certain users from getting a notification. 50 | 51 | For instance, if a notification is solely for staff members, we can exclude all the users that aren't: 52 | 53 | .. code-block:: python 54 | 55 | notify( 56 | _("OMG! Something happened"), EVENT_KEY, 57 | filter_exclude={'settings__user__is_staff': False} 58 | ) 59 | 60 | 61 | Disabling notifications 62 | ----------------------- 63 | 64 | Use ``decorators.disable_notify`` to ensure that all notifications within a function are disabled. 65 | 66 | For instance: 67 | 68 | .. code-block:: python 69 | 70 | from django_nyt.decorators import disable_notify 71 | @disable_notify 72 | def my_view(request): 73 | ... 74 | 75 | 76 | Case: Django-wiki integration 77 | ----------------------------- 78 | 79 | Django-nyt is integrated with django-wiki by enabling ``wiki.plugins.notifications``. 80 | -------------------------------------------------------------------------------- /docs/howto/javascript.rst: -------------------------------------------------------------------------------- 1 | How to do JavaScript polling 2 | ============================ 3 | 4 | In order to really make use of a notification system, you would probably want a small icon in the top corner of your websites. Like the privacy destroying 5 | villains at facebook have. Or the wonderful innovation heroes at Github have. 6 | 7 | Method: 8 | 9 | 1. Put all your logic in your own ui.js file. There is so much presentation logic in this stuff, that we can hardly ship anything useful. 10 | You have to build that from scratch. 11 | 2. Initialize your javascript environment with the URLs necessary to use your js file: 12 | 13 | .. code-block:: html+django 14 | 15 | 20 | 21 | 22 | 23 | 24 | Example ui.js 25 | ------------- 26 | 27 | **YOU NEED JQUERY** 28 | 29 | Create the necessary elements for this javascript to run, and you should have a pretty useful little menu at the top of your website. 30 | 31 | .. code-block:: javascript 32 | 33 | var nyt_oldest_id = 0; 34 | var nyt_latest_id = 0; 35 | var nyt_update_timeout = 30000; 36 | var nyt_update_timeout_adjust = 1.2; // factor to adjust between each timeout. 37 | 38 | function ajaxError(){} 39 | 40 | $.ajaxSetup({ 41 | timeout: 7000, 42 | cache: false, 43 | error: function(e, xhr, settings, exception) { 44 | ajaxError(); 45 | } 46 | }); 47 | 48 | function jsonWrapper(url, callback) { 49 | $.getJSON(url, function(data) { 50 | if (data == null) { 51 | ajaxError(); 52 | } else { 53 | callback(data); 54 | } 55 | }); 56 | } 57 | 58 | function nyt_update() { 59 | jsonWrapper(URL_NYT_GET_NEW+nyt_latest_id+'/', function (data) { 60 | if (data.success) { 61 | $('.notification-cnt').html(data.total_count); 62 | if (data.objects.length> 0) { 63 | $('.notification-cnt').addClass('badge-important'); 64 | $('.notifications-empty').hide(); 65 | } else { 66 | $('.notification-cnt').removeClass('badge-important'); 67 | } 68 | for (var i=data.objects.length-1; i >=0 ; i--) { 69 | var n = data.objects[i]; 70 | nyt_latest_id = n.pk>nyt_latest_id ? n.pk:nyt_latest_id; 71 | nyt_oldest_id = (n.pk 1) { 73 | element = $('
  • '+n.message+'
    '+n.occurrences_msg+' - ' + n.since + '
  • ') 74 | } else { 75 | element = $('
  • '+n.message+'
    '+n.since+'
  • '); 76 | } 77 | element.addClass('notification-li'); 78 | element.insertAfter('.notification-before-list'); 79 | } 80 | } 81 | }); 82 | } 83 | 84 | function nyt_mark_read() { 85 | $('.notification-li-container').empty(); 86 | url = URL_NYT_MARK_READ+nyt_latest_id+'/'+nyt_oldest_id+'/'; 87 | nyt_oldest_id = 0; 88 | nyt_latest_id = 0; 89 | jsonWrapper(url, function (data) { 90 | if (data.success) { 91 | nyt_update(); 92 | } 93 | }); 94 | } 95 | 96 | function update_timeout() { 97 | setTimeout("nyt_update()", nyt_update_timeout); 98 | setTimeout("update_timeout()", nyt_update_timeout); 99 | nyt_update_timeout *= nyt_update_timeout_adjust; 100 | } 101 | 102 | $(document).ready(function () { 103 | update_timeout(); 104 | }); 105 | 106 | // Don't check immediately... some users just click through pages very quickly. 107 | setTimeout("nyt_update()", 2000); 108 | 109 | Example HTML 110 | ------------ 111 | 112 | In order for the example JavaScript for websockets and snippets to work, we 113 | have assumed a list with notifications. The list contains a 114 | ``.notification-before-list`` element which indicates to the JavaScript code 115 | that all ``
  • ``'s should be appended after this element. Inside this element, 116 | we also have the ``.notification-cnt`` which is updated every time new 117 | notifications arrive or are marked as read. 118 | 119 | .. code-block:: html+django 120 | 121 |

    Notifications:

    122 | 126 | 127 | 128 | 129 | {% trans "Clear notifications list" %} 130 | 131 | -------------------------------------------------------------------------------- /docs/howto/object_relations.rst: -------------------------------------------------------------------------------- 1 | How to map a related object to a notification 2 | ============================================= 3 | 4 | Since django-nyt is based on Django, we encourage you to extend functionality by writing your own models. 5 | 6 | A common scenario is when you want to provide extra data with a notification. 7 | Typically, users are notified about a specific object being created or updated. 8 | You can keep this relation by writing your own relational model. 9 | 10 | For instance, if we want to track that a notification is for a specific Comment, 11 | we can store this relation in such a model: 12 | 13 | .. code-block:: python 14 | 15 | class CommentNotification(models.Model): 16 | notification = models.OneToOneField( 17 | "django_nyt.Notification", 18 | on_delete=models.CASCADE, 19 | related_name="comment", 20 | ) 21 | comment = models.ForeignKey( 22 | Comment, 23 | on_delete=models.CASCADE, 24 | ) 25 | 26 | Then we can plugin a Django signal to create the notification and store the new relation: 27 | 28 | .. code-block:: python 29 | 30 | @receiver(post_save, sender=Comment) 31 | def event_published(**kwargs): 32 | comment = kwargs.get("comment") 33 | 34 | # Notify everyone except for the comment author (they know!) 35 | notifications = notify( 36 | f"Comment created {comment.title}", 37 | key=COMMENT_CREATED, 38 | url=reverse("comments:comment_detail", kwargs={"pk": comments.id}), 39 | filter_exclude={"settings__user": comment.author}, 40 | ) 41 | for notification in notifications: 42 | models.CommentNotification.objects.create( 43 | notification=notification, 44 | comment=comment, 45 | ) 46 | 47 | After this, we can access the comment related to a notifications. 48 | For instance, if we have defined a custom template for the ``COMMENT_CREATED`` key, 49 | we could send out an email to a moderator and display the whole comment text in the email. 50 | Emails generated by django-nyt all receive their notifications in the template context, 51 | and therefore accessing the comments can be done with ``notification.comment`` (defined by the reverse relation on the ``OneToOneField``.) 52 | 53 | Notes on security 54 | ----------------- 55 | 56 | If a user has certain credentials in your system that are revoked at a later time, 57 | then they may still gain access through a relation by accessing a previous notification. 58 | 59 | As you may choose to revoke certain access, you should always design your system to anticipate this. 60 | 61 | It's therefore important that you deal with these relations in a sensible manner: 62 | 63 | * Delete relations when credentials are revoked 64 | * Do not use relations to display sensitive information or grant access 65 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to django-nyt's documentation! 2 | ====================================== 3 | 4 | Contents: 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | 9 | installation 10 | howto/index 11 | reference/index 12 | 13 | .. include:: ../README.rst 14 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | From PyPi 5 | --------- 6 | 7 | Simply run the good old ``pip install django-nyt`` and you'll have the ``django_nyt`` package available. 8 | 9 | 10 | Configuring your project 11 | ------------------------ 12 | 13 | Before django-nyt can be used, you need to migrate its models. This is also 14 | necessary if you update it. 15 | 16 | .. code-block:: bash 17 | 18 | python manage.py migrate django_nyt 19 | 20 | 21 | INSTALLED_APPS 22 | ~~~~~~~~~~~~~~ 23 | 24 | .. code-block:: python 25 | 26 | INSTALLED_APPS = ( 27 | ... 28 | 'django_nyt.apps.DjangoNytConfig' 29 | ... 30 | ) 31 | 32 | 33 | urlconf 34 | ~~~~~~~ 35 | 36 | You need to add the following patterns to get JSON views. You need to add it to your projects urlconf, typically ``urls.py``. 37 | 38 | .. code-block:: python 39 | 40 | urlpatterns += [ 41 | url(r'^notifications/', include('django_nyt.urls')), 42 | ] 43 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. linkcheck to check all external links for integrity 37 | echo. doctest to run all doctests embedded in the documentation if enabled 38 | goto end 39 | ) 40 | 41 | if "%1" == "clean" ( 42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 43 | del /q /s %BUILDDIR%\* 44 | goto end 45 | ) 46 | 47 | if "%1" == "html" ( 48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 49 | if errorlevel 1 exit /b 1 50 | echo. 51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 52 | goto end 53 | ) 54 | 55 | if "%1" == "dirhtml" ( 56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 57 | if errorlevel 1 exit /b 1 58 | echo. 59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 60 | goto end 61 | ) 62 | 63 | if "%1" == "singlehtml" ( 64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 68 | goto end 69 | ) 70 | 71 | if "%1" == "pickle" ( 72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished; now you can process the pickle files. 76 | goto end 77 | ) 78 | 79 | if "%1" == "json" ( 80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished; now you can process the JSON files. 84 | goto end 85 | ) 86 | 87 | if "%1" == "htmlhelp" ( 88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can run HTML Help Workshop with the ^ 92 | .hhp project file in %BUILDDIR%/htmlhelp. 93 | goto end 94 | ) 95 | 96 | if "%1" == "qthelp" ( 97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 98 | if errorlevel 1 exit /b 1 99 | echo. 100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 101 | .qhcp project file in %BUILDDIR%/qthelp, like this: 102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-nyt.qhcp 103 | echo.To view the help file: 104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-nyt.ghc 105 | goto end 106 | ) 107 | 108 | if "%1" == "devhelp" ( 109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 110 | if errorlevel 1 exit /b 1 111 | echo. 112 | echo.Build finished. 113 | goto end 114 | ) 115 | 116 | if "%1" == "epub" ( 117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 118 | if errorlevel 1 exit /b 1 119 | echo. 120 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 121 | goto end 122 | ) 123 | 124 | if "%1" == "latex" ( 125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 129 | goto end 130 | ) 131 | 132 | if "%1" == "text" ( 133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The text files are in %BUILDDIR%/text. 137 | goto end 138 | ) 139 | 140 | if "%1" == "man" ( 141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 145 | goto end 146 | ) 147 | 148 | if "%1" == "texinfo" ( 149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 150 | if errorlevel 1 exit /b 1 151 | echo. 152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 153 | goto end 154 | ) 155 | 156 | if "%1" == "gettext" ( 157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 158 | if errorlevel 1 exit /b 1 159 | echo. 160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 161 | goto end 162 | ) 163 | 164 | if "%1" == "changes" ( 165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 166 | if errorlevel 1 exit /b 1 167 | echo. 168 | echo.The overview file is in %BUILDDIR%/changes. 169 | goto end 170 | ) 171 | 172 | if "%1" == "linkcheck" ( 173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 174 | if errorlevel 1 exit /b 1 175 | echo. 176 | echo.Link check complete; look for any errors in the above output ^ 177 | or in %BUILDDIR%/linkcheck/output.txt. 178 | goto end 179 | ) 180 | 181 | if "%1" == "doctest" ( 182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 183 | if errorlevel 1 exit /b 1 184 | echo. 185 | echo.Testing of doctests in the sources finished, look at the ^ 186 | results in %BUILDDIR%/doctest/output.txt. 187 | goto end 188 | ) 189 | 190 | :end 191 | -------------------------------------------------------------------------------- /docs/misc/screenshot_dropdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-wiki/django-nyt/af833c9be6e8af9527c421d811b384f12461650a/docs/misc/screenshot_dropdown.png -------------------------------------------------------------------------------- /docs/reference/configuration.rst: -------------------------------------------------------------------------------- 1 | Configuration 2 | ============= 3 | 4 | .. automodule:: django_nyt.conf 5 | :noindex: 6 | 7 | .. autoclass:: AppSettings() 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/reference/index.rst: -------------------------------------------------------------------------------- 1 | Reference 2 | ========= 3 | 4 | .. toctree:: 5 | :maxdepth: 1 6 | :glob: 7 | 8 | configuration 9 | ../modules 10 | ../release_notes 11 | -------------------------------------------------------------------------------- /docs/release_notes.rst: -------------------------------------------------------------------------------- 1 | Release Notes 2 | ============= 3 | 4 | 1.4.2 (2025-04-23) 5 | ------------------ 6 | 7 | **Added** 8 | 9 | * Django 5.2 support #146 (Daniel Grießhaber) 10 | 11 | 12 | 1.4.1 (2024-08-21) 13 | ------------------ 14 | 15 | **Added** 16 | 17 | * Django 5.1 support #145 (Benjamin Balder Bach) 18 | 19 | 1.4 (2024-02-16) 20 | ---------------- 21 | 22 | Hello 👋️ We're alive and maintaining this! 23 | 24 | This release puts django-nyt a step forwards toward being a mature framework, especially by adding Django 5 support, fixing issues and adding more tests. 25 | However, there are still some async capabilities remaining to be solved for it to be both mature and modern. 26 | 27 | If you can get behind :doc:`the concept `, please consider the great potential of contributing to this project! 28 | 29 | 30 | **Added** 31 | 32 | * Custom email templates per notification type: 33 | For instance, a site admin and a user may now receive different notification emails, both for content and subject line. 34 | This is controlled by two new dictionaries in your settings ``NYT_EMAIL_TEMPLATE_NAMES`` and ``NYT_EMAIL_SUBJECT_TEMPLATE_NAMES``. 35 | Templates are matched to a notification key pattern that uses glob expressions, 36 | so it's now encouraged to use ``/`` separators in notification keys, 37 | for instance ``comments/new`` is matched by ``comments/**``. #125 (Benjamin Balder Bach) 38 | * Django 5 support #128 (Benjamin Balder Bach) 39 | * New arguments ``--domain`` and ``--http-only`` for management command ``notifymail``. #130 (Benjamin Balder Bach) 40 | * Documentation reorganized with Diataxis structure and more how-to guides have been added. (Benjamin Balder Bach) 41 | * New shortcut function ``utils.unsubscribe()``. #137 (Benjamin Balder Bach) 42 | * Better logging for ``notifymail`` management command #141 (Benjamin Balder Bach) 43 | * Added fields ``created`` and ``modified`` on models ``Settings`` and ``Subscription`` #142 (Benjamin Balder Bach) 44 | 45 | **Changed** 46 | 47 | * Tests migrated to pytest #123 (Benjamin Balder Bach) 48 | * Default email notification paths are changed to ``notifications/emails/default.txt`` and ``notifications/emails/default_subject.txt`` #125 (Benjamin Balder Bach) 49 | * Notification URLs added to emails have a hard-coded `https://` (before, this was `http://`) #125 (Benjamin Balder Bach) 50 | * New test-friendly settings pattern changes internal names of settings, but has no effects on Django settings #127 (Benjamin Balder Bach) 51 | * Corrected name of method to ``Settings.get_default_settings`` #129 (Benjamin Balder Bach) 52 | * Improvements to docstrings of main methods ``notify()`` and ``subscribe()`` #129 (Benjamin Balder Bach) 53 | * Return value of ``notify()`` was changed - it no longer returns an `int` (number of created notifications), instead it returns a list of created notifications. 54 | This is very useful, see :doc:`howto/object_relations` #134 (Benjamin Balder Bach) 55 | * The field ``Notification.url`` now accepts 2,000 characters rather than 200 #142 (Benjamin Balder Bach) 56 | 57 | **Fixed** 58 | 59 | * Template files possible for email subjects. Previously, this file was ignored #125 (Benjamin Balder Bach) 60 | * Notifications without URLs had a broken URL in emails #125 (Benjamin Balder Bach) 61 | * Management command ``notifymail`` to send emails is more robust #129 (Benjamin Balder Bach) 62 | * ``Settings.save`` recursively called itself when adding a non-default setting #140 (Benjamin Balder Bach) 63 | * Weekly digests weren't correctly generated #142 (Benjamin Balder Bach) 64 | 65 | **Removed** 66 | 67 | * Python 3.7 support removed #129 (Benjamin Balder Bach) 68 | * Unused (!) setting ``NYT_ENABLED`` was removed #134 (Benjamin Balder Bach) 69 | 70 | 1.3 (2023-05-03) 71 | ---------------- 72 | 73 | * Hatch build system, environment management and more #116 (Oscar Cortez) 74 | * pre-commit configuration updated #116 (Oscar Cortez) 75 | * Code-base black'ned #116 (Oscar Cortez) 76 | 77 | 78 | 1.2.4 (2022-11-11) 79 | ------------------ 80 | 81 | * Adds Django 4.1 support #113 (Oscar Cortez) 82 | 83 | 84 | 1.2.3 85 | ----- 86 | 87 | * Add missing .txt email template files in distributed packages #109 88 | 89 | 90 | 1.2.2 91 | ----- 92 | 93 | * Adds a no-op migration because of auto-detected changes 94 | 95 | 96 | 1.2.1 97 | ----- 98 | 99 | * Django 4.0 and Python 3.10 support (added to test matrix) 100 | 101 | 102 | 1.2 103 | --- 104 | 105 | Added 106 | ^^^^^ 107 | 108 | * Django 3.2 and Python 3.9 support (added to test matrix) 109 | * Travis replaced with Circle CI 110 | 111 | Removed 112 | ^^^^^^^ 113 | 114 | * Django 1.11 and 2.1 support 115 | 116 | 117 | 1.1.6 118 | ----- 119 | 120 | Added 121 | ^^^^^ 122 | 123 | * Django 3.1 support (added to test matrix) 124 | 125 | 1.1.5 126 | ----- 127 | 128 | Fixed 129 | ^^^^^ 130 | 131 | * Do not access ``Settings.user`` in ``Settings.clean()`` on blank new objects :url-pr:`92` 132 | 133 | 134 | 1.1.4 135 | ----- 136 | 137 | Added 138 | ^^^^^ 139 | 140 | * Django 3.0 support (added to test matrix) 141 | 142 | 143 | 1.1.3 144 | ----- 145 | 146 | Added 147 | ^^^^^ 148 | 149 | * Django 2.2 support (added to test matrix) 150 | * Linting (no changes to functionality) 151 | 152 | 153 | 1.1.2 154 | ----- 155 | 156 | Added 157 | ^^^^^ 158 | 159 | * Django 2.1 support (no changes in code) 160 | 161 | 162 | 1.1.1 163 | ----- 164 | 165 | Added 166 | ^^^^^ 167 | 168 | * Python 3.7 support :url-pr:`81` 169 | 170 | Deprecations 171 | ^^^^^^^^^^^^ 172 | 173 | * Removed ``django_nyt.notify``, use ``django_nyt.utils.notify`` 174 | 175 | 176 | 177 | 1.1 178 | --- 179 | 180 | New features 181 | ^^^^^^^^^^^^ 182 | 183 | * Django 2.0 support :url-pr:`55` 184 | 185 | Bug fixes 186 | ^^^^^^^^^ 187 | 188 | * Restored missing translation files :url-pr:`73` 189 | 190 | Deprecations 191 | ^^^^^^^^^^^^ 192 | 193 | * Django < 1.11 support is dropped :url-pr:`62` 194 | * Python < 3.4 support is dropped :url-pr:`65` and :url-pr:`68` 195 | * Deprecate ``django_nyt.urls.get_pattern``, use ``include('django_nyt.urls')`` instead :url-pr:`63` 196 | * Removed ``django_nyt.VERSION``, use `django_nyt.__version__` instead :url-pr:`73` 197 | 198 | 1.0 199 | --- 200 | 201 | Starting from django-nyt 1.0, support for the upcoming 202 | `channels `_ has been added together with 203 | Django 1.9, 1.10 and 1.11 support. 204 | 205 | You can switch off django-channels by setting 206 | ``settings.NYT_CHANNELS_DISABLE = True``. 207 | 208 | 209 | New features 210 | ^^^^^^^^^^^^ 211 | 212 | * Support for ``channels`` and web sockets. :url-pr:`21` 213 | * Django 1.9, 1.10, and 1.11 support :url-pr:`25` 214 | * Default AppConfig ``"django_nyt.apps.DjangoNytConfig"`` :url-pr:`57` 215 | 216 | 217 | Bug fixes 218 | ^^^^^^^^^ 219 | 220 | * Celery will auto-load ``django_nyt.tasks`` when ``channels`` isn't installed :url-issue:`23` 221 | * Error in channels consumer when requested with AnonymousUser (Benjamin Bach) :url-issue:`50` :url-pr:`51` 222 | * Clear the notification type cache every time a new notification type is created or deleted (Benjamin Bach) :url-issue:`34` :url-pr:`36` 223 | * Explicitly accept WebSocket connections (Kim Desrosiers) :url-pr:`35` 224 | * Fix critical django-channels err (Tomaž Žniderič) :url-issue:`29` 225 | * Correctly set default options for ``notifymail`` management command (Benjamin Bach) :url-pr:`32` 226 | * Adds Django 1.11 to test matrix (Benjamin Bach) :url-pr:`32` 227 | * Do not return ``bytes`` in ``__str__`` (Øystein Hiåsen) :url-pr:`28` 228 | 229 | 230 | Deprecations 231 | ^^^^^^^^^^^^ 232 | 233 | * Django 1.5 and 1.6 support is dropped 234 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ===== 3 | 4 | This is the overall picture: In your Python code, you call ``notify()`` with 5 | some string message for the user, then in your browser application, you 6 | should implement Websocket or polling based retrieval of those notifications 7 | from a JSON view. 8 | 9 | Python API 10 | ---------- 11 | 12 | Adding a notification 13 | ~~~~~~~~~~~~~~~~~~~~~ 14 | 15 | .. code-block:: python 16 | 17 | from django_nyt.utils import notify 18 | 19 | EVENT_KEY = "my_key" 20 | notify(_("OMG! Something happened"), EVENT_KEY) 21 | 22 | 23 | Adding a notification with a certain target object 24 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 25 | 26 | The Notification model has a GenericForeignKey which can link it to any other 27 | object. This is nice, because you might have an intention to go the other way 28 | around and ask "for this object, are there any notifications?" 29 | 30 | .. code-block:: python 31 | 32 | notify(_("OMG! Something happened"), EVENT_KEY, target_object=my_model_instance) 33 | 34 | 35 | Excluding certain recipients 36 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 37 | 38 | By setting the kwarg ``filter_exclude`` to a dictionary of lookup fields for 39 | ``models.Subscription``, you may exclude certain users from getting a notification. 40 | For instance, if a notification is solely for staff members: 41 | 42 | .. code-block:: html+django 43 | 44 | notify( 45 | _("OMG! Something happened"), EVENT_KEY, 46 | filter_exclude={'settings__user__is_staff': True} 47 | ) 48 | 49 | 50 | Disabling notifications 51 | ~~~~~~~~~~~~~~~~~~~~~~~ 52 | 53 | Use ``decorators.disable_notify`` to ensure that all notifications within a function are disabled. 54 | 55 | For instance: 56 | 57 | .. code-block:: html+django 58 | 59 | from django_nyt.decorators import disable_notify 60 | @disable_notify 61 | def my_view(request): 62 | ... 63 | 64 | 65 | JSON views 66 | ---------- 67 | 68 | The below views can be used for your JavaScript application. Examples of how to 69 | use them is provided in :doc:`javascript`. 70 | 71 | .. automodule:: django_nyt.views 72 | :members: 73 | :noindex: 74 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "django-nyt" 7 | description = "A pluggable notification system written for the Django framework." 8 | readme = "README.rst" 9 | requires-python = ">=3.8" 10 | license = "GPL-3.0" 11 | keywords = ["django", "alerts", "notification"] 12 | authors = [ 13 | { name = "Benjamin Bach", email = "benjamin@overtag.dk" }, 14 | ] 15 | maintainers = [ 16 | { name = "Oscar Cortez", email = "om.cortez.2010@gmail.com" }, 17 | ] 18 | classifiers = [ 19 | "Development Status :: 5 - Production/Stable", 20 | "Environment :: Web Environment", 21 | "Framework :: Django", 22 | "Intended Audience :: Developers", 23 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 24 | "Operating System :: OS Independent", 25 | "Programming Language :: Python", 26 | "Programming Language :: Python :: 3", 27 | "Programming Language :: Python :: 3 :: Only", 28 | "Programming Language :: Python :: 3.8", 29 | "Programming Language :: Python :: 3.9", 30 | "Programming Language :: Python :: 3.10", 31 | "Programming Language :: Python :: 3.11", 32 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 33 | "Topic :: Software Development", 34 | "Topic :: Software Development :: Libraries :: Application Frameworks", 35 | ] 36 | dependencies = [ 37 | "django>=2.2,<5.3", 38 | ] 39 | dynamic = ["version"] 40 | 41 | [project.optional-dependencies] 42 | docs = [ 43 | "sphinx==6.2.1", 44 | "channels==4.0.0", 45 | "sphinx_rtd_theme==1.2.0", 46 | ] 47 | 48 | [project.urls] 49 | Homepage = "https://github.com/django-wiki/django-nyt" 50 | Documentation = "https://django-nyt.readthedocs.io/en/latest/" 51 | Tracker = "https://github.com/django-wiki/django-nyt/issues" 52 | Source = "https://github.com/django-wiki/django-nyt" 53 | Funding = "https://donate.pypi.org" 54 | "Release notes" = "https://github.com/django-wiki/django-nyt/releases" 55 | 56 | [tool.hatch.publish.index] 57 | disable = true 58 | 59 | [tool.hatch.version] 60 | path = "django_nyt/__init__.py" 61 | 62 | [tool.hatch.build.targets.sdist] 63 | include = [ 64 | "README.rst", 65 | "/django_nyt", 66 | ] 67 | 68 | [tool.hatch.envs.default] 69 | dependencies = [ 70 | "coverage[toml]", 71 | "codecov", 72 | "flake8>=3.7,<5.1", 73 | "pre-commit", 74 | ] 75 | 76 | [tool.hatch.envs.default.scripts] 77 | clean = [ 78 | "rm -fr build/", 79 | "rm -fr dist/", 80 | "rm -fr *.egg-info", 81 | "rm -fr .tox/", 82 | "rm -f .coverage", 83 | "rm -fr htmlcov/", 84 | "find . -name '*.pyc' -exec rm -f {{}} +", 85 | "find . -name '*.pyo' -exec rm -f {{}} +", 86 | "find . -name '*~' -exec rm -f {{}} +", 87 | ] 88 | lint = [ 89 | "flake8 --max-line-length=213 --extend-ignore=E203 --max-complexity=10 --exclude=.svn,CVS,.bzr,.hg,.git,__pycache__,.tox,.eggs,*.egg,*/*migrations,testproject django_nyt/", 90 | "pre-commit install -f --install-hooks", 91 | "pre-commit run --all-files --show-diff-on-failure", 92 | ] 93 | 94 | [tool.hatch.envs.test] 95 | dependencies = [ 96 | "flake8>=3.7,<5.1", 97 | "pre-commit", 98 | "pytest>=7,<8", 99 | "pytest-cov", 100 | "pytest-django", 101 | "pytest-pythonpath", 102 | ] 103 | matrix-name-format = "dj{value}" 104 | 105 | [tool.hatch.envs.test.overrides] 106 | matrix.django.dependencies = [ 107 | { value = "django~={matrix:django}" }, 108 | ] 109 | 110 | [[tool.hatch.envs.test.matrix]] 111 | python = ["3.8", "3.9"] 112 | django = ["2.2", "3.0", "3.1", "3.2"] 113 | 114 | [[tool.hatch.envs.test.matrix]] 115 | python = ["3.10"] 116 | django = ["3.2"] 117 | 118 | [[tool.hatch.envs.test.matrix]] 119 | python = ["3.8", "3.9", "3.10"] 120 | django = ["4.0"] 121 | 122 | [[tool.hatch.envs.test.matrix]] 123 | python = ["3.8", "3.9", "3.10", "3.11"] 124 | django = ["4.1", "4.2"] 125 | 126 | [[tool.hatch.envs.test.matrix]] 127 | python = ["3.10", "3.11", "3.12", "3.13"] 128 | django = ["5.0", "5.1", "5.2"] 129 | 130 | [tool.hatch.envs.test.scripts] 131 | all = [ 132 | "sh -c 'python test-project/manage.py makemigrations --check'", 133 | "pytest --cov=django_nyt tests/ {args}", 134 | ] 135 | 136 | [tool.hatch.envs.docs] 137 | dependencies = [ 138 | "sphinx==6.2.1", 139 | "channels==4.0.0", 140 | "sphinx_rtd_theme==1.2.0", 141 | ] 142 | 143 | [tool.hatch.envs.docs.scripts] 144 | clean = "rm -rf docs/_build/*" 145 | build-html = "sphinx-build -c docs -b html -d docs/_build/doctrees -D latex_paper_size={args} docs/ docs/_build/html" 146 | build = [ 147 | "hatch run docs:clean", 148 | "rm -f docs/modules.rst", 149 | "rm -f docs/django_nyt*.rst", 150 | "sphinx-apidoc -d 10 -H 'Python Reference' -o docs/ django_nyt django_nyt/tests django_nyt/migrations", 151 | "hatch run docs:build-html {args}", 152 | ] 153 | link-check = "sphinx-build -b linkcheck ./docs ./docs/_build" 154 | 155 | [tool.isort] 156 | combine_as_imports = true 157 | default_section = "THIRDPARTY" 158 | include_trailing_comma = true 159 | line_length = 160 160 | multi_line_output = 5 161 | not_skip = "__init__.py" 162 | skip = "docs/conf.py" 163 | indent = 4 164 | 165 | [tool.pytest.ini_options] 166 | django_find_project = false 167 | testpaths = [ 168 | "tests", 169 | ] 170 | pythonpath = [ 171 | "test-project" 172 | ] 173 | norecursedirs = [ 174 | "test-project", 175 | ".svn", 176 | "_build", 177 | "tmp*", 178 | "dist", 179 | "*.egg-info", 180 | ] 181 | DJANGO_SETTINGS_MODULE = "tests.settings" 182 | -------------------------------------------------------------------------------- /test-project/django_nyt: -------------------------------------------------------------------------------- 1 | ../django_nyt -------------------------------------------------------------------------------- /test-project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproject.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /test-project/prepopulated.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-wiki/django-nyt/af833c9be6e8af9527c421d811b384f12461650a/test-project/prepopulated.db -------------------------------------------------------------------------------- /test-project/requirements.txt: -------------------------------------------------------------------------------- 1 | channels 2 | -------------------------------------------------------------------------------- /test-project/testproject/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-wiki/django-nyt/af833c9be6e8af9527c421d811b384f12461650a/test-project/testproject/__init__.py -------------------------------------------------------------------------------- /test-project/testproject/settings/__init__.py: -------------------------------------------------------------------------------- 1 | from os import path as os_path 2 | 3 | from django import VERSION 4 | 5 | PROJECT_PATH = os_path.abspath(os_path.split(os_path.dirname(__file__))[0]) 6 | 7 | DEBUG = True 8 | 9 | ADMINS = ( 10 | # ('Your Name', 'your_email@example.com'), 11 | ) 12 | 13 | MANAGERS = ADMINS 14 | 15 | DATABASES = { 16 | "default": { 17 | "ENGINE": "django.db.backends.sqlite3", 18 | "NAME": os_path.join(PROJECT_PATH, "prepopulated.db"), 19 | } 20 | } 21 | 22 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 23 | TIME_ZONE = "Europe/Copenhagen" 24 | 25 | # http://www.i18nguy.com/unicode/language-identifiers.html 26 | LANGUAGE_CODE = "en-dk" 27 | 28 | SITE_ID = 1 29 | 30 | USE_I18N = True 31 | USE_L10N = True 32 | USE_TZ = True 33 | 34 | MEDIA_ROOT = os_path.join(PROJECT_PATH, "media") 35 | MEDIA_URL = "/media/" 36 | 37 | STATIC_ROOT = os_path.join(PROJECT_PATH, "static") 38 | STATIC_URL = "/static/" 39 | STATICFILES_DIRS = () 40 | STATICFILES_FINDERS = ( 41 | "django.contrib.staticfiles.finders.FileSystemFinder", 42 | "django.contrib.staticfiles.finders.AppDirectoriesFinder", 43 | ) 44 | 45 | 46 | TEMPLATES = [ 47 | { 48 | "BACKEND": "django.template.backends.django.DjangoTemplates", 49 | "APP_DIRS": True, 50 | "OPTIONS": { 51 | "context_processors": [ 52 | "django.contrib.auth.context_processors.auth", 53 | "django.template.context_processors.debug", 54 | "django.template.context_processors.i18n", 55 | "django.template.context_processors.media", 56 | "django.template.context_processors.static", 57 | "django.template.context_processors.tz", 58 | "django.template.context_processors.debug", 59 | "django.template.context_processors.request", 60 | "django.contrib.messages.context_processors.messages", 61 | ] 62 | }, 63 | }, 64 | ] 65 | 66 | ROOT_URLCONF = "testproject.urls" 67 | 68 | # Python dotted path to the WSGI application used by Django's runserver. 69 | WSGI_APPLICATION = "testproject.wsgi.application" 70 | 71 | MIDDLEWARE = [ 72 | "django.middleware.security.SecurityMiddleware", 73 | "django.contrib.sessions.middleware.SessionMiddleware", 74 | "django.middleware.common.CommonMiddleware", 75 | "django.middleware.csrf.CsrfViewMiddleware", 76 | "django.contrib.auth.middleware.AuthenticationMiddleware", 77 | "django.contrib.messages.middleware.MessageMiddleware", 78 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 79 | ] 80 | 81 | INSTALLED_APPS = [ 82 | "django.contrib.humanize", 83 | "django.contrib.auth", 84 | "django.contrib.contenttypes", 85 | "django.contrib.sessions", 86 | "django.contrib.sites", 87 | "django.contrib.messages", 88 | "django.contrib.staticfiles", 89 | "django.contrib.admin", 90 | "django.contrib.admindocs", 91 | "django_nyt", 92 | "tests.testapp", 93 | ] 94 | 95 | if VERSION <= (1, 6): 96 | INSTALLED_APPS.append("south") 97 | SOUTH_MIGRATION_MODULES = { 98 | "django_nyt": "django_nyt.south_migrations", 99 | } 100 | else: 101 | TEST_RUNNER = "django.test.runner.DiscoverRunner" 102 | 103 | 104 | LOGGING = { 105 | "version": 1, 106 | "disable_existing_loggers": True, 107 | "handlers": { 108 | "console": { 109 | "class": "logging.StreamHandler", 110 | }, 111 | }, 112 | "loggers": { 113 | "django.request": { 114 | "handlers": ["console"], 115 | "level": "DEBUG" if DEBUG else "INFO", 116 | }, 117 | "django_nyt": { 118 | "handlers": ["console"], 119 | "level": "DEBUG" if DEBUG else "INFO", 120 | }, 121 | "django.channels": { 122 | "handlers": ["console"], 123 | "level": "DEBUG" if DEBUG else "INFO", 124 | }, 125 | }, 126 | } 127 | 128 | # "Secret" key for the prepopulated db 129 | SECRET_KEY = "b^fv_)t39h%9p40)fnkfblo##jkr!$0)lkp6bpy!fi*f$4*92!" 130 | 131 | try: # noqa 132 | from testproject.settings.local import * # noqa 133 | except ImportError: # noqa 134 | pass # noqa 135 | 136 | 137 | ########################################## 138 | # django-nyt options 139 | ########################################## 140 | 141 | _enable_channels = False 142 | if _enable_channels: 143 | INSTALLED_APPS.append("channels") 144 | CHANNEL_LAYERS = { 145 | "default": { 146 | "BACKEND": "asgiref.inmemory.ChannelLayer", 147 | "ROUTING": "django_nyt.routing.channel_routing", 148 | }, 149 | } 150 | 151 | 152 | NYT_ENABLE_ADMIN = True 153 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 154 | -------------------------------------------------------------------------------- /test-project/testproject/settings/local.py: -------------------------------------------------------------------------------- 1 | # Add your own changes here -- but do not push to remote!! 2 | # After changing the file, from root of repository execute: 3 | # git update-index --assume-unchanged testproject/testproject/settings/local.py 4 | -------------------------------------------------------------------------------- /test-project/testproject/settings/travis.py: -------------------------------------------------------------------------------- 1 | from testproject.settings import * # noqa 2 | 3 | SECRET_KEY = "b^fv_)t39h%9p40)fnkfblo##jkr!$0)lkp6bpy!fi*f$4*92!" 4 | -------------------------------------------------------------------------------- /test-project/testproject/urls.py: -------------------------------------------------------------------------------- 1 | import django.views.static 2 | from django.conf import settings 3 | from django.contrib import admin 4 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 5 | from django.urls import include 6 | from django.urls import re_path as url 7 | 8 | 9 | admin.autodiscover() 10 | 11 | urlpatterns = [ 12 | url(r"^admin/doc/", include("django.contrib.admindocs.urls")), 13 | url(r"^admin/", admin.site.urls), 14 | url(r"^nyt/", include("django_nyt.urls")), 15 | url(r"", include("tests.testapp.urls")), 16 | ] 17 | 18 | if settings.DEBUG: 19 | urlpatterns += staticfiles_urlpatterns() 20 | urlpatterns += [ 21 | url( 22 | r"^media/(?P.*)$", 23 | django.views.static.serve, 24 | kwargs={ 25 | "document_root": settings.MEDIA_ROOT, 26 | }, 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /test-project/testproject/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for testproject project. 3 | 4 | This module contains the WSGI application used by Django's development server 5 | and any production WSGI deployments. It should expose a module-level variable 6 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover 7 | this application via the ``WSGI_APPLICATION`` setting. 8 | 9 | Usually you will have the standard Django WSGI application here, but it also 10 | might make sense to replace the whole Django WSGI application with a custom one 11 | that later delegates to the Django one. For example, you could introduce WSGI 12 | middleware here, or combine a Django application with an application of another 13 | framework. 14 | 15 | """ 16 | import os 17 | 18 | # We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks 19 | # if running multiple sites in the same mod_wsgi process. To fix this, use 20 | # mod_wsgi daemon mode with each site in its own daemon process, or use 21 | # os.environ["DJANGO_SETTINGS_MODULE"] = "testproject.settings" 22 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproject.settings") 23 | 24 | # This application object is used by any WSGI server configured to use this 25 | # file. This includes Django's development server, if the WSGI_APPLICATION 26 | # setting points here. 27 | from django.core.wsgi import get_wsgi_application # noqa 28 | 29 | application = get_wsgi_application() 30 | 31 | # Apply WSGI middleware here. 32 | # from helloworld.wsgi import HelloWorldApplication 33 | # application = HelloWorldApplication(application) 34 | -------------------------------------------------------------------------------- /test-project/tests: -------------------------------------------------------------------------------- 1 | ../tests -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-wiki/django-nyt/af833c9be6e8af9527c421d811b384f12461650a/tests/__init__.py -------------------------------------------------------------------------------- /tests/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-wiki/django-nyt/af833c9be6e8af9527c421d811b384f12461650a/tests/core/__init__.py -------------------------------------------------------------------------------- /tests/core/test_basic.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.test import TestCase 4 | 5 | from django_nyt import models 6 | from django_nyt import utils 7 | from django_nyt.conf import WEEKLY 8 | from django_nyt.decorators import disable_notify 9 | from django_nyt.models import Settings 10 | from tests.testapp.models import TestModel 11 | 12 | User = get_user_model() 13 | 14 | 15 | class NotifyTestBase(TestCase): 16 | 17 | TEST_KEY = "test_key" 18 | 19 | def setUp(self): 20 | super(NotifyTestBase, self).setUp() 21 | models._notification_type_cache = {} 22 | 23 | # These two users are created by migrations in testproject.testapp 24 | # Reason is to make the testproject easy to setup and use. 25 | self.user1 = User.objects.get(username="alice") 26 | self.user1_settings = models.Settings.get_default_setting(self.user1) 27 | self.user2 = User.objects.get(username="bob") 28 | self.user2_settings = models.Settings.get_default_setting(self.user2) 29 | 30 | def tearDown(self): 31 | self.user1.delete() 32 | self.user2.delete() 33 | models._notification_type_cache = {} 34 | super().tearDown() 35 | 36 | 37 | class NotifyTest(NotifyTestBase): 38 | def test_notify(self): 39 | 40 | # Subscribe User 1 to test key 41 | utils.subscribe(self.user1_settings, self.TEST_KEY) 42 | utils.notify("Test is a test", self.TEST_KEY) 43 | 44 | # Check that there is exactly 1 notification 45 | self.assertEqual(models.Notification.objects.all().count(), 1) 46 | 47 | def test_disable_notify(self): 48 | # Subscribe User 1 to test key 49 | utils.subscribe(self.user1_settings, self.TEST_KEY) 50 | 51 | @disable_notify 52 | def inner(): 53 | utils.notify("Test is a test", self.TEST_KEY) 54 | 55 | inner() 56 | 57 | # Check that there is exactly 1 notification 58 | self.assertEqual(models.Notification.objects.all().count(), 0) 59 | 60 | def test_notify_two_users(self): 61 | 62 | # Subscribe User 2 to test key 63 | utils.subscribe(self.user2_settings, self.TEST_KEY) 64 | utils.subscribe(self.user1_settings, self.TEST_KEY) 65 | utils.notify("Another test", self.TEST_KEY) 66 | 67 | self.assertEqual(models.Notification.objects.all().count(), 2) 68 | 69 | # Now create the same notification again, this should not create new 70 | # objects in the DB but instead increase the count of that notification! 71 | utils.notify("Another test", self.TEST_KEY) 72 | 73 | self.assertEqual(models.Notification.objects.filter(occurrences=2).count(), 2) 74 | 75 | def test_failure_target_object_not_model(self): 76 | 77 | # Subscribe User 1 to test key 78 | utils.subscribe(self.user1_settings, self.TEST_KEY) 79 | with self.assertRaises(TypeError): 80 | utils.notify("Another test", self.TEST_KEY, target_object=object()) 81 | 82 | def test_with_target_object(self): 83 | 84 | related_object = TestModel.objects.create(name="test_with_target_object") 85 | content_type = ContentType.objects.get_for_model(TestModel) 86 | # Subscribe User 1 to test key 87 | utils.subscribe( 88 | self.user1_settings, 89 | self.TEST_KEY, 90 | content_type=content_type, 91 | object_id=related_object.id, 92 | ) 93 | utils.notify("Test related object", self.TEST_KEY, target_object=related_object) 94 | 95 | self.assertEqual( 96 | models.Notification.objects.filter( 97 | subscription__object_id=related_object.id, 98 | subscription__notification_type__content_type=content_type, 99 | ).count(), 100 | 1, 101 | ) 102 | 103 | def test_unsubscribe(self): 104 | 105 | related_object = TestModel.objects.create(name="test_with_target_object") 106 | content_type = ContentType.objects.get_for_model(TestModel) 107 | # Subscribe User 1 to test key 108 | utils.subscribe( 109 | self.user1_settings, 110 | self.TEST_KEY, 111 | content_type=content_type, 112 | object_id=related_object.id, 113 | ) 114 | # Subscribe User 1 to test key without content type 115 | utils.subscribe( 116 | self.user1_settings, 117 | self.TEST_KEY, 118 | ) 119 | utils.notify("Test related object", self.TEST_KEY, target_object=related_object) 120 | 121 | # Test also that notifications aren't deleted 122 | notifications_before = models.Notification.objects.all().count() 123 | 124 | utils.unsubscribe( 125 | self.TEST_KEY, 126 | settings=self.user1_settings, 127 | content_type=content_type, 128 | object_id=related_object.id, 129 | ) 130 | 131 | assert notifications_before == models.Notification.objects.all().count() 132 | 133 | # Check that exactly 1 notification is generated (no content type filter) 134 | utils.notify("Test related object", self.TEST_KEY, target_object=related_object) 135 | assert models.Notification.objects.all().count() == notifications_before + 1 136 | 137 | # And now we unsubscribe the remaining! 138 | utils.unsubscribe( 139 | self.TEST_KEY, 140 | user=self.user1_settings.user, 141 | ) 142 | 143 | assert models.Notification.objects.all().count() == notifications_before + 1 144 | 145 | # Check that no notification is generated here 146 | utils.notify("Test related object", self.TEST_KEY) 147 | assert models.Notification.objects.all().count() == notifications_before + 1 148 | 149 | 150 | class NotifySettingsTest(NotifyTestBase): 151 | def test_create_settings(self): 152 | # Create another user setting 153 | 154 | Settings.objects.create(user=self.user1, interval=WEEKLY, is_default=False) 155 | assert Settings.objects.filter(user=self.user1, is_default=True).count() == 1 156 | assert Settings.objects.filter(user=self.user1).count() == 2 157 | 158 | Settings.objects.create(user=self.user1, interval=WEEKLY, is_default=True) 159 | assert Settings.objects.filter(user=self.user1, is_default=True).count() == 1 160 | assert Settings.objects.filter(user=self.user1).count() == 3 161 | -------------------------------------------------------------------------------- /tests/core/test_email.py: -------------------------------------------------------------------------------- 1 | import re 2 | from collections import OrderedDict 3 | from datetime import timedelta 4 | 5 | import pytest 6 | from django.core import mail 7 | from django.core.management import call_command 8 | from django.test import override_settings 9 | from django.utils import timezone 10 | 11 | from .test_basic import NotifyTestBase 12 | from django_nyt import models 13 | from django_nyt import utils 14 | from django_nyt.conf import WEEKLY 15 | 16 | 17 | @pytest.mark.django_db 18 | def test_glob_matching(): 19 | test_cases = [ 20 | # path, pattern, new_should_match 21 | ("/path/to/foo", "*", False), 22 | ("/path/to/foo", "**", True), 23 | ("/path/to/foo", "/path/*", False), 24 | ("/path/to/foo", "/path/**", True), 25 | ("/path/to/foo", "/path/to/*", True), 26 | ("/path/to", "/path?to", False), 27 | ("/path/to", "/path[!abc]to", False), 28 | ("/pathlalato", "/path[a-z]*to", True), 29 | ("/path-to", "/path-*", True), 30 | ("/path-[to", "/path-[*", True), 31 | ("admin/user/notification", "admin/**", True), 32 | ("WHATEVER", "*", True), 33 | ("WHATEVER/BUT/NOT/THIS", "*", False), 34 | ("admin/user/notification", "admin/**/*", True), 35 | ] 36 | 37 | for path, pattern, new_should_match in test_cases: 38 | new_re = re.compile(models._glob_to_re(pattern)) 39 | new_match = bool(new_re.match(path)) 40 | if new_match is not new_should_match: 41 | raise AssertionError( 42 | f"regex from `glob_to_re()` should match path " 43 | f"'{path}' when given pattern: {pattern}" 44 | ) 45 | 46 | 47 | class NotifyTest(NotifyTestBase): 48 | def test_notify(self): 49 | mail.outbox = [] 50 | 51 | # Subscribe User 1 to test key 52 | utils.subscribe(self.user1_settings, self.TEST_KEY, send_emails=True) 53 | utils.notify("Test is a test", self.TEST_KEY) 54 | 55 | call_command("notifymail", "--cron", "--no-sys-exit") 56 | 57 | assert len(mail.outbox) == 1 58 | assert ( 59 | mail.outbox[0].subject 60 | == "You have new notifications from example.com (type: instantly)" 61 | ) 62 | assert ( 63 | mail.outbox[0].body 64 | == """Dear alice, 65 | 66 | These are notifications sent instantly from example.com. 67 | 68 | * Test is a test 69 | 70 | 71 | Thanks for using our site! 72 | 73 | Sincerely, 74 | example.com 75 | 76 | """ 77 | ) 78 | 79 | def test_notify_with_url(self): 80 | mail.outbox = [] 81 | 82 | # Subscribe User 1 to test key 83 | utils.subscribe(self.user1_settings, self.TEST_KEY, send_emails=True) 84 | utils.notify("Test is a test", self.TEST_KEY, url="/test") 85 | 86 | call_command("notifymail", "--cron", "--no-sys-exit") 87 | 88 | assert len(mail.outbox) == 1 89 | assert ( 90 | mail.outbox[0].subject 91 | == "You have new notifications from example.com (type: instantly)" 92 | ) 93 | assert ( 94 | mail.outbox[0].body 95 | == """Dear alice, 96 | 97 | These are notifications sent instantly from example.com. 98 | 99 | * Test is a test 100 | https://example.com/test 101 | 102 | 103 | Thanks for using our site! 104 | 105 | Sincerely, 106 | example.com 107 | 108 | """ 109 | ) 110 | 111 | def test_notify_with_url_domain(self): 112 | mail.outbox = [] 113 | 114 | # Subscribe User 1 to test key 115 | utils.subscribe(self.user1_settings, self.TEST_KEY, send_emails=True) 116 | utils.notify("Test is a test", self.TEST_KEY, url="/test") 117 | 118 | call_command("notifymail", "--cron", "--no-sys-exit", "--domain=foobar.com") 119 | 120 | assert len(mail.outbox) == 1 121 | assert ( 122 | mail.outbox[0].subject 123 | == "You have new notifications from foobar.com (type: instantly)" 124 | ) 125 | assert ( 126 | mail.outbox[0].body 127 | == """Dear alice, 128 | 129 | These are notifications sent instantly from foobar.com. 130 | 131 | * Test is a test 132 | https://foobar.com/test 133 | 134 | 135 | Thanks for using our site! 136 | 137 | Sincerely, 138 | foobar.com 139 | 140 | """ 141 | ) 142 | 143 | @override_settings( 144 | NYT_EMAIL_TEMPLATE_NAMES=OrderedDict( 145 | {NotifyTestBase.TEST_KEY: "testapp/notifications/email.txt"} 146 | ), 147 | NYT_EMAIL_SUBJECT_TEMPLATE_NAMES=OrderedDict( 148 | {NotifyTestBase.TEST_KEY: "testapp/notifications/email_subject.txt"} 149 | ), 150 | ) 151 | def test_custom_email_template(self): 152 | 153 | mail.outbox = [] 154 | 155 | # Subscribe User 1 to test key 156 | utils.subscribe(self.user1_settings, self.TEST_KEY, send_emails=True) 157 | utils.notify("Test is a test", self.TEST_KEY, url="/test") 158 | 159 | call_command("notifymail", "--cron", "--no-sys-exit") 160 | 161 | assert len(mail.outbox) == 1 162 | assert mail.outbox[0].subject == "subject" 163 | assert mail.outbox[0].body == "Test\n" 164 | 165 | @override_settings( 166 | NYT_EMAIL_TEMPLATE_NAMES=OrderedDict( 167 | [ 168 | ("key1", "testapp/notifications/email.txt"), 169 | ("admin_*", "testapp/notifications/admin.txt"), 170 | ] 171 | ), 172 | NYT_EMAIL_SUBJECT_TEMPLATE_NAMES=OrderedDict( 173 | [ 174 | ("key1", "testapp/notifications/email_subject.txt"), 175 | ("admin_*", "testapp/notifications/admin_subject.txt"), 176 | ("*", "notifications/emails/default_subject.txt"), 177 | ] 178 | ), 179 | ) 180 | def test_multiple_templates(self): 181 | 182 | # Subscribe User 1 to 3 keys 183 | utils.subscribe(self.user1_settings, "key1", send_emails=True) 184 | utils.subscribe(self.user1_settings, "admin_emails", send_emails=True) 185 | utils.subscribe(self.user1_settings, "key2", send_emails=True) 186 | utils.subscribe(self.user1_settings, "key3", send_emails=False) 187 | 188 | mail.outbox = [] 189 | utils.notify("Specific key1 test", "key1", url="/test-key1") 190 | utils.notify("Default test", "key2", url="/test") 191 | utils.notify("Admin test", "admin_emails", url="/test") 192 | 193 | call_command("notifymail", "--cron", "--no-sys-exit") 194 | 195 | assert len(mail.outbox) == 3 196 | 197 | assert mail.outbox[1].subject == "subject" 198 | assert mail.outbox[1].body == "Test\n" 199 | assert mail.outbox[0].body == "Admin\n" 200 | assert mail.outbox[0].subject == "notifications for admin" 201 | assert ( 202 | mail.outbox[2].subject 203 | == "You have new notifications from example.com (type: instantly)" 204 | ) 205 | 206 | @override_settings(NYT_EMAIL_SUBJECT="test") 207 | def test_nyt_email_subject(self): 208 | 209 | mail.outbox = [] 210 | 211 | # Subscribe User 1 to test key 212 | utils.subscribe(self.user1_settings, self.TEST_KEY, send_emails=True) 213 | utils.notify("Test is a test", self.TEST_KEY, url="/test") 214 | 215 | call_command("notifymail", "--cron", "--no-sys-exit") 216 | 217 | assert len(mail.outbox) == 1 218 | assert mail.outbox[0].subject == "test" 219 | 220 | 221 | class NotifyTestWeekly(NotifyTestBase): 222 | def test_notify_weekly_nothing_sent_first_week(self): 223 | 224 | user1_weekly_setting = models.Settings.objects.create( 225 | user=self.user1_settings.user, 226 | interval=WEEKLY, 227 | ) 228 | 229 | mail.outbox = [] 230 | 231 | # Subscribe User 1 to test key for weekly notifications 232 | utils.subscribe(user1_weekly_setting, self.TEST_KEY, send_emails=True) 233 | 234 | # Create a notification 235 | utils.notify("Test is a test", self.TEST_KEY) 236 | 237 | call_command("notifymail", "--cron", "--no-sys-exit") 238 | 239 | # Ensure nothing is sent, a week hasn't passed 240 | assert len(mail.outbox) == 0 241 | 242 | # Ensure something is sent if we try a week from now 243 | mail.outbox = [] 244 | call_command( 245 | "notifymail", 246 | "--cron", 247 | "--no-sys-exit", 248 | now=timezone.now() + timedelta(minutes=WEEKLY + 1), 249 | ) 250 | assert len(mail.outbox) == 1 251 | assert "weekly" in mail.outbox[0].subject 252 | 253 | # Ensure nothing is sent by re-running the command 254 | mail.outbox = [] 255 | call_command( 256 | "notifymail", 257 | "--cron", 258 | "--no-sys-exit", 259 | now=timezone.now() + timedelta(minutes=WEEKLY + 1), 260 | ) 261 | assert len(mail.outbox) == 0 262 | 263 | # Now ensure that we receive an instant notification regardless of the previous weekly notification 264 | utils.subscribe(self.user1_settings, self.TEST_KEY, send_emails=True) 265 | utils.notify("This is a second test", self.TEST_KEY) 266 | assert ( 267 | models.Notification.objects.filter( 268 | subscription__notification_type__key=self.TEST_KEY, 269 | subscription__settings=self.user1_settings, 270 | ).count() 271 | == 1 272 | ) 273 | mail.outbox = [] 274 | call_command("notifymail", "--cron", "--no-sys-exit") 275 | assert len(mail.outbox) == 1 276 | 277 | # Unsubscribe the instant notification 278 | mail.outbox = [] 279 | utils.unsubscribe(self.TEST_KEY, settings=self.user1_settings) 280 | 281 | # Now create 2 notifications and test that it'll get sent when running again a week later 282 | notifications = utils.notify("Test is a third test", self.TEST_KEY) 283 | notifications += utils.notify("Test is a forth test", self.TEST_KEY) 284 | 285 | call_command("notifymail", "--cron", "--no-sys-exit") 286 | 287 | # Ensure nothing is sent, a week hasn't passed 288 | assert len(mail.outbox) == 0 289 | 290 | # Emulate that it was sent ½ week ago and try again 291 | user1_weekly_setting.subscription_set.all().update( 292 | last_sent=timezone.now() - timedelta(minutes=WEEKLY * 0.5) 293 | ) 294 | 295 | call_command("notifymail", "--cron", "--no-sys-exit") 296 | 297 | # Ensure nothing is sent, a week hasn't passed 298 | assert len(mail.outbox) == 0 299 | 300 | # Emulate that it was sent 1 week ago and try again 301 | threshold = timezone.now() - timedelta( 302 | minutes=user1_weekly_setting.interval * 1 + 1 303 | ) 304 | user1_weekly_setting.subscription_set.all().update(last_sent=threshold) 305 | 306 | assert user1_weekly_setting.subscription_set.all().exists() 307 | assert models.Notification.objects.filter( 308 | subscription__settings=user1_weekly_setting, 309 | is_emailed=False, 310 | subscription__latest__is_emailed=False, 311 | subscription__last_sent__lte=threshold, 312 | ).exists() 313 | 314 | call_command("notifymail", "--cron", "--no-sys-exit") 315 | 316 | # Ensure that exactly 1 digest is sent 317 | assert len(mail.outbox) == 1 318 | assert "weekly" in mail.outbox[0].subject 319 | assert all(n.message in mail.outbox[0].body for n in notifications) 320 | -------------------------------------------------------------------------------- /tests/core/test_management.py: -------------------------------------------------------------------------------- 1 | import os 2 | import signal 3 | import time 4 | from tempfile import NamedTemporaryFile 5 | 6 | from django.core.management import call_command 7 | 8 | from .test_basic import NotifyTestBase 9 | from django_nyt import models 10 | from django_nyt import utils 11 | 12 | 13 | class CommandTest(NotifyTestBase): 14 | def tearDown(self): 15 | models._notification_type_cache = {} 16 | super().tearDown() 17 | 18 | def test_notifymail(self): 19 | 20 | utils.subscribe(self.user2_settings, self.TEST_KEY) 21 | utils.subscribe(self.user1_settings, self.TEST_KEY) 22 | utils.notify("This notification goes out by mail!", self.TEST_KEY) 23 | 24 | call_command("notifymail", cron=True) 25 | 26 | # No un-mailed notifications can be left! 27 | self.assertEqual( 28 | models.Notification.objects.filter(is_emailed=False).count(), 0 29 | ) 30 | 31 | # Test that calling it again works but nothing gets sent 32 | call_command("notifymail", cron=True) 33 | 34 | # Now try the daemon 35 | pid_file = NamedTemporaryFile(delete=False) 36 | # Close it so its available for the other command 37 | pid_file.close() 38 | 39 | try: 40 | call_command( 41 | "notifymail", daemon=True, pid=pid_file.name, no_sys_exit=False 42 | ) 43 | except SystemExit: 44 | # It's normal for this command to exit 45 | pass 46 | 47 | with open(pid_file.name, "r") as fp: 48 | pid = fp.read() 49 | os.unlink(pid_file.name) 50 | 51 | # Give it a second to start 52 | time.sleep(1) 53 | 54 | os.kill(int(pid), signal.SIGTERM) 55 | -------------------------------------------------------------------------------- /tests/core/test_views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.test import TestCase 3 | from django.urls import reverse 4 | 5 | from django_nyt import models 6 | from django_nyt import utils 7 | 8 | 9 | User = get_user_model() 10 | 11 | 12 | class TestViews(TestCase): 13 | def setUp(self): 14 | super().setUp() 15 | self.TEST_KEY = "test_key" 16 | self.user = User.objects.create_user( 17 | "lalala", 18 | password="password", 19 | ) 20 | self.user_settings = models.Settings.get_default_setting(self.user) 21 | 22 | def tearDown(self): 23 | super().tearDown() 24 | models._notification_type_cache = {} 25 | 26 | def test_mark_read(self): 27 | utils.subscribe(self.user_settings, self.TEST_KEY) 28 | utils.notify("Test Is a Test", self.TEST_KEY) 29 | self.assertEqual(models.Notification.objects.filter(is_viewed=False).count(), 1) 30 | nid = models.Notification.objects.get().id 31 | self.client.login(username=self.user.username, password="password") 32 | self.client.get(reverse("nyt:json_mark_read", args=(nid,))) 33 | self.assertEqual(models.Notification.objects.filter(is_viewed=False).count(), 0) 34 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "lskfjklsdfjalkfslfjslfksdjfslkfslkfj" 2 | 3 | DEBUG = True 4 | 5 | # AUTH_USER_MODEL='testdata.CustomUser', 6 | 7 | DATABASES = { 8 | "default": { 9 | "ENGINE": "django.db.backends.sqlite3", 10 | } 11 | } 12 | 13 | EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" 14 | 15 | SITE_ID = 1 16 | ROOT_URLCONF = "testproject.urls" 17 | INSTALLED_APPS = [ 18 | "django.contrib.auth", 19 | "django.contrib.contenttypes", 20 | "django.contrib.sessions", 21 | "django.contrib.admin", 22 | "django.contrib.messages", 23 | "django.contrib.humanize", 24 | "django.contrib.sites", 25 | "django_nyt", 26 | "tests.testapp", 27 | ] 28 | 29 | TEMPLATES = [ 30 | { 31 | "BACKEND": "django.template.backends.django.DjangoTemplates", 32 | "APP_DIRS": True, 33 | "OPTIONS": { 34 | "context_processors": [ 35 | "django.contrib.auth.context_processors.auth", 36 | "django.template.context_processors.debug", 37 | "django.template.context_processors.i18n", 38 | "django.template.context_processors.media", 39 | "django.template.context_processors.static", 40 | "django.template.context_processors.tz", 41 | "django.template.context_processors.debug", 42 | "django.template.context_processors.request", 43 | "django.contrib.messages.context_processors.messages", 44 | ] 45 | }, 46 | }, 47 | ] 48 | 49 | USE_TZ = True 50 | 51 | MIDDLEWARE = [ 52 | "django.middleware.common.CommonMiddleware", 53 | "django.contrib.sessions.middleware.SessionMiddleware", 54 | "django.middleware.csrf.CsrfViewMiddleware", 55 | "django.contrib.auth.middleware.AuthenticationMiddleware", 56 | "django.contrib.messages.middleware.MessageMiddleware", 57 | ] 58 | 59 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 60 | -------------------------------------------------------------------------------- /tests/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-wiki/django-nyt/af833c9be6e8af9527c421d811b384f12461650a/tests/testapp/__init__.py -------------------------------------------------------------------------------- /tests/testapp/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from . import models 4 | 5 | admin.site.register(models.TestModel) 6 | -------------------------------------------------------------------------------- /tests/testapp/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from . import models 4 | 5 | 6 | class TestModelForm(forms.ModelForm): 7 | class Meta: 8 | model = models.TestModel 9 | fields = ("name",) 10 | -------------------------------------------------------------------------------- /tests/testapp/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | from django.db import models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | initial = True 8 | 9 | dependencies = [] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="TestModel", 14 | fields=[ 15 | ( 16 | "id", 17 | models.AutoField( 18 | auto_created=True, 19 | primary_key=True, 20 | serialize=False, 21 | verbose_name="ID", 22 | ), 23 | ), 24 | ("name", models.CharField(max_length=255)), 25 | ], 26 | options={ 27 | "verbose_name": "Test", 28 | }, 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /tests/testapp/migrations/0002_createtestusers.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | from django_nyt.utils import subscribe 4 | from tests.testapp.models import NOTIFICATION_TEST_KEY 5 | 6 | FIXTURES = ( 7 | ("bob", "bob@example.com"), 8 | ("alice", "alice@example.com"), 9 | ("frank", ""), 10 | ) 11 | 12 | 13 | def create_users(apps, schema_editor): 14 | from django.contrib.auth import get_user_model 15 | 16 | user_model = get_user_model() 17 | 18 | for username, email in FIXTURES: 19 | user = user_model.objects.create( 20 | username=username, 21 | email=email, 22 | is_active=True, 23 | ) 24 | from django_nyt.models import Settings 25 | 26 | settings = Settings.get_default_setting(user) 27 | subscribe(settings, NOTIFICATION_TEST_KEY) 28 | 29 | 30 | def remove_users(apps, schema_editor): 31 | user_model = apps.get_model("auth", "User") 32 | 33 | user_model.objects.filter(username__in=[entry[0] for entry in FIXTURES]).delete() 34 | 35 | 36 | class Migration(migrations.Migration): 37 | 38 | dependencies = [ 39 | ("testapp", "0001_initial"), 40 | ] 41 | 42 | operations = [migrations.RunPython(create_users, reverse_code=remove_users)] 43 | -------------------------------------------------------------------------------- /tests/testapp/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-wiki/django-nyt/af833c9be6e8af9527c421d811b384f12461650a/tests/testapp/migrations/__init__.py -------------------------------------------------------------------------------- /tests/testapp/models.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.db import models 4 | from django.db.models.signals import post_save 5 | from django.dispatch import receiver 6 | from django.utils.translation import gettext as _ 7 | 8 | from django_nyt.utils import notify 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | NOTIFICATION_TEST_KEY = "testapp_stuff_was_created" 13 | 14 | 15 | class TestModel(models.Model): 16 | 17 | name = models.CharField(max_length=255) 18 | 19 | def __str__(self): 20 | return "Test object: {}".format(self.name) 21 | 22 | class Meta: 23 | verbose_name = "Test" 24 | 25 | 26 | @receiver(post_save, sender=TestModel) 27 | def notify_test(sender, instance, **kwargs): 28 | 29 | logger.info("New object created: {}".format(str(instance))) 30 | 31 | notify( 32 | _("Message is: {}".format(instance.name)), 33 | NOTIFICATION_TEST_KEY, 34 | target_object=instance, 35 | ) 36 | -------------------------------------------------------------------------------- /tests/testapp/static/testapp/css/custom.css: -------------------------------------------------------------------------------- 1 | .notification-list 2 | { 3 | list-style: none; 4 | border-collapse: collapse; 5 | width: 100%; 6 | } 7 | 8 | .notification-list li { 9 | padding: 5px; 10 | border: 1px solid #ddd; 11 | background: #eee; 12 | display: block; 13 | margin: 0; 14 | } 15 | 16 | /* copied from getskeleton.com */ 17 | 18 | .container { 19 | max-width: 800px; } 20 | .header { 21 | margin-top: 6rem; 22 | text-align: center; } 23 | .value-prop { 24 | margin-top: 1rem; } 25 | .value-props { 26 | margin-top: 4rem; 27 | margin-bottom: 4rem; } 28 | .docs-header { 29 | text-transform: uppercase; 30 | font-size: 1.4rem; 31 | letter-spacing: .2rem; 32 | font-weight: 600; } 33 | .docs-section { 34 | border-top: 1px solid #eee; 35 | padding: 4rem 0; 36 | margin-bottom: 0;} 37 | .value-img { 38 | display: block; 39 | text-align: center; 40 | margin: 2.5rem auto 0; } 41 | .example-grid .column, 42 | .example-grid .columns { 43 | background: #EEE; 44 | text-align: center; 45 | border-radius: 4px; 46 | font-size: 1rem; 47 | text-transform: uppercase; 48 | height: 30px; 49 | line-height: 30px; 50 | margin-bottom: .75rem; 51 | font-weight: 600; 52 | letter-spacing: .1rem; } 53 | .docs-example .row, 54 | .docs-example.row, 55 | .docs-example form { 56 | margin-bottom: 0; } 57 | .docs-example h1, 58 | .docs-example h2, 59 | .docs-example h3, 60 | .docs-example h4, 61 | .docs-example h5, 62 | .docs-example h6 { 63 | margin-bottom: 1rem; } 64 | .heading-font-size { 65 | font-size: 1.2rem; 66 | color: #999; 67 | letter-spacing: normal; } 68 | .code-example { 69 | margin-top: 1.5rem; 70 | margin-bottom: 0; } 71 | .code-example-body { 72 | white-space: pre; 73 | word-wrap: break-word } 74 | .example { 75 | position: relative; 76 | margin-top: 4rem; } 77 | .example-header { 78 | font-weight: 600; 79 | margin-top: 1.5rem; 80 | margin-bottom: .5rem; } 81 | .example-description { 82 | margin-bottom: 1.5rem; } 83 | .example-screenshot-wrapper { 84 | display: block; 85 | position: relative; 86 | overflow: hidden; 87 | border-radius: 6px; 88 | border: 1px solid #eee; 89 | height: 250px; } 90 | .example-screenshot { 91 | width: 100%; 92 | height: auto; } 93 | .example-screenshot.coming-soon { 94 | width: auto; 95 | position: absolute; 96 | background: #eee; 97 | top: 5px; 98 | right: 5px; 99 | bottom: 5px; 100 | left: 5px; } 101 | .navbar { 102 | display: none; } 103 | 104 | /* Larger than phone */ 105 | @media (min-width: 550px) { 106 | .header { 107 | margin-top: 18rem; } 108 | .value-props { 109 | margin-top: 9rem; 110 | margin-bottom: 7rem; } 111 | .value-img { 112 | margin-bottom: 1rem; } 113 | .example-grid .column, 114 | .example-grid .columns { 115 | margin-bottom: 1.5rem; } 116 | .docs-section { 117 | padding: 6rem 0; } 118 | .example-send-yourself-copy { 119 | float: right; 120 | margin-top: 12px; } 121 | .example-screenshot-wrapper { 122 | position: absolute; 123 | width: 48%; 124 | height: 100%; 125 | left: 0; 126 | max-height: none; } 127 | } 128 | 129 | /* Larger than tablet */ 130 | @media (min-width: 750px) { 131 | /* Navbar */ 132 | .navbar + .docs-section { 133 | border-top-width: 0; } 134 | .navbar, 135 | .navbar-spacer { 136 | display: block; 137 | width: 100%; 138 | height: 6.5rem; 139 | background: #fff; 140 | z-index: 99; 141 | border-top: 1px solid #eee; 142 | border-bottom: 1px solid #eee; } 143 | .navbar-spacer { 144 | display: none; } 145 | .navbar > .container { 146 | width: 100%; } 147 | .navbar-list { 148 | list-style: none; 149 | margin-bottom: 0; } 150 | .navbar-item { 151 | position: relative; 152 | float: left; 153 | margin-bottom: 0; } 154 | .navbar-link { 155 | text-transform: uppercase; 156 | font-size: 11px; 157 | font-weight: 600; 158 | letter-spacing: .2rem; 159 | margin-right: 35px; 160 | text-decoration: none; 161 | line-height: 6.5rem; 162 | color: #222; } 163 | .navbar-link.active { 164 | color: #33C3F0; } 165 | .has-docked-nav .navbar { 166 | position: fixed; 167 | top: 0; 168 | left: 0; } 169 | .has-docked-nav .navbar-spacer { 170 | display: block; } 171 | /* Re-overiding the width 100% declaration to match size of % based container */ 172 | .has-docked-nav .navbar > .container { 173 | width: 80%; } 174 | 175 | /* Popover */ 176 | .popover.open { 177 | display: block; 178 | } 179 | .popover { 180 | display: none; 181 | position: absolute; 182 | top: 0; 183 | left: 0; 184 | background: #fff; 185 | border: 1px solid #eee; 186 | border-radius: 4px; 187 | top: 92%; 188 | left: -50%; 189 | -webkit-filter: drop-shadow(0 0 6px rgba(0,0,0,.1)); 190 | -moz-filter: drop-shadow(0 0 6px rgba(0,0,0,.1)); 191 | filter: drop-shadow(0 0 6px rgba(0,0,0,.1)); } 192 | .popover-item:first-child .popover-link:after, 193 | .popover-item:first-child .popover-link:before { 194 | bottom: 100%; 195 | left: 50%; 196 | border: solid transparent; 197 | content: " "; 198 | height: 0; 199 | width: 0; 200 | position: absolute; 201 | pointer-events: none; } 202 | .popover-item:first-child .popover-link:after { 203 | border-color: rgba(255, 255, 255, 0); 204 | border-bottom-color: #fff; 205 | border-width: 10px; 206 | margin-left: -10px; } 207 | .popover-item:first-child .popover-link:before { 208 | border-color: rgba(238, 238, 238, 0); 209 | border-bottom-color: #eee; 210 | border-width: 11px; 211 | margin-left: -11px; } 212 | .popover-list { 213 | padding: 0; 214 | margin: 0; 215 | list-style: none; } 216 | .popover-item { 217 | padding: 0; 218 | margin: 0; } 219 | .popover-link { 220 | position: relative; 221 | color: #222; 222 | display: block; 223 | padding: 8px 20px; 224 | border-bottom: 1px solid #eee; 225 | text-decoration: none; 226 | text-transform: uppercase; 227 | font-size: 1.0rem; 228 | font-weight: 600; 229 | text-align: center; 230 | letter-spacing: .1rem; } 231 | .popover-item:first-child .popover-link { 232 | border-radius: 4px 4px 0 0; } 233 | .popover-item:last-child .popover-link { 234 | border-radius: 0 0 4px 4px; 235 | border-bottom-width: 0; } 236 | .popover-link:hover { 237 | color: #fff; 238 | background: #33C3F0; } 239 | .popover-link:hover, 240 | .popover-item:first-child .popover-link:hover:after { 241 | border-bottom-color: #33C3F0; } 242 | } 243 | -------------------------------------------------------------------------------- /tests/testapp/static/testapp/skeleton/css/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v3.0.2 | MIT License | git.io/normalize */ 2 | 3 | /** 4 | * 1. Set default font family to sans-serif. 5 | * 2. Prevent iOS text size adjust after orientation change, without disabling 6 | * user zoom. 7 | */ 8 | 9 | html { 10 | font-family: sans-serif; /* 1 */ 11 | -ms-text-size-adjust: 100%; /* 2 */ 12 | -webkit-text-size-adjust: 100%; /* 2 */ 13 | } 14 | 15 | /** 16 | * Remove default margin. 17 | */ 18 | 19 | body { 20 | margin: 0; 21 | } 22 | 23 | /* HTML5 display definitions 24 | ========================================================================== */ 25 | 26 | /** 27 | * Correct `block` display not defined for any HTML5 element in IE 8/9. 28 | * Correct `block` display not defined for `details` or `summary` in IE 10/11 29 | * and Firefox. 30 | * Correct `block` display not defined for `main` in IE 11. 31 | */ 32 | 33 | article, 34 | aside, 35 | details, 36 | figcaption, 37 | figure, 38 | footer, 39 | header, 40 | hgroup, 41 | main, 42 | menu, 43 | nav, 44 | section, 45 | summary { 46 | display: block; 47 | } 48 | 49 | /** 50 | * 1. Correct `inline-block` display not defined in IE 8/9. 51 | * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. 52 | */ 53 | 54 | audio, 55 | canvas, 56 | progress, 57 | video { 58 | display: inline-block; /* 1 */ 59 | vertical-align: baseline; /* 2 */ 60 | } 61 | 62 | /** 63 | * Prevent modern browsers from displaying `audio` without controls. 64 | * Remove excess height in iOS 5 devices. 65 | */ 66 | 67 | audio:not([controls]) { 68 | display: none; 69 | height: 0; 70 | } 71 | 72 | /** 73 | * Address `[hidden]` styling not present in IE 8/9/10. 74 | * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22. 75 | */ 76 | 77 | [hidden], 78 | template { 79 | display: none; 80 | } 81 | 82 | /* Links 83 | ========================================================================== */ 84 | 85 | /** 86 | * Remove the gray background color from active links in IE 10. 87 | */ 88 | 89 | a { 90 | background-color: transparent; 91 | } 92 | 93 | /** 94 | * Improve readability when focused and also mouse hovered in all browsers. 95 | */ 96 | 97 | a:active, 98 | a:hover { 99 | outline: 0; 100 | } 101 | 102 | /* Text-level semantics 103 | ========================================================================== */ 104 | 105 | /** 106 | * Address styling not present in IE 8/9/10/11, Safari, and Chrome. 107 | */ 108 | 109 | abbr[title] { 110 | border-bottom: 1px dotted; 111 | } 112 | 113 | /** 114 | * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. 115 | */ 116 | 117 | b, 118 | strong { 119 | font-weight: bold; 120 | } 121 | 122 | /** 123 | * Address styling not present in Safari and Chrome. 124 | */ 125 | 126 | dfn { 127 | font-style: italic; 128 | } 129 | 130 | /** 131 | * Address variable `h1` font-size and margin within `section` and `article` 132 | * contexts in Firefox 4+, Safari, and Chrome. 133 | */ 134 | 135 | h1 { 136 | font-size: 2em; 137 | margin: 0.67em 0; 138 | } 139 | 140 | /** 141 | * Address styling not present in IE 8/9. 142 | */ 143 | 144 | mark { 145 | background: #ff0; 146 | color: #000; 147 | } 148 | 149 | /** 150 | * Address inconsistent and variable font size in all browsers. 151 | */ 152 | 153 | small { 154 | font-size: 80%; 155 | } 156 | 157 | /** 158 | * Prevent `sub` and `sup` affecting `line-height` in all browsers. 159 | */ 160 | 161 | sub, 162 | sup { 163 | font-size: 75%; 164 | line-height: 0; 165 | position: relative; 166 | vertical-align: baseline; 167 | } 168 | 169 | sup { 170 | top: -0.5em; 171 | } 172 | 173 | sub { 174 | bottom: -0.25em; 175 | } 176 | 177 | /* Embedded content 178 | ========================================================================== */ 179 | 180 | /** 181 | * Remove border when inside `a` element in IE 8/9/10. 182 | */ 183 | 184 | img { 185 | border: 0; 186 | } 187 | 188 | /** 189 | * Correct overflow not hidden in IE 9/10/11. 190 | */ 191 | 192 | svg:not(:root) { 193 | overflow: hidden; 194 | } 195 | 196 | /* Grouping content 197 | ========================================================================== */ 198 | 199 | /** 200 | * Address margin not present in IE 8/9 and Safari. 201 | */ 202 | 203 | figure { 204 | margin: 1em 40px; 205 | } 206 | 207 | /** 208 | * Address differences between Firefox and other browsers. 209 | */ 210 | 211 | hr { 212 | -moz-box-sizing: content-box; 213 | box-sizing: content-box; 214 | height: 0; 215 | } 216 | 217 | /** 218 | * Contain overflow in all browsers. 219 | */ 220 | 221 | pre { 222 | overflow: auto; 223 | } 224 | 225 | /** 226 | * Address odd `em`-unit font size rendering in all browsers. 227 | */ 228 | 229 | code, 230 | kbd, 231 | pre, 232 | samp { 233 | font-family: monospace, monospace; 234 | font-size: 1em; 235 | } 236 | 237 | /* Forms 238 | ========================================================================== */ 239 | 240 | /** 241 | * Known limitation: by default, Chrome and Safari on OS X allow very limited 242 | * styling of `select`, unless a `border` property is set. 243 | */ 244 | 245 | /** 246 | * 1. Correct color not being inherited. 247 | * Known issue: affects color of disabled elements. 248 | * 2. Correct font properties not being inherited. 249 | * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. 250 | */ 251 | 252 | button, 253 | input, 254 | optgroup, 255 | select, 256 | textarea { 257 | color: inherit; /* 1 */ 258 | font: inherit; /* 2 */ 259 | margin: 0; /* 3 */ 260 | } 261 | 262 | /** 263 | * Address `overflow` set to `hidden` in IE 8/9/10/11. 264 | */ 265 | 266 | button { 267 | overflow: visible; 268 | } 269 | 270 | /** 271 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 272 | * All other form control elements do not inherit `text-transform` values. 273 | * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. 274 | * Correct `select` style inheritance in Firefox. 275 | */ 276 | 277 | button, 278 | select { 279 | text-transform: none; 280 | } 281 | 282 | /** 283 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 284 | * and `video` controls. 285 | * 2. Correct inability to style clickable `input` types in iOS. 286 | * 3. Improve usability and consistency of cursor style between image-type 287 | * `input` and others. 288 | */ 289 | 290 | button, 291 | html input[type="button"], /* 1 */ 292 | input[type="reset"], 293 | input[type="submit"] { 294 | -webkit-appearance: button; /* 2 */ 295 | cursor: pointer; /* 3 */ 296 | } 297 | 298 | /** 299 | * Re-set default cursor for disabled elements. 300 | */ 301 | 302 | button[disabled], 303 | html input[disabled] { 304 | cursor: default; 305 | } 306 | 307 | /** 308 | * Remove inner padding and border in Firefox 4+. 309 | */ 310 | 311 | button::-moz-focus-inner, 312 | input::-moz-focus-inner { 313 | border: 0; 314 | padding: 0; 315 | } 316 | 317 | /** 318 | * Address Firefox 4+ setting `line-height` on `input` using `!important` in 319 | * the UA stylesheet. 320 | */ 321 | 322 | input { 323 | line-height: normal; 324 | } 325 | 326 | /** 327 | * It's recommended that you don't attempt to style these elements. 328 | * Firefox's implementation doesn't respect box-sizing, padding, or width. 329 | * 330 | * 1. Address box sizing set to `content-box` in IE 8/9/10. 331 | * 2. Remove excess padding in IE 8/9/10. 332 | */ 333 | 334 | input[type="checkbox"], 335 | input[type="radio"] { 336 | box-sizing: border-box; /* 1 */ 337 | padding: 0; /* 2 */ 338 | } 339 | 340 | /** 341 | * Fix the cursor style for Chrome's increment/decrement buttons. For certain 342 | * `font-size` values of the `input`, it causes the cursor style of the 343 | * decrement button to change from `default` to `text`. 344 | */ 345 | 346 | input[type="number"]::-webkit-inner-spin-button, 347 | input[type="number"]::-webkit-outer-spin-button { 348 | height: auto; 349 | } 350 | 351 | /** 352 | * 1. Address `appearance` set to `searchfield` in Safari and Chrome. 353 | * 2. Address `box-sizing` set to `border-box` in Safari and Chrome 354 | * (include `-moz` to future-proof). 355 | */ 356 | 357 | input[type="search"] { 358 | -webkit-appearance: textfield; /* 1 */ 359 | -moz-box-sizing: content-box; 360 | -webkit-box-sizing: content-box; /* 2 */ 361 | box-sizing: content-box; 362 | } 363 | 364 | /** 365 | * Remove inner padding and search cancel button in Safari and Chrome on OS X. 366 | * Safari (but not Chrome) clips the cancel button when the search input has 367 | * padding (and `textfield` appearance). 368 | */ 369 | 370 | input[type="search"]::-webkit-search-cancel-button, 371 | input[type="search"]::-webkit-search-decoration { 372 | -webkit-appearance: none; 373 | } 374 | 375 | /** 376 | * Define consistent border, margin, and padding. 377 | */ 378 | 379 | fieldset { 380 | border: 1px solid #c0c0c0; 381 | margin: 0 2px; 382 | padding: 0.35em 0.625em 0.75em; 383 | } 384 | 385 | /** 386 | * 1. Correct `color` not being inherited in IE 8/9/10/11. 387 | * 2. Remove padding so people aren't caught out if they zero out fieldsets. 388 | */ 389 | 390 | legend { 391 | border: 0; /* 1 */ 392 | padding: 0; /* 2 */ 393 | } 394 | 395 | /** 396 | * Remove default vertical scrollbar in IE 8/9/10/11. 397 | */ 398 | 399 | textarea { 400 | overflow: auto; 401 | } 402 | 403 | /** 404 | * Don't inherit the `font-weight` (applied by a rule above). 405 | * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. 406 | */ 407 | 408 | optgroup { 409 | font-weight: bold; 410 | } 411 | 412 | /* Tables 413 | ========================================================================== */ 414 | 415 | /** 416 | * Remove most spacing between table cells. 417 | */ 418 | 419 | table { 420 | border-collapse: collapse; 421 | border-spacing: 0; 422 | } 423 | 424 | td, 425 | th { 426 | padding: 0; 427 | } 428 | -------------------------------------------------------------------------------- /tests/testapp/static/testapp/skeleton/css/skeleton.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Skeleton V2.0.4 3 | * Copyright 2014, Dave Gamache 4 | * www.getskeleton.com 5 | * Free to use under the MIT license. 6 | * http://www.opensource.org/licenses/mit-license.php 7 | * 12/29/2014 8 | */ 9 | 10 | 11 | /* Table of contents 12 | –––––––––––––––––––––––––––––––––––––––––––––––––– 13 | - Grid 14 | - Base Styles 15 | - Typography 16 | - Links 17 | - Buttons 18 | - Forms 19 | - Lists 20 | - Code 21 | - Tables 22 | - Spacing 23 | - Utilities 24 | - Clearing 25 | - Media Queries 26 | */ 27 | 28 | 29 | /* Grid 30 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 31 | .container { 32 | position: relative; 33 | width: 100%; 34 | max-width: 960px; 35 | margin: 0 auto; 36 | padding: 0 20px; 37 | box-sizing: border-box; } 38 | .column, 39 | .columns { 40 | width: 100%; 41 | float: left; 42 | box-sizing: border-box; } 43 | 44 | /* For devices larger than 400px */ 45 | @media (min-width: 400px) { 46 | .container { 47 | width: 85%; 48 | padding: 0; } 49 | } 50 | 51 | /* For devices larger than 550px */ 52 | @media (min-width: 550px) { 53 | .container { 54 | width: 80%; } 55 | .column, 56 | .columns { 57 | margin-left: 4%; } 58 | .column:first-child, 59 | .columns:first-child { 60 | margin-left: 0; } 61 | 62 | .one.column, 63 | .one.columns { width: 4.66666666667%; } 64 | .two.columns { width: 13.3333333333%; } 65 | .three.columns { width: 22%; } 66 | .four.columns { width: 30.6666666667%; } 67 | .five.columns { width: 39.3333333333%; } 68 | .six.columns { width: 48%; } 69 | .seven.columns { width: 56.6666666667%; } 70 | .eight.columns { width: 65.3333333333%; } 71 | .nine.columns { width: 74.0%; } 72 | .ten.columns { width: 82.6666666667%; } 73 | .eleven.columns { width: 91.3333333333%; } 74 | .twelve.columns { width: 100%; margin-left: 0; } 75 | 76 | .one-third.column { width: 30.6666666667%; } 77 | .two-thirds.column { width: 65.3333333333%; } 78 | 79 | .one-half.column { width: 48%; } 80 | 81 | /* Offsets */ 82 | .offset-by-one.column, 83 | .offset-by-one.columns { margin-left: 8.66666666667%; } 84 | .offset-by-two.column, 85 | .offset-by-two.columns { margin-left: 17.3333333333%; } 86 | .offset-by-three.column, 87 | .offset-by-three.columns { margin-left: 26%; } 88 | .offset-by-four.column, 89 | .offset-by-four.columns { margin-left: 34.6666666667%; } 90 | .offset-by-five.column, 91 | .offset-by-five.columns { margin-left: 43.3333333333%; } 92 | .offset-by-six.column, 93 | .offset-by-six.columns { margin-left: 52%; } 94 | .offset-by-seven.column, 95 | .offset-by-seven.columns { margin-left: 60.6666666667%; } 96 | .offset-by-eight.column, 97 | .offset-by-eight.columns { margin-left: 69.3333333333%; } 98 | .offset-by-nine.column, 99 | .offset-by-nine.columns { margin-left: 78.0%; } 100 | .offset-by-ten.column, 101 | .offset-by-ten.columns { margin-left: 86.6666666667%; } 102 | .offset-by-eleven.column, 103 | .offset-by-eleven.columns { margin-left: 95.3333333333%; } 104 | 105 | .offset-by-one-third.column, 106 | .offset-by-one-third.columns { margin-left: 34.6666666667%; } 107 | .offset-by-two-thirds.column, 108 | .offset-by-two-thirds.columns { margin-left: 69.3333333333%; } 109 | 110 | .offset-by-one-half.column, 111 | .offset-by-one-half.columns { margin-left: 52%; } 112 | 113 | } 114 | 115 | 116 | /* Base Styles 117 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 118 | /* NOTE 119 | html is set to 62.5% so that all the REM measurements throughout Skeleton 120 | are based on 10px sizing. So basically 1.5rem = 15px :) */ 121 | html { 122 | font-size: 62.5%; } 123 | body { 124 | font-size: 1.5em; /* currently ems cause chrome bug misinterpreting rems on body element */ 125 | line-height: 1.6; 126 | font-weight: 400; 127 | font-family: "Raleway", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif; 128 | color: #222; } 129 | 130 | 131 | /* Typography 132 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 133 | h1, h2, h3, h4, h5, h6 { 134 | margin-top: 0; 135 | margin-bottom: 2rem; 136 | font-weight: 300; } 137 | h1 { font-size: 4.0rem; line-height: 1.2; letter-spacing: -.1rem;} 138 | h2 { font-size: 3.6rem; line-height: 1.25; letter-spacing: -.1rem; } 139 | h3 { font-size: 3.0rem; line-height: 1.3; letter-spacing: -.1rem; } 140 | h4 { font-size: 2.4rem; line-height: 1.35; letter-spacing: -.08rem; } 141 | h5 { font-size: 1.8rem; line-height: 1.5; letter-spacing: -.05rem; } 142 | h6 { font-size: 1.5rem; line-height: 1.6; letter-spacing: 0; } 143 | 144 | /* Larger than phablet */ 145 | @media (min-width: 550px) { 146 | h1 { font-size: 5.0rem; } 147 | h2 { font-size: 4.2rem; } 148 | h3 { font-size: 3.6rem; } 149 | h4 { font-size: 3.0rem; } 150 | h5 { font-size: 2.4rem; } 151 | h6 { font-size: 1.5rem; } 152 | } 153 | 154 | p { 155 | margin-top: 0; } 156 | 157 | 158 | /* Links 159 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 160 | a { 161 | color: #1EAEDB; } 162 | a:hover { 163 | color: #0FA0CE; } 164 | 165 | 166 | /* Buttons 167 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 168 | .button, 169 | button, 170 | input[type="submit"], 171 | input[type="reset"], 172 | input[type="button"] { 173 | display: inline-block; 174 | height: 38px; 175 | padding: 0 30px; 176 | color: #555; 177 | text-align: center; 178 | font-size: 11px; 179 | font-weight: 600; 180 | line-height: 38px; 181 | letter-spacing: .1rem; 182 | text-transform: uppercase; 183 | text-decoration: none; 184 | white-space: nowrap; 185 | background-color: transparent; 186 | border-radius: 4px; 187 | border: 1px solid #bbb; 188 | cursor: pointer; 189 | box-sizing: border-box; } 190 | .button:hover, 191 | button:hover, 192 | input[type="submit"]:hover, 193 | input[type="reset"]:hover, 194 | input[type="button"]:hover, 195 | .button:focus, 196 | button:focus, 197 | input[type="submit"]:focus, 198 | input[type="reset"]:focus, 199 | input[type="button"]:focus { 200 | color: #333; 201 | border-color: #888; 202 | outline: 0; } 203 | .button.button-primary, 204 | button.button-primary, 205 | input[type="submit"].button-primary, 206 | input[type="reset"].button-primary, 207 | input[type="button"].button-primary { 208 | color: #FFF; 209 | background-color: #33C3F0; 210 | border-color: #33C3F0; } 211 | .button.button-primary:hover, 212 | button.button-primary:hover, 213 | input[type="submit"].button-primary:hover, 214 | input[type="reset"].button-primary:hover, 215 | input[type="button"].button-primary:hover, 216 | .button.button-primary:focus, 217 | button.button-primary:focus, 218 | input[type="submit"].button-primary:focus, 219 | input[type="reset"].button-primary:focus, 220 | input[type="button"].button-primary:focus { 221 | color: #FFF; 222 | background-color: #1EAEDB; 223 | border-color: #1EAEDB; } 224 | 225 | 226 | /* Forms 227 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 228 | input[type="email"], 229 | input[type="number"], 230 | input[type="search"], 231 | input[type="text"], 232 | input[type="tel"], 233 | input[type="url"], 234 | input[type="password"], 235 | textarea, 236 | select { 237 | height: 38px; 238 | padding: 6px 10px; /* The 6px vertically centers text on FF, ignored by Webkit */ 239 | background-color: #fff; 240 | border: 1px solid #D1D1D1; 241 | border-radius: 4px; 242 | box-shadow: none; 243 | box-sizing: border-box; } 244 | /* Removes awkward default styles on some inputs for iOS */ 245 | input[type="email"], 246 | input[type="number"], 247 | input[type="search"], 248 | input[type="text"], 249 | input[type="tel"], 250 | input[type="url"], 251 | input[type="password"], 252 | textarea { 253 | -webkit-appearance: none; 254 | -moz-appearance: none; 255 | appearance: none; } 256 | textarea { 257 | min-height: 65px; 258 | padding-top: 6px; 259 | padding-bottom: 6px; } 260 | input[type="email"]:focus, 261 | input[type="number"]:focus, 262 | input[type="search"]:focus, 263 | input[type="text"]:focus, 264 | input[type="tel"]:focus, 265 | input[type="url"]:focus, 266 | input[type="password"]:focus, 267 | textarea:focus, 268 | select:focus { 269 | border: 1px solid #33C3F0; 270 | outline: 0; } 271 | label, 272 | legend { 273 | display: block; 274 | margin-bottom: .5rem; 275 | font-weight: 600; } 276 | fieldset { 277 | padding: 0; 278 | border-width: 0; } 279 | input[type="checkbox"], 280 | input[type="radio"] { 281 | display: inline; } 282 | label > .label-body { 283 | display: inline-block; 284 | margin-left: .5rem; 285 | font-weight: normal; } 286 | 287 | 288 | /* Lists 289 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 290 | ul { 291 | list-style: circle inside; } 292 | ol { 293 | list-style: decimal inside; } 294 | ol, ul { 295 | padding-left: 0; 296 | margin-top: 0; } 297 | ul ul, 298 | ul ol, 299 | ol ol, 300 | ol ul { 301 | margin: 1.5rem 0 1.5rem 3rem; 302 | font-size: 90%; } 303 | li { 304 | margin-bottom: 1rem; } 305 | 306 | 307 | /* Code 308 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 309 | code { 310 | padding: .2rem .5rem; 311 | margin: 0 .2rem; 312 | font-size: 90%; 313 | white-space: nowrap; 314 | background: #F1F1F1; 315 | border: 1px solid #E1E1E1; 316 | border-radius: 4px; } 317 | pre > code { 318 | display: block; 319 | padding: 1rem 1.5rem; 320 | white-space: pre; } 321 | 322 | 323 | /* Tables 324 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 325 | th, 326 | td { 327 | padding: 12px 15px; 328 | text-align: left; 329 | border-bottom: 1px solid #E1E1E1; } 330 | th:first-child, 331 | td:first-child { 332 | padding-left: 0; } 333 | th:last-child, 334 | td:last-child { 335 | padding-right: 0; } 336 | 337 | 338 | /* Spacing 339 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 340 | button, 341 | .button { 342 | margin-bottom: 1rem; } 343 | input, 344 | textarea, 345 | select, 346 | fieldset { 347 | margin-bottom: 1.5rem; } 348 | pre, 349 | blockquote, 350 | dl, 351 | figure, 352 | table, 353 | p, 354 | ul, 355 | ol, 356 | form { 357 | margin-bottom: 2.5rem; } 358 | 359 | 360 | /* Utilities 361 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 362 | .u-full-width { 363 | width: 100%; 364 | box-sizing: border-box; } 365 | .u-max-full-width { 366 | max-width: 100%; 367 | box-sizing: border-box; } 368 | .u-pull-right { 369 | float: right; } 370 | .u-pull-left { 371 | float: left; } 372 | 373 | 374 | /* Misc 375 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 376 | hr { 377 | margin-top: 3rem; 378 | margin-bottom: 3.5rem; 379 | border-width: 0; 380 | border-top: 1px solid #E1E1E1; } 381 | 382 | 383 | /* Clearing 384 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 385 | 386 | /* Self Clearing Goodness */ 387 | .container:after, 388 | .row:after, 389 | .u-cf { 390 | content: ""; 391 | display: table; 392 | clear: both; } 393 | 394 | 395 | /* Media Queries 396 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 397 | /* 398 | Note: The best way to structure the use of media queries is to create the queries 399 | near the relevant code. For example, if you wanted to change the styles for buttons 400 | on small devices, paste the mobile query code up in the buttons section and style it 401 | there. 402 | */ 403 | 404 | 405 | /* Larger than mobile */ 406 | @media (min-width: 400px) {} 407 | 408 | /* Larger than phablet (also point when grid becomes active) */ 409 | @media (min-width: 550px) {} 410 | 411 | /* Larger than tablet */ 412 | @media (min-width: 750px) {} 413 | 414 | /* Larger than desktop */ 415 | @media (min-width: 1000px) {} 416 | 417 | /* Larger than Desktop HD */ 418 | @media (min-width: 1200px) {} 419 | -------------------------------------------------------------------------------- /tests/testapp/static/testapp/skeleton/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-wiki/django-nyt/af833c9be6e8af9527c421d811b384f12461650a/tests/testapp/static/testapp/skeleton/images/favicon.png -------------------------------------------------------------------------------- /tests/testapp/static/testapp/skeleton/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | Your page title here :) 9 | 10 | 11 | 12 | 14 | 15 | 16 | 18 | 19 | 20 | 22 | 23 | 24 | 25 | 27 | 28 | 29 | 30 | 31 | 32 | 34 |
    35 |
    36 |
    37 |

    Basic Page

    38 |

    This index.html page is a placeholder with the CSS, font and favicon. It's just waiting for you to add some content! If you need some help hit up the Skeleton documentation.

    39 |
    40 |
    41 |
    42 | 43 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /tests/testapp/templates/testapp/index.html: -------------------------------------------------------------------------------- 1 | {% load i18n static humanize %} 2 | 3 | 4 | 5 | 7 | 8 | django-nyt notification test panel 9 | 10 | 11 | 12 | 14 | 15 | 16 | 18 | 19 | 20 | 22 | 23 | 24 | 25 | 26 | 28 | 29 | 30 | 31 | 32 | 33 | 49 | 50 | 51 |
    52 |

    Notification tests

    53 |
    54 |
    55 |

    This is a test page with a simple styling to test the notification system and convince potential users that django-nyt is pretty cool.

    56 |
    57 |
    58 |
    59 | 60 | 61 |
    62 |

    Notification live stream

    63 |
    64 |
    65 |

    Here you'll see new notifications as they arrive from the server.

    66 |
    67 |
    68 |
      69 |
    • Notifications (0):
    • 70 |
    • {% trans "No notifications found" %}
    • 71 |
    72 | 73 | {% trans "Mark seen and clear" %} 74 | 75 | 76 | {% trans "Fetch" %} 77 | 78 |
    79 |
    80 |
    81 | 82 | 83 |
    84 |

    Create a notification

    85 |
    86 |
    87 |

    After filling in the form, everyone subscribed to receive notification instantly for the Test Model object should receive them.

    88 |

    89 | NB! By design, saving a notification does not trigger displaying one. This way, you can play around with automatic updating and channels. 90 |

    91 |
    92 |
    93 | 94 |
    95 | {% csrf_token %} 96 | 97 | {{ testmodel_form.name }} 98 | 99 |
    100 | 101 |
    102 |
    103 |
    104 | 105 | 106 | 107 |
    108 |

    Login as another user

    109 |
    110 |
    111 |

    Select one of the test user accounts and you'll be logged in (this is a test application, so don't use this mechanism in production)

    112 |
    113 |
    114 | 115 | {% for user in users %} 116 |

    117 | Login as {{ user.username }} 118 |

    119 | {% endfor %} 120 | 121 |
    122 |
    123 |
    124 | 125 | 126 | 127 |
    128 |

    Notification settings

    129 |
    130 |
    131 |

    Change settings of currently logged in user

    132 |
    133 | 134 |
    135 |
    136 | {% csrf_token %} 137 | {% if settings_form %} 138 | {{ settings_form.as_p }} 139 | 140 | {% else %} 141 | Not logged in 142 | {% endif %} 143 |
    144 |
    145 | 146 |
    147 |
    148 | 149 | 150 | 151 |
    152 |

    List of notifications (static)

    153 |
    154 |
    155 |

    Please reload this page to have the list updated

    156 |
    157 |
    158 |
    159 |
    160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | {% for notification in notifications %} 169 | 170 | 171 | 172 | 173 | 174 | 175 | {% endfor %} 176 | 177 |
    MessageCreatedSeenType
    {{ notification.message }} x{{ notification.occurrences }}{{ notification.modified|naturaltime }}{{ notification.is_viewed|yesno }}{{ notification.subscription.notification_type.key }}
    178 |
    179 | 180 | 181 | 182 | 183 | 207 | 208 | 312 | 313 | 314 | 315 | -------------------------------------------------------------------------------- /tests/testapp/templates/testapp/notifications/admin.txt: -------------------------------------------------------------------------------- 1 | Admin 2 | -------------------------------------------------------------------------------- /tests/testapp/templates/testapp/notifications/admin_subject.txt: -------------------------------------------------------------------------------- 1 | notifications for admin 2 | -------------------------------------------------------------------------------- /tests/testapp/templates/testapp/notifications/email.txt: -------------------------------------------------------------------------------- 1 | Test 2 | -------------------------------------------------------------------------------- /tests/testapp/templates/testapp/notifications/email_subject.txt: -------------------------------------------------------------------------------- 1 | subject 2 | -------------------------------------------------------------------------------- /tests/testapp/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase # noqa 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /tests/testapp/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path as url 2 | 3 | from . import views 4 | 5 | app_name = "testapp" 6 | 7 | 8 | urlpatterns = [ 9 | url(r"^$", views.TestIndex.as_view(), name="index"), 10 | url(r"^create/$", views.CreateTestModelView.as_view(), name="create"), 11 | url(r"^login-as/(?P\d+)/$", views.TestLoginAsUser.as_view(), name="login_as"), 12 | ] 13 | -------------------------------------------------------------------------------- /tests/testapp/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.contrib.auth import login 3 | from django.shortcuts import redirect 4 | from django.utils.decorators import method_decorator 5 | from django.views.generic.base import TemplateView 6 | from django.views.generic.detail import DetailView 7 | from django.views.generic.edit import CreateView 8 | 9 | from . import forms 10 | from . import models 11 | from django_nyt.decorators import json_view 12 | from django_nyt.forms import SettingsForm 13 | from django_nyt.models import Notification 14 | from django_nyt.models import Settings 15 | 16 | 17 | class TestIndex(TemplateView): 18 | 19 | template_name = "testapp/index.html" 20 | 21 | def get_context_data(self, **kwargs): 22 | c = TemplateView.get_context_data(self, **kwargs) 23 | user_model = get_user_model() 24 | c["users"] = user_model.objects.all() 25 | if self.request.user.is_authenticated: 26 | c["notifications"] = Notification.objects.filter( 27 | user=self.request.user 28 | ).order_by("-created") 29 | c["settings_form"] = SettingsForm( 30 | instance=Settings.get_default_setting(self.request.user) 31 | ) 32 | c["testmodel_form"] = forms.TestModelForm() 33 | return c 34 | 35 | 36 | class CreateTestModelView(CreateView): 37 | model = models.TestModel 38 | form_class = forms.TestModelForm 39 | 40 | @method_decorator(json_view) 41 | def dispatch(self, request, *args, **kwargs): 42 | return CreateView.dispatch(self, request, *args, **kwargs) 43 | 44 | def form_valid(self, form): 45 | # There is a signal on TestModel that will create notifications 46 | form.save() 47 | return {"OK": True} 48 | 49 | def form_invalid(self, form): 50 | return {"OK": False} 51 | 52 | 53 | class TestLoginAsUser(DetailView): 54 | 55 | model = get_user_model() 56 | 57 | def get(self, *args, **kwargs): 58 | user = self.get_object() 59 | user.backend = "django.contrib.auth.backends.ModelBackend" 60 | login(self.request, user) 61 | return redirect("testapp:index") 62 | --------------------------------------------------------------------------------