├── .coveragerc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── changelog-ci.yaml │ └── test.yaml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── manage.py ├── newsfeed ├── __init__.py ├── admin.py ├── app_settings.py ├── apps.py ├── constants.py ├── forms.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_alter_issue_issue_type.py │ └── __init__.py ├── models.py ├── querysets.py ├── signals.py ├── static │ └── newsfeed │ │ └── js │ │ └── newsletter_subscription.js ├── templates │ └── newsfeed │ │ ├── base.html │ │ ├── email │ │ ├── email_verification.html │ │ ├── email_verification.txt │ │ ├── email_verification_subject.txt │ │ └── newsletter_email.html │ │ ├── issue_detail.html │ │ ├── issue_list.html │ │ ├── issue_posts.html │ │ ├── latest_issue.html │ │ ├── messages.html │ │ ├── newsletter_subscribe.html │ │ ├── newsletter_subscription_confirm.html │ │ ├── newsletter_unsubscribe.html │ │ └── subscription_form.html ├── urls.py ├── utils │ ├── __init__.py │ ├── check_ajax.py │ ├── send_newsletters.py │ └── send_verification.py └── views.py ├── requirements_dev.txt ├── requirements_test.txt ├── runtests.py ├── setup.cfg ├── setup.py ├── test_project ├── __init__.py ├── asgi.py ├── settings.py ├── urls.py └── wsgi.py ├── tests ├── __init__.py ├── test_admin.py ├── test_apps.py ├── test_models.py ├── test_utils.py └── test_views.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | 4 | [report] 5 | omit = 6 | *site-packages* 7 | *tests* 8 | *.tox* 9 | *migrations* 10 | wsgi.py 11 | asgi.py 12 | show_missing = True 13 | exclude_lines = 14 | raise NotImplementedError 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Actual behavior** 20 | A clear and concise description of what actually happened. 21 | 22 | **Your Environment:** 23 | - Python Version: [e.g. 3.8] 24 | - Django Version [e.g. 3.2] 25 | - Django-newsfeed Version [e.g. 0.8.4] 26 | 27 | **Additional context** 28 | Add any other context about the problem here. 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/changelog-ci.yaml: -------------------------------------------------------------------------------- 1 | name: Changelog CI 2 | 3 | on: 4 | pull_request: 5 | types: [opened] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | - name: Run Changelog CI 15 | uses: saadmk11/changelog-ci@v0.7.0 16 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Django Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-20.04 11 | 12 | strategy: 13 | fail-fast: false 14 | 15 | matrix: 16 | python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] 17 | django-version: ["2.2", "3.0", "3.1", "3.2", "4.0"] 18 | 19 | exclude: 20 | - python-version: "3.6" 21 | django-version: "4.0" 22 | 23 | - python-version: "3.7" 24 | django-version: "4.0" 25 | 26 | - python-version: "3.10" 27 | django-version: "2.2" 28 | - python-version: "3.10" 29 | django-version: "3.0" 30 | - python-version: "3.10" 31 | django-version: "3.1" 32 | 33 | steps: 34 | - uses: actions/checkout@v2 35 | 36 | - name: Install and Setup Python ${{ matrix.python-version }} 37 | uses: actions/setup-python@v2 38 | with: 39 | python-version: ${{ matrix.python-version }} 40 | 41 | - name: Install Dependencies 42 | run: | 43 | python -m pip install --upgrade pip 44 | python -m pip install "Django==${{ matrix.django-version }}" 45 | python -m pip install -r requirements_test.txt 46 | 47 | - name: Run tests with Python ${{ matrix.python-version }} and Django ${{ matrix.django-version }} 48 | run: | 49 | TOX_ENV=$(echo "py${{ matrix.python-version}}-django${{ matrix.django-version}}" | tr -d .) 50 | python -m tox -e $TOX_ENV 51 | 52 | - name: Publish coverage results 53 | uses: codecov/codecov-action@v1 54 | 55 | lint: 56 | runs-on: ubuntu-latest 57 | 58 | steps: 59 | - uses: actions/checkout@v2 60 | 61 | - name: Install and Setup Python 3.8 62 | uses: actions/setup-python@v2 63 | with: 64 | python-version: 3.8 65 | 66 | - name: Install Dependencies 67 | run: | 68 | python -m pip install --upgrade pip 69 | python -m pip install -r requirements_test.txt 70 | 71 | - name: Run Flake8 72 | run: python -m tox -e lint 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | local/ 3 | bin/ 4 | include/ 5 | pip-selfcheck.json 6 | 7 | course_import.py 8 | .idea/ 9 | dist/ 10 | .vscode/ 11 | db.json 12 | htmlcov/ 13 | media/ 14 | db.sqlite3 15 | staticfiles/ 16 | .env 17 | .coverage 18 | celerybeat.pid 19 | celerybeat-schedule 20 | 21 | # Byte-compiled / optimized / DLL files 22 | __pycache__/ 23 | *.py[cod] 24 | *$py.class 25 | 26 | # C extensions 27 | *.so 28 | 29 | # Distribution / packaging 30 | .Python 31 | env/ 32 | build/ 33 | develop-eggs/ 34 | downloads/ 35 | eggs/ 36 | .eggs/ 37 | lib/ 38 | lib64/ 39 | parts/ 40 | sdist/ 41 | var/ 42 | wheels/ 43 | *.egg-info/ 44 | .installed.cfg 45 | *.egg 46 | 47 | # PyInstaller 48 | # Usually these files are written by a python script from a template 49 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 50 | *.manifest 51 | *.spec 52 | 53 | # Installer logs 54 | pip-log.txt 55 | pip-delete-this-directory.txt 56 | 57 | # Unit test / coverage reports 58 | htmlcov/ 59 | .tox/ 60 | .coverage 61 | .coverage.* 62 | .cache 63 | nosetests.xml 64 | coverage.xml 65 | *.cover 66 | .hypothesis/ 67 | 68 | # Translations 69 | *.mo 70 | *.pot 71 | 72 | # Django stuff: 73 | *.log 74 | local_settings.py 75 | 76 | # Flask stuff: 77 | instance/ 78 | .webassets-cache 79 | 80 | # Scrapy stuff: 81 | .scrapy 82 | 83 | # Sphinx documentation 84 | docs/_build/ 85 | 86 | # PyBuilder 87 | target/ 88 | 89 | # Jupyter Notebook 90 | .ipynb_checkpoints 91 | 92 | # pyenv 93 | .python-version 94 | 95 | # celery beat schedule file 96 | celerybeat-schedule 97 | 98 | # SageMath parsed files 99 | *.sage.py 100 | 101 | # dotenv 102 | .env 103 | 104 | # virtualenv 105 | .venv 106 | venv/ 107 | ENV/ 108 | 109 | # Spyder project settings 110 | .spyderproject 111 | .spyproject 112 | 113 | # Rope project settings 114 | .ropeproject 115 | 116 | # mkdocs documentation 117 | /site 118 | 119 | # mypy 120 | .mypy_cache/ 121 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Version: 0.8.8 2 | 3 | * [#80](https://github.com/saadmk11/django-newsfeed/pull/80): Bump model-bakery from 1.3.3 to 1.6.0 4 | * [#82](https://github.com/saadmk11/django-newsfeed/pull/82): Add missing migrations 5 | * [#81](https://github.com/saadmk11/django-newsfeed/pull/81): Bump django from 3.2.10 to 3.2.14 6 | 7 | 8 | # Version: 0.8.7 9 | 10 | * [#59](https://github.com/saadmk11/django-newsfeed/pull/59): Bump django from 3.2.8 to 3.2.9 11 | * [#63](https://github.com/saadmk11/django-newsfeed/pull/63): Fix Bug in `MONTHLY_ISSUE` Constant 12 | * [#64](https://github.com/saadmk11/django-newsfeed/pull/64): Add Support For Django 4.0 and Python 3.10 13 | * [#65](https://github.com/saadmk11/django-newsfeed/pull/65): Bump django from 3.2.9 to 3.2.10 14 | 15 | 16 | # Version: 0.8.6 17 | 18 | * [#42](https://github.com/saadmk11/django-newsfeed/pull/42): Bump django from 3.1.8 to 3.1.9 19 | * [#33](https://github.com/saadmk11/django-newsfeed/pull/33): Bump django from 3.1.6 to 3.1.7 20 | * [#32](https://github.com/saadmk11/django-newsfeed/pull/32): Bump django from 3.1.5 to 3.1.6 21 | * [#30](https://github.com/saadmk11/django-newsfeed/pull/30): Add License and supported python version in the README.rst 22 | * [#28](https://github.com/saadmk11/django-newsfeed/pull/28): Improve Subscription Success Message 23 | * [#29](https://github.com/saadmk11/django-newsfeed/pull/29): Add django 3.2 and python 3.8, 3.9 in the test suite 24 | * [#27](https://github.com/saadmk11/django-newsfeed/pull/27): Bump Changelog-CI version to 0.7.0 25 | * [#34](https://github.com/saadmk11/django-newsfeed/pull/34): Bump coverage from 5.4 to 5.5 26 | * [#39](https://github.com/saadmk11/django-newsfeed/pull/39): Bump model-bakery from 1.3.0 to 1.3.1 27 | * [#35](https://github.com/saadmk11/django-newsfeed/pull/35): Bump django from 3.1.7 to 3.1.8 28 | * [#36](https://github.com/saadmk11/django-newsfeed/pull/36): Use GitHub Actions to Run Django Tests and Remove Travis CI 29 | * [#38](https://github.com/saadmk11/django-newsfeed/pull/38): Bump model-bakery from 1.2.1 to 1.3.0 30 | * [#44](https://github.com/saadmk11/django-newsfeed/pull/44): Bump model-bakery from 1.3.1 to 1.3.2 31 | * [#46](https://github.com/saadmk11/django-newsfeed/pull/46): Bump django from 3.1.9 to 3.2.6 32 | * [#47](https://github.com/saadmk11/django-newsfeed/pull/47): Bump django from 3.2.6 to 3.2.7 33 | * [#53](https://github.com/saadmk11/django-newsfeed/pull/53): Added Bug Report Issue Template 34 | * [#54](https://github.com/saadmk11/django-newsfeed/pull/54): Feature Request Issue Template 35 | * [#55](https://github.com/saadmk11/django-newsfeed/pull/55): Update Test Project Settings With `DEFAULT_AUTO_FIELD` 36 | * [#56](https://github.com/saadmk11/django-newsfeed/pull/56): Add Custom `is_ajax()` function to check if request is ajax 37 | * [#49](https://github.com/saadmk11/django-newsfeed/pull/49): Bump model-bakery from 1.3.2 to 1.3.3 38 | * [#51](https://github.com/saadmk11/django-newsfeed/pull/51): Bump django from 3.2.7 to 3.2.8 39 | 40 | 41 | # Version: 0.8.4 42 | 43 | * [#23](https://github.com/saadmk11/django-newsfeed/pull/23): Bump django from 3.1.4 to 3.1.5 44 | * [#24](https://github.com/saadmk11/django-newsfeed/pull/24): Bump coverage from 5.3.1 to 5.4 45 | * [#18](https://github.com/saadmk11/django-newsfeed/pull/18): Bump django from 3.1.2 to 3.1.3 46 | * [#16](https://github.com/saadmk11/django-newsfeed/pull/16): Bump django from 3.1.1 to 3.1.2 47 | * [#17](https://github.com/saadmk11/django-newsfeed/pull/17): Bump model-bakery from 1.1.1 to 1.2.0 48 | * [#20](https://github.com/saadmk11/django-newsfeed/pull/20): Bump model-bakery from 1.2.0 to 1.2.1 49 | * [#21](https://github.com/saadmk11/django-newsfeed/pull/21): Bump django from 3.1.3 to 3.1.4 50 | * [#22](https://github.com/saadmk11/django-newsfeed/pull/22): Bump coverage from 5.3 to 5.3.1 51 | * [#25](https://github.com/saadmk11/django-newsfeed/pull/25): Don't add same message twice on unsubscribe view 52 | 53 | 54 | # Version: 0.8.3 55 | 56 | * [#12](https://github.com/saadmk11/django-newsfeed/pull/12): Added Changelog CI Badge to Readme 57 | * [#10](https://github.com/saadmk11/django-newsfeed/pull/10): Bump coverage from 5.2.1 to 5.3 58 | * [#11](https://github.com/saadmk11/django-newsfeed/pull/11): Added Changelog CI 59 | * [#5](https://github.com/saadmk11/django-newsfeed/pull/5): Bump django from 3.1 to 3.1.1 60 | * [#13](https://github.com/saadmk11/django-newsfeed/pull/13): Added subscribe, unsubscribe and verification email sent signals 61 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ------------ 3 | 4 | Contribution is always welcome and appreciated. Feel Free to submit 5 | issues or pull requests by following the guide below. 6 | 7 | Report A Bug 8 | ------------ 9 | 10 | Report bugs at https://github.com/saadmk11/django-newsfeed/issues. 11 | 12 | Please include these on your bug report: 13 | 14 | * How you came across this bug. 15 | * Details about your local setup. 16 | * Version of python and django you are using. 17 | * Traceback of the error (if any). 18 | 19 | Write Documentation 20 | ------------------- 21 | 22 | If you find anything that may require more explaining or is not documented 23 | feel free to update it and submit a pull request. 24 | 25 | Request A New Feature 26 | --------------------- 27 | 28 | You can add your feature request `here`_. 29 | 30 | Please include these on your feature request: 31 | 32 | * Detailed information of your feature request. 33 | * Explain how it will work. 34 | 35 | .. _here: https://github.com/saadmk11/django-newsfeed 36 | 37 | Fix Bugs 38 | -------- 39 | 40 | Look through the `GitHub issues`_ for bugs. If you find anything you want to work 41 | on feel free to get started on it. 42 | 43 | Implement A Feature 44 | ------------------- 45 | 46 | If you find any issue on the projects `GitHub issues`_ with ``Feature`` tag 47 | and you want to implement it you are more than welcome to leave a comment on 48 | the issue we will get back to you soon. 49 | 50 | Give Feedback 51 | ------------- 52 | 53 | If you are using this package we would love to know your experience and suggestions. 54 | You can open a `GitHub issues`_ and we can talk more about it. 55 | Feedbacks are always appreciated. 56 | 57 | .. _GitHub issues: https://github.com/saadmk11/django-newsfeed 58 | 59 | 60 | Setting up the project for development 61 | -------------------------------------- 62 | 63 | Follow the steps below to set up ``django-newsfeed`` locally. 64 | 65 | 1. Fork ``django-newsfeed`` repository on GitHub. 66 | 67 | ``https://github.com/saadmk11/django-newsfeed`` 68 | 69 | 2. Clone the forked repository on your local machine: 70 | 71 | .. code-block:: sh 72 | 73 | $ git clone git@github.com:/django-newsfeed.git 74 | 75 | 3. change directory to ``django-newsfeed`` and install ``django-newsfeed`` inside a virtualenv: 76 | 77 | .. code-block:: sh 78 | 79 | $ mkvirtualenv django-newsfeed # you can use virtualenv instead of virtualenvwrapper 80 | $ cd django-newsfeed/ 81 | $ python setup.py develop 82 | 83 | 4 Setup and Run the development server: 84 | 85 | .. code-block:: sh 86 | 87 | $ python manage.py migrate 88 | $ python manage.py runserver # http://127.0.0.1:8000/ 89 | 90 | 5. Create a new branch for local development: 91 | 92 | .. code-block:: sh 93 | 94 | $ git checkout -b 95 | 96 | 97 | 6. Make the changes you need. Include tests if you have made any changes to the code. 98 | 99 | 7. Run tests to make sure everything is working properly: 100 | 101 | .. code-block:: sh 102 | 103 | $ tox 104 | 105 | run ``pip install tox`` if its not already installed in your machine 106 | 107 | 8. Commit the changes and push it to GitHub: 108 | 109 | .. code-block:: sh 110 | 111 | $ git add . 112 | $ git commit -m "" 113 | $ git push 114 | 115 | 9. Create a pull request to ``django-newsfeed`` 116 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CONTRIBUTING.rst 2 | include LICENSE 3 | include README.rst 4 | 5 | recursive-include newsfeed/static * 6 | recursive-include newsfeed/migrations * 7 | recursive-include newsfeed/templates * 8 | recursive-include newsfeed/utils * 9 | 10 | global-exclude __pycache__ 11 | global-exclude *.py[co] 12 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-newsfeed 2 | =============== 3 | 4 | .. image:: https://badge.fury.io/py/django-newsfeed.svg 5 | :target: https://badge.fury.io/py/django-newsfeed 6 | 7 | .. image:: https://github.com/saadmk11/django-newsfeed/actions/workflows/test.yaml/badge.svg 8 | :target: https://github.com/saadmk11/django-newsfeed/actions/workflows/test.yaml 9 | 10 | .. image:: https://codecov.io/gh/saadmk11/django-newsfeed/branch/master/graph/badge.svg 11 | :target: https://codecov.io/gh/saadmk11/django-newsfeed 12 | 13 | .. image:: https://github.com/saadmk11/django-newsfeed/workflows/Changelog%20CI/badge.svg 14 | :target: https://github.com/saadmk11/changelog-ci 15 | 16 | 17 | What is django-newsfeed? 18 | ======================== 19 | 20 | ``django-newsfeed`` is a news curator and newsletter subscription package for django. 21 | It can be used to create a news curator website which sends newsletters to their subscribers 22 | also it can be used to add a news subscription section to your website. 23 | 24 | Features 25 | ======== 26 | 27 | * Create monthly, weekly or daily issues with ``draft`` issue support. 28 | * Create posts with different categories. 29 | * Archive and display all of the issues in your website. 30 | * Newsletter e-mail subscription (``ajax`` support) with e-mail verification. 31 | * Newsletter e-mail unsubscription (``ajax`` support). 32 | * Sending newsletters for each issue to all the subscribers. 33 | * Fully customizable templates. 34 | * Uses Django's internal tools for sending email. 35 | * Efficient mass mailing support. 36 | 37 | Requirements 38 | ============ 39 | 40 | * **Python**: 3.6, 3.7, 3.8, 3.9, 3.10 41 | * **Django**: 2.2, 3.0, 3.1, 3.2, 4.0 42 | 43 | Example Project 44 | =============== 45 | 46 | You can view the example project for this package `here`_. 47 | This is a news-curator and newsletter subscription application that only uses this package. 48 | It also uses ``celery``, ``celery-beat`` and ``redis`` to send email newsletters in the background. 49 | The styles in the example project uses ``bootstrap``. 50 | 51 | .. _here: https://github.com/saadmk11/test-django-newsfeed 52 | 53 | 54 | Documentation 55 | ============= 56 | 57 | Installation 58 | ============ 59 | 60 | Install ``django-newsfeed`` using pip: 61 | 62 | .. code-block:: sh 63 | 64 | pip install django-newsfeed 65 | 66 | 67 | Then add ``newsfeed`` to your ``INSTALLED_APPS``: 68 | 69 | .. code-block:: python 70 | 71 | INSTALLED_APPS = [ 72 | ... 73 | 'newsfeed', 74 | ] 75 | 76 | Then add ``newsfeed`` to your projects ``urls.py``: 77 | 78 | .. code-block:: python 79 | 80 | urlpatterns = [ 81 | ... 82 | path('newsfeed/', include('newsfeed.urls', namespace='newsfeed')), 83 | ... 84 | ] 85 | 86 | Usage 87 | ===== 88 | **Available views** 89 | 90 | We provide these views out of the box: 91 | 92 | * **latest_issue:** ``newsfeed/`` 93 | * **issue_list:** ``newsfeed/issues/`` 94 | * **issue_detail:** ``newsfeed/issues//`` 95 | * **newsletter_subscribe:** ``newsfeed/subscribe/`` 96 | * **newsletter_subscription_confirm:** ``newsfeed/subscribe/confirm//`` 97 | * **newsletter_unsubscribe:** ``newsfeed/unsubscribe/`` 98 | 99 | **Templates** 100 | 101 | The basic templates are provided for all the views and emails with ``django-newsfeed``. 102 | You can override the templates to add your own design. 103 | 104 | Just add ``newsfeed`` directory inside your templates directory 105 | add templates with the same name as the showed tree below. 106 | more on template overriding on the `django docs`_ 107 | 108 | .. _django docs: https://docs.djangoproject.com/en/3.1/howto/overriding-templates/ 109 | 110 | Template Tree for ``django-newfeed``: 111 | 112 | .. code-block:: 113 | 114 | templates 115 | └── newsfeed 116 | ├── base.html 117 | ├── email 118 | │ ├── email_verification.html 119 | │ ├── email_verification_subject.txt 120 | │ ├── email_verification.txt 121 | │ └── newsletter_email.html 122 | ├── issue_detail.html 123 | ├── issue_list.html 124 | ├── issue_posts.html 125 | ├── latest_issue.html 126 | ├── messages.html 127 | ├── newsletter_subscribe.html 128 | ├── newsletter_subscription_confirm.html 129 | ├── newsletter_unsubscribe.html 130 | └── subscription_form.html 131 | 132 | **Subscription confirmation Email** 133 | 134 | We send subscription confirmation email to the new subscribers. 135 | you can override these template to change the styles: 136 | 137 | .. code-block:: 138 | 139 | templates 140 | └── newsfeed 141 | ├── email 142 | │ ├── email_verification.html 143 | │ ├── email_verification_subject.txt 144 | │ ├── email_verification.txt 145 | 146 | 147 | **Admin Actions** 148 | 149 | These actions are available from the admin panel: 150 | 151 | * **publish issues:** The selected issues will be published. 152 | * **mark issues as draft:** The selected issues will be marked as draft. 153 | * **hide posts:** The selected posts will be hidden from the issues. 154 | * **make posts visible:** The selected posts will visible on the issues. 155 | * **send newsletters:** Sends selected newsletters to all the subscribers. 156 | (``send newsletters`` action should be overridden to use a background task queue. 157 | See the `example project`_ to see an example using celery) 158 | 159 | **Send Email Newsletter** 160 | 161 | We provide a class to handle sending email newsletters to the subscribers. 162 | We do not provide any background task queue by default. But it is fairly easy to set it up. 163 | 164 | See the `example project`_ to see an example using ``celery`` and ``celery-beat``. 165 | 166 | You can override this template to change the style of the newsletter: 167 | 168 | .. code-block:: 169 | 170 | templates 171 | └── newsfeed 172 | ├── email 173 | │ └── newsletter_email.html 174 | 175 | 176 | .. _example project: https://github.com/saadmk11/test-django-newsfeed 177 | 178 | Settings Configuration 179 | ====================== 180 | 181 | The below settings are available for ``django-newsfeed``. 182 | Add these settings to your projects ``settings.py`` as required. 183 | 184 | ``NEWSFEED_SITE_BASE_URL`` 185 | -------------------------- 186 | 187 | * default: ``http://127.0.0.1:8000`` (your sites URL) 188 | * required: True 189 | 190 | This settings is required. You need to add your websites URL here in production. 191 | This is used to generate confirmation URL and unsubscribe URL for the emails. 192 | 193 | ``NEWSFEED_EMAIL_CONFIRMATION_EXPIRE_DAYS`` 194 | ------------------------------------------- 195 | 196 | * default: 3 (after number of days confirmation link expires) 197 | * required: False 198 | 199 | This settings tells ``django-newsfeed`` to expire the confirmation link in specified number of days. 200 | 201 | ``NEWSFEED_EMAIL_BATCH_SIZE`` 202 | ----------------------------- 203 | 204 | * default: 0 (number of emails per batch) 205 | * required: False 206 | 207 | This settings is helpful when there are a lot of subscribers. 208 | This settings tells ``django-newsfeed`` to send the specified number of emails per batch. 209 | if its zero (``0``) then all of the emails will be sent together. 210 | 211 | ``NEWSFEED_EMAIL_BATCH_WAIT`` 212 | ----------------------------- 213 | 214 | * default: 0 (in seconds) 215 | * required: False 216 | 217 | This settings tells ``django-newsfeed`` how long it should wait between 218 | each batch of newsletter email sent. 219 | 220 | ``NEWSFEED_SUBSCRIPTION_REDIRECT_URL`` 221 | -------------------------------------- 222 | 223 | * default: ``/newsfeed/issues/`` 224 | * required: False 225 | 226 | This is only required if you are not using ``ajax`` request on the subscription form. 227 | The ``JavaScript`` code for ``ajax`` is included with ``django-newsfeed`` and on by default. 228 | 229 | ``NEWSFEED_UNSUBSCRIPTION_REDIRECT_URL`` 230 | ---------------------------------------- 231 | 232 | * default: ``/newsfeed/issues/`` 233 | * required: False 234 | 235 | This is only required if you are not using ``ajax`` request on the unsubscription form. 236 | The ``JavaScript`` code for ``ajax`` is included with ``django-newsfeed`` and on by default. 237 | 238 | 239 | Signals 240 | ======= 241 | 242 | ``django-newsfeed`` sends several signals for various actions. 243 | You can add ``receivers`` to listen to the signals and 244 | add your own functionality after each signal is sent. 245 | To learn more about ``signals`` refer to django `Signals Documentation`_. 246 | 247 | .. _Signals Documentation: https://docs.djangoproject.com/en/3.1/topics/signals/ 248 | 249 | 250 | Subscriber Signals 251 | ------------------ 252 | 253 | 254 | * ``newsfeed.signals.email_verification_sent(instance)`` 255 | Sent after email verification is sent, with ``Subscriber`` instance. 256 | 257 | * ``newsfeed.signals.subscribed(instance)`` 258 | Sent after subscription is confirmed, with ``Subscriber`` instance. 259 | 260 | * ``newsfeed.signals.unsubscribed(instance)`` 261 | Sent after unsubscription is successful, with ``Subscriber`` instance. 262 | 263 | 264 | Contribute 265 | ========== 266 | 267 | See `CONTRIBUTING.rst `_ 268 | for information about contributing to ``django-newsfeed``. 269 | 270 | 271 | License 272 | ======= 273 | 274 | The code in this project is released under the `GNU General Public License v3.0`_ 275 | 276 | .. _GNU General Public License v3.0: https://github.com/saadmk11/django-newsfeed/blob/master/LICENSE 277 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | 6 | if __name__ == "__main__": 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /newsfeed/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saadmk11/django-newsfeed/7342f096a43bd651b9ba88381f7044a8273e974f/newsfeed/__init__.py -------------------------------------------------------------------------------- /newsfeed/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin, messages 2 | 3 | from .models import Issue, Newsletter, Post, PostCategory, Subscriber 4 | from newsfeed.utils.send_newsletters import send_email_newsletter 5 | 6 | 7 | class PostInline(admin.TabularInline): 8 | model = Post 9 | 10 | 11 | class IssueAdmin(admin.ModelAdmin): 12 | view_on_site = True 13 | date_hierarchy = 'publish_date' 14 | list_display = ( 15 | 'issue_number', 'title', 16 | 'publish_date', 'issue_type', 17 | 'is_draft', 'is_published', 18 | ) 19 | list_filter = ('is_draft', 'issue_type',) 20 | search_fields = ( 21 | 'title', 'short_description', 22 | 'posts__title', 'posts__short_description', 23 | ) 24 | readonly_fields = ('created_at', 'updated_at',) 25 | sortable_by = ('issue_number', 'publish_date',) 26 | inlines = (PostInline,) 27 | 28 | actions = ('publish_issues', 'make_draft',) 29 | 30 | def publish_issues(self, request, queryset): 31 | updated = queryset.update(is_draft=False) 32 | messages.add_message( 33 | request, 34 | messages.SUCCESS, 35 | f'Successfully published {updated} issue(s)', 36 | ) 37 | 38 | publish_issues.short_description = 'Publish issues now' 39 | 40 | def make_draft(self, request, queryset): 41 | updated = queryset.update(is_draft=True) 42 | messages.add_message( 43 | request, 44 | messages.SUCCESS, 45 | f'Successfully marked {updated} issue(s) as draft', 46 | ) 47 | 48 | make_draft.short_description = 'Mark issues as draft' 49 | 50 | 51 | class NewsletterAdmin(admin.ModelAdmin): 52 | list_select_related = ('issue',) 53 | date_hierarchy = 'schedule' 54 | list_display = ( 55 | 'subject', 'issue', 'is_sent', 'schedule', 56 | ) 57 | list_filter = ('is_sent',) 58 | search_fields = ( 59 | 'subject', 'issue__short_description', 60 | 'issue__title', 61 | ) 62 | readonly_fields = ('created_at', 'updated_at',) 63 | sortable_by = ('schedule',) 64 | autocomplete_fields = ('issue',) 65 | 66 | actions = ('send_newsletters',) 67 | 68 | def send_newsletters(self, request, queryset): 69 | # This should always be overridden to use a task 70 | send_email_newsletter(newsletters=queryset, respect_schedule=False) 71 | messages.add_message( 72 | request, 73 | messages.SUCCESS, 74 | 'Sending selected newsletters(s) to the subscribers', 75 | ) 76 | 77 | send_newsletters.short_description = 'Send newsletters' 78 | 79 | 80 | class PostAdmin(admin.ModelAdmin): 81 | list_select_related = ('issue', 'category',) 82 | list_display = ( 83 | 'title', 'category', 84 | 'issue', 'order', 'is_visible', 85 | ) 86 | list_filter = ('is_visible', 'category',) 87 | search_fields = ( 88 | 'title', 'short_description', 89 | 'issue__title', 'issue__short_description', 90 | 'category__title', 91 | ) 92 | readonly_fields = ('created_at', 'updated_at',) 93 | autocomplete_fields = ('issue', 'category',) 94 | 95 | actions = ('hide_post', 'make_post_visible',) 96 | 97 | def hide_post(self, request, queryset): 98 | updated = queryset.update(is_visible=False) 99 | messages.add_message( 100 | request, 101 | messages.SUCCESS, 102 | f'Successfully marked {updated} post(s) as hidden', 103 | ) 104 | 105 | hide_post.short_description = 'Hide posts from issue' 106 | 107 | def make_post_visible(self, request, queryset): 108 | updated = queryset.update(is_visible=True) 109 | messages.add_message( 110 | request, 111 | messages.SUCCESS, 112 | f'Successfully made {updated} post(s) visible', 113 | ) 114 | 115 | make_post_visible.short_description = 'Make posts visible' 116 | 117 | 118 | class PostCategoryAdmin(admin.ModelAdmin): 119 | list_display = ('name', 'order',) 120 | search_fields = ('name',) 121 | 122 | 123 | class SubscriberAdmin(admin.ModelAdmin): 124 | list_display = ( 125 | 'email_address', 'subscribed', 126 | 'verified', 'token_expired', 127 | 'verification_sent_date', 128 | ) 129 | list_filter = ( 130 | 'subscribed', 'verified', 131 | 'verification_sent_date', 132 | ) 133 | search_fields = ('email_address',) 134 | readonly_fields = ('created_at',) 135 | exclude = ('token',) 136 | 137 | 138 | admin.site.register(Issue, IssueAdmin) 139 | admin.site.register(Newsletter, NewsletterAdmin) 140 | admin.site.register(Post, PostAdmin) 141 | admin.site.register(PostCategory, PostCategoryAdmin) 142 | admin.site.register(Subscriber, SubscriberAdmin) 143 | -------------------------------------------------------------------------------- /newsfeed/app_settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.urls import reverse_lazy 3 | 4 | 5 | NEWSFEED_EMAIL_BATCH_WAIT = getattr( 6 | settings, 'NEWSFEED_EMAIL_BATCH_WAIT', 0 7 | ) 8 | NEWSFEED_EMAIL_BATCH_SIZE = getattr( 9 | settings, 'NEWSFEED_EMAIL_BATCH_SIZE', 0 10 | ) 11 | NEWSFEED_EMAIL_CONFIRMATION_EXPIRE_DAYS = getattr( 12 | settings, 'NEWSFEED_EMAIL_CONFIRMATION_EXPIRE_DAYS', 3 13 | ) 14 | NEWSFEED_SITE_BASE_URL = getattr( 15 | settings, 'NEWSFEED_SITE_BASE_URL', 'http://127.0.0.1:8000' 16 | ) 17 | NEWSFEED_SUBSCRIPTION_REDIRECT_URL = getattr( 18 | settings, 'NEWSFEED_SUBSCRIPTION_REDIRECT_URL', 19 | reverse_lazy('newsfeed:issue_list') 20 | ) 21 | NEWSFEED_UNSUBSCRIPTION_REDIRECT_URL = getattr( 22 | settings, 'NEWSFEED_UNSUBSCRIPTION_REDIRECT_URL', 23 | reverse_lazy('newsfeed:issue_list') 24 | ) 25 | -------------------------------------------------------------------------------- /newsfeed/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class NewsfeedConfig(AppConfig): 5 | name = 'newsfeed' 6 | -------------------------------------------------------------------------------- /newsfeed/constants.py: -------------------------------------------------------------------------------- 1 | DAILY_ISSUE = 1 2 | WEEKLY_ISSUE = 2 3 | MONTHLY_ISSUE = 3 4 | 5 | 6 | ISSUE_TYPE_CHOICES = ( 7 | (DAILY_ISSUE, 'Daily Issue'), 8 | (WEEKLY_ISSUE, 'Weekly Issue'), 9 | (MONTHLY_ISSUE, 'Monthly Issue'), 10 | ) 11 | -------------------------------------------------------------------------------- /newsfeed/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | 4 | class SubscriberEmailForm(forms.Form): 5 | email_address = forms.EmailField() 6 | -------------------------------------------------------------------------------- /newsfeed/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1 on 2020-08-25 20:04 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import uuid 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Issue', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('title', models.CharField(max_length=128)), 21 | ('issue_number', models.PositiveIntegerField(help_text='Used as a slug for each issue', unique=True)), 22 | ('publish_date', models.DateTimeField()), 23 | ('issue_type', models.PositiveSmallIntegerField(choices=[(1, 'Daily Issue'), (2, 'Weekly Issue'), (2, 'Monthly Issue')], default=2)), 24 | ('short_description', models.TextField(blank=True, null=True)), 25 | ('is_draft', models.BooleanField(default=False)), 26 | ('created_at', models.DateTimeField(auto_now_add=True)), 27 | ('updated_at', models.DateTimeField(auto_now=True)), 28 | ], 29 | options={ 30 | 'ordering': ['-issue_number', '-publish_date'], 31 | }, 32 | ), 33 | migrations.CreateModel( 34 | name='PostCategory', 35 | fields=[ 36 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 37 | ('name', models.CharField(max_length=255)), 38 | ('order', models.PositiveIntegerField(default=0)), 39 | ], 40 | options={ 41 | 'verbose_name_plural': 'Post categories', 42 | 'ordering': ['order'], 43 | }, 44 | ), 45 | migrations.CreateModel( 46 | name='Subscriber', 47 | fields=[ 48 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 49 | ('email_address', models.EmailField(max_length=254, unique=True)), 50 | ('token', models.CharField(default=uuid.uuid4, max_length=128, unique=True)), 51 | ('verified', models.BooleanField(default=False)), 52 | ('subscribed', models.BooleanField(default=False)), 53 | ('verification_sent_date', models.DateTimeField(blank=True, null=True)), 54 | ('created_at', models.DateTimeField(auto_now_add=True)), 55 | ], 56 | ), 57 | migrations.CreateModel( 58 | name='Post', 59 | fields=[ 60 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 61 | ('title', models.CharField(max_length=255)), 62 | ('source_url', models.URLField()), 63 | ('is_visible', models.BooleanField(default=True)), 64 | ('short_description', models.TextField()), 65 | ('order', models.PositiveIntegerField(default=0)), 66 | ('created_at', models.DateTimeField(auto_now_add=True)), 67 | ('updated_at', models.DateTimeField(auto_now=True)), 68 | ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='posts', to='newsfeed.postcategory')), 69 | ('issue', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='posts', to='newsfeed.issue')), 70 | ], 71 | options={ 72 | 'ordering': ['order', '-created_at'], 73 | }, 74 | ), 75 | migrations.CreateModel( 76 | name='Newsletter', 77 | fields=[ 78 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 79 | ('subject', models.CharField(max_length=128)), 80 | ('schedule', models.DateTimeField(blank=True, null=True)), 81 | ('is_sent', models.BooleanField(default=False)), 82 | ('sent_at', models.DateTimeField(blank=True, null=True)), 83 | ('created_at', models.DateTimeField(auto_now_add=True)), 84 | ('updated_at', models.DateTimeField(auto_now=True)), 85 | ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='newsletters', to='newsfeed.issue')), 86 | ], 87 | ), 88 | ] 89 | -------------------------------------------------------------------------------- /newsfeed/migrations/0002_alter_issue_issue_type.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.10 on 2022-07-11 13:24 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('newsfeed', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='issue', 15 | name='issue_type', 16 | field=models.PositiveSmallIntegerField(choices=[(1, 'Daily Issue'), (2, 'Weekly Issue'), (3, 'Monthly Issue')], default=2), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /newsfeed/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saadmk11/django-newsfeed/7342f096a43bd651b9ba88381f7044a8273e974f/newsfeed/migrations/__init__.py -------------------------------------------------------------------------------- /newsfeed/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.db import models 4 | from django.urls import reverse 5 | from django.utils import timezone 6 | 7 | from . import signals 8 | from .app_settings import NEWSFEED_EMAIL_CONFIRMATION_EXPIRE_DAYS 9 | from .constants import ISSUE_TYPE_CHOICES, WEEKLY_ISSUE 10 | from .querysets import IssueQuerySet, SubscriberQuerySet, PostQuerySet 11 | from .utils.send_verification import send_subscription_verification_email 12 | 13 | 14 | class Issue(models.Model): 15 | title = models.CharField(max_length=128) 16 | issue_number = models.PositiveIntegerField( 17 | unique=True, help_text='Used as a slug for each issue' 18 | ) 19 | publish_date = models.DateTimeField() 20 | issue_type = models.PositiveSmallIntegerField( 21 | choices=ISSUE_TYPE_CHOICES, 22 | default=WEEKLY_ISSUE 23 | ) 24 | short_description = models.TextField(blank=True, null=True) 25 | is_draft = models.BooleanField(default=False) 26 | 27 | created_at = models.DateTimeField(auto_now_add=True) 28 | updated_at = models.DateTimeField(auto_now=True) 29 | 30 | objects = IssueQuerySet.as_manager() 31 | 32 | class Meta: 33 | ordering = ['-issue_number', '-publish_date'] 34 | 35 | def __str__(self): 36 | return self.title 37 | 38 | @property 39 | def is_published(self): 40 | return not self.is_draft and self.publish_date <= timezone.now() 41 | 42 | def get_absolute_url(self): 43 | return reverse( 44 | 'newsfeed:issue_detail', 45 | kwargs={'issue_number': self.issue_number} 46 | ) 47 | 48 | 49 | class PostCategory(models.Model): 50 | name = models.CharField(max_length=255) 51 | order = models.PositiveIntegerField(default=0) 52 | 53 | class Meta: 54 | verbose_name_plural = 'Post categories' 55 | ordering = ['order'] 56 | 57 | def __str__(self): 58 | return self.name 59 | 60 | 61 | class Post(models.Model): 62 | issue = models.ForeignKey( 63 | Issue, 64 | on_delete=models.SET_NULL, 65 | related_name='posts', 66 | blank=True, 67 | null=True 68 | ) 69 | category = models.ForeignKey( 70 | PostCategory, 71 | on_delete=models.SET_NULL, 72 | related_name='posts', 73 | blank=True, 74 | null=True 75 | ) 76 | title = models.CharField(max_length=255) 77 | source_url = models.URLField() 78 | is_visible = models.BooleanField(default=True) 79 | short_description = models.TextField() 80 | order = models.PositiveIntegerField(default=0) 81 | 82 | created_at = models.DateTimeField(auto_now_add=True) 83 | updated_at = models.DateTimeField(auto_now=True) 84 | 85 | objects = PostQuerySet.as_manager() 86 | 87 | class Meta: 88 | ordering = ['order', '-created_at'] 89 | 90 | def __str__(self): 91 | return self.title 92 | 93 | 94 | class Newsletter(models.Model): 95 | issue = models.ForeignKey( 96 | Issue, 97 | on_delete=models.CASCADE, 98 | related_name='newsletters' 99 | ) 100 | subject = models.CharField(max_length=128) 101 | schedule = models.DateTimeField(blank=True, null=True) 102 | is_sent = models.BooleanField(default=False) 103 | sent_at = models.DateTimeField(blank=True, null=True) 104 | 105 | created_at = models.DateTimeField(auto_now_add=True) 106 | updated_at = models.DateTimeField(auto_now=True) 107 | 108 | def __str__(self): 109 | return self.subject 110 | 111 | 112 | class Subscriber(models.Model): 113 | email_address = models.EmailField(unique=True) 114 | token = models.CharField(max_length=128, unique=True, default=uuid.uuid4) 115 | verified = models.BooleanField(default=False) 116 | subscribed = models.BooleanField(default=False) 117 | verification_sent_date = models.DateTimeField(blank=True, null=True) 118 | 119 | created_at = models.DateTimeField(auto_now_add=True) 120 | 121 | objects = SubscriberQuerySet.as_manager() 122 | 123 | def __str__(self): 124 | return self.email_address 125 | 126 | def token_expired(self): 127 | if not self.verification_sent_date: 128 | return True 129 | 130 | expiration_date = ( 131 | self.verification_sent_date + timezone.timedelta( 132 | days=NEWSFEED_EMAIL_CONFIRMATION_EXPIRE_DAYS 133 | ) 134 | ) 135 | return expiration_date <= timezone.now() 136 | 137 | def reset_token(self): 138 | unique_token = str(uuid.uuid4()) 139 | 140 | while self.__class__.objects.filter(token=unique_token).exists(): 141 | unique_token = str(uuid.uuid4()) 142 | 143 | self.token = unique_token 144 | self.save() 145 | 146 | def subscribe(self): 147 | if not self.token_expired(): 148 | self.verified = True 149 | self.subscribed = True 150 | self.save() 151 | 152 | signals.subscribed.send( 153 | sender=self.__class__, instance=self 154 | ) 155 | 156 | return True 157 | 158 | def unsubscribe(self): 159 | if self.subscribed: 160 | self.subscribed = False 161 | self.verified = False 162 | self.save() 163 | 164 | signals.unsubscribed.send( 165 | sender=Subscriber, instance=self 166 | ) 167 | 168 | return True 169 | 170 | def send_verification_email(self, created): 171 | minutes_before = timezone.now() - timezone.timedelta(minutes=5) 172 | sent_date = self.verification_sent_date 173 | 174 | # Only send email again if the last sent date is five minutes earlier 175 | if sent_date and sent_date >= minutes_before: 176 | return 177 | 178 | if not created: 179 | self.reset_token() 180 | 181 | self.verification_sent_date = timezone.now() 182 | self.save() 183 | 184 | send_subscription_verification_email( 185 | self.get_verification_url(), self.email_address 186 | ) 187 | signals.email_verification_sent.send( 188 | sender=self.__class__, instance=self 189 | ) 190 | 191 | def get_verification_url(self): 192 | return reverse( 193 | 'newsfeed:newsletter_subscription_confirm', 194 | kwargs={'token': self.token} 195 | ) 196 | -------------------------------------------------------------------------------- /newsfeed/querysets.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils import timezone 3 | 4 | 5 | class IssueQuerySet(models.QuerySet): 6 | 7 | use_for_related_fields = True 8 | 9 | def released(self): 10 | return self.filter( 11 | is_draft=False, 12 | publish_date__lte=timezone.now() 13 | ) 14 | 15 | 16 | class SubscriberQuerySet(models.QuerySet): 17 | 18 | use_for_related_fields = True 19 | 20 | def subscribed(self): 21 | return self.filter(verified=True, subscribed=True) 22 | 23 | 24 | class PostQuerySet(models.QuerySet): 25 | 26 | use_for_related_fields = True 27 | 28 | def visible(self): 29 | return self.filter(is_visible=True) 30 | -------------------------------------------------------------------------------- /newsfeed/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | # Sent after email verification is sent, with Subscriber instance 4 | email_verification_sent = Signal() 5 | 6 | # Sent after subscription confirmed, with Subscriber instance 7 | subscribed = Signal() 8 | 9 | # Sent after unsubscription is successful, with Subscriber instance 10 | unsubscribed = Signal() 11 | -------------------------------------------------------------------------------- /newsfeed/static/newsfeed/js/newsletter_subscription.js: -------------------------------------------------------------------------------- 1 | window.addEventListener("load", function () { 2 | let forms = document.querySelectorAll(".subscriptionForm"); 3 | 4 | forms.forEach(function (form) { 5 | form.addEventListener("submit", function (event) { 6 | event.preventDefault(); 7 | let data = new FormData(form); 8 | let messageElement = form.querySelector(".subscriptionMessage"); 9 | 10 | fetch(form.action, { 11 | method: "post", 12 | mode: "same-origin", 13 | body: data, 14 | credentials: "same-origin", 15 | headers: { 16 | Accept: "application/json", 17 | "X-Requested-With": "XMLHttpRequest", 18 | }, 19 | }) 20 | .then(function (response) { 21 | response.json().then(function (data) { 22 | if (response.status !== 200) { 23 | messageElement.innerText = data.email_address[0].message; 24 | messageElement.classList.remove("success"); 25 | messageElement.classList.add("error"); 26 | } else { 27 | messageElement.innerText = data.message; 28 | form.reset(); 29 | 30 | if (data.success) { 31 | messageElement.classList.remove("error"); 32 | messageElement.classList.add("success"); 33 | } else { 34 | messageElement.classList.remove("success"); 35 | messageElement.classList.add("error"); 36 | } 37 | } 38 | }); 39 | }) 40 | .catch(function (error) { 41 | console.log(error); 42 | messageElement.innerText = "An error occurred, please try again"; 43 | messageElement.classList.remove("success"); 44 | messageElement.classList.add("error"); 45 | }); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /newsfeed/templates/newsfeed/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block head_title %}{% endblock %} 6 | 7 | {% block head_extra %} 8 | {% endblock %} 9 | 10 | 11 | {% block body %} 12 | {% include 'newsfeed/messages.html' %} 13 | {% block content %} 14 | 15 | {% endblock %} 16 | {% endblock %} 17 | 18 | {% block footer_extra %} 19 | {% endblock %} 20 | 21 | 22 | -------------------------------------------------------------------------------- /newsfeed/templates/newsfeed/email/email_verification.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Verify Your E-mail Address 5 | 6 | 7 |

Please click on the link bellow to confirm subscription to {{ site_url }}

8 | Confirm Subscription 9 | Thank You 10 | 11 | 12 | -------------------------------------------------------------------------------- /newsfeed/templates/newsfeed/email/email_verification.txt: -------------------------------------------------------------------------------- 1 | Please click on the link bellow to confirm subscription to {{ site_url }} 2 | 3 | {{ site_url }}{{ verification_url }} 4 | 5 | Thank You 6 | -------------------------------------------------------------------------------- /newsfeed/templates/newsfeed/email/email_verification_subject.txt: -------------------------------------------------------------------------------- 1 | Please Confirm Your Subscription 2 | -------------------------------------------------------------------------------- /newsfeed/templates/newsfeed/email/newsletter_email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Issue #{{ issue.issue_number }} 5 | 6 | 7 |

Issue #{{ issue.issue_number }}

8 |

{{ issue.title }}

9 |

{{ issue.short_description }}

10 |

publish date: {{ issue.publish_date|date:"D d M Y" }}

11 | 12 | {% include 'newsfeed/issue_posts.html' with posts=post_list %} 13 | 14 | Unsubscribe 15 | 16 | 17 | -------------------------------------------------------------------------------- /newsfeed/templates/newsfeed/issue_detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'newsfeed/base.html' %} 2 | 3 | {% block head_title %}Issue #{{ issue.issue_number }}{% endblock %} 4 | 5 | {% block content %} 6 |

Issue #{{ issue.issue_number }}

7 | 8 |

{{ issue.title }}

9 |

{{ issue.short_description }}

10 | publish date: {{ issue.publish_date|date:"D d M Y" }} 11 | 12 | {% include 'newsfeed/issue_posts.html' with posts=object_list %} 13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /newsfeed/templates/newsfeed/issue_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'newsfeed/base.html' %} 2 | 3 | {% block head_title %}Issue Archive{% endblock %} 4 | 5 | {% block content %} 6 |
7 | {% for issue in object_list %} 8 |

{{ issue.title }}

9 |

{{ issue.short_description }}

10 | release date: {{ issue.publish_date|date:"D d M Y" }} 11 | {% endfor %} 12 |
13 | 14 | 29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /newsfeed/templates/newsfeed/issue_posts.html: -------------------------------------------------------------------------------- 1 | {% regroup posts|dictsort:"category.order" by category as category_list %} 2 | 3 |
    4 | {% for category, posts in category_list %} 5 |
  • {{ category }}

    6 |
      7 | {% for post in posts %} 8 |
    • 9 |
      {{ post.title }}
      10 |

      {{ post.short_description }}

      11 |

      {{ post.category.name }}

      12 |
    • 13 | {% endfor %} 14 |
    15 |
  • 16 | {% endfor %} 17 |
18 | -------------------------------------------------------------------------------- /newsfeed/templates/newsfeed/latest_issue.html: -------------------------------------------------------------------------------- 1 | {% extends 'newsfeed/base.html' %} 2 | {% load static %} 3 | 4 | {% block head_title %}Issue #{{ latest_issue.issue_number }}{% endblock %} 5 | 6 | {% block content %} 7 |

Issue #{{ latest_issue.issue_number }}

8 | 9 |

{{ latest_issue.title }}

10 |

{{ latest_issue.short_description }}

11 | publish date: {{ latest_issue.publish_date|date:"D d M Y" }} 12 | 13 | {% include 'newsfeed/issue_posts.html' with posts=latest_issue.posts.all %} 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /newsfeed/templates/newsfeed/messages.html: -------------------------------------------------------------------------------- 1 | {% if messages %} 2 |
3 |
    4 | {% for message in messages %} 5 |
  • {{message}}
  • 6 | {% endfor %} 7 |
8 |
9 | {% endif %} 10 | -------------------------------------------------------------------------------- /newsfeed/templates/newsfeed/newsletter_subscribe.html: -------------------------------------------------------------------------------- 1 | {% extends 'newsfeed/base.html' %} 2 | {% load static %} 3 | 4 | {% block head_title %}Subscribe{% endblock %} 5 | 6 | {% block content %} 7 | {% include 'newsfeed/subscription_form.html' with form=form form_type='subscribe' %} 8 | {% endblock %} 9 | 10 | {% block footer_extra %} 11 | 12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /newsfeed/templates/newsfeed/newsletter_subscription_confirm.html: -------------------------------------------------------------------------------- 1 | {% extends 'newsfeed/base.html' %} 2 | {% load static %} 3 | 4 | {% block head_title %}Confirm Subscription{% endblock %} 5 | 6 | {% block content %} 7 | {% if subscribed %} 8 | Subscription confirmed! 9 | {% else %} 10 | Subscription confirmation link has expired or is invalid. 11 | Please click on the button below to receive an e-mail with a new confirmation link. 12 |
13 | {% csrf_token %} 14 | 15 | 16 |
17 |
18 | {% endif %} 19 | {% endblock %} 20 | 21 | {% block head_extra %} 22 | 23 | {% endblock %} -------------------------------------------------------------------------------- /newsfeed/templates/newsfeed/newsletter_unsubscribe.html: -------------------------------------------------------------------------------- 1 | {% extends 'newsfeed/base.html' %} 2 | {% load static %} 3 | 4 | {% block head_title %}Unsubscribe{% endblock %} 5 | 6 | {% block content %} 7 | {% include 'newsfeed/subscription_form.html' with form=form form_type='unsubscribe' %} 8 | {% endblock %} 9 | 10 | {% block footer_extra %} 11 | 12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /newsfeed/templates/newsfeed/subscription_form.html: -------------------------------------------------------------------------------- 1 |
2 | {% csrf_token %} 3 | {{ form }} 4 | 5 |
6 |
7 | -------------------------------------------------------------------------------- /newsfeed/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import ( 4 | IssueDetailView, 5 | IssueListView, 6 | LatestIssueView, 7 | NewsletterSubscribeView, 8 | NewsletterSubscriptionConfirmView, 9 | NewsletterUnsubscribeView, 10 | ) 11 | 12 | 13 | app_name = 'newsfeed' 14 | 15 | urlpatterns = [ 16 | path('', LatestIssueView.as_view(), name='latest_issue'), 17 | path('issues/', IssueListView.as_view(), name='issue_list'), 18 | path( 19 | 'issues//', 20 | IssueDetailView.as_view(), 21 | name='issue_detail' 22 | ), 23 | path( 24 | 'subscribe/', 25 | NewsletterSubscribeView.as_view(), 26 | name='newsletter_subscribe'), 27 | path( 28 | 'subscribe/confirm//', 29 | NewsletterSubscriptionConfirmView.as_view(), 30 | name='newsletter_subscription_confirm' 31 | ), 32 | path( 33 | 'unsubscribe/', 34 | NewsletterUnsubscribeView.as_view(), 35 | name='newsletter_unsubscribe' 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /newsfeed/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saadmk11/django-newsfeed/7342f096a43bd651b9ba88381f7044a8273e974f/newsfeed/utils/__init__.py -------------------------------------------------------------------------------- /newsfeed/utils/check_ajax.py: -------------------------------------------------------------------------------- 1 | def is_ajax(request): 2 | """Check if the request is ajax or not""" 3 | return any( 4 | [ 5 | request.META.get('HTTP_X_REQUESTED_WITH') == 'XMLHttpRequest', 6 | request.META.get('HTTP_ACCEPT') == 'application/json' 7 | ] 8 | ) 9 | -------------------------------------------------------------------------------- /newsfeed/utils/send_newsletters.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | 4 | from django.conf import settings 5 | from django.core.mail import EmailMessage, get_connection 6 | from django.template.loader import render_to_string 7 | from django.urls import reverse 8 | from django.utils import timezone 9 | 10 | from newsfeed.app_settings import ( 11 | NEWSFEED_EMAIL_BATCH_WAIT, 12 | NEWSFEED_EMAIL_BATCH_SIZE, 13 | NEWSFEED_SITE_BASE_URL, 14 | ) 15 | from newsfeed.models import Newsletter, Subscriber 16 | 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | class NewsletterEmailSender: 22 | """The main class that handles sending email newsletters""" 23 | 24 | def __init__(self, newsletters=None, respect_schedule=True): 25 | self.newsletters = self._get_newsletters( 26 | newsletters=newsletters, respect_schedule=respect_schedule 27 | ) 28 | # get subscriber email addresses 29 | self.subscriber_emails = Subscriber.objects.subscribed().values_list( 30 | 'email_address', flat=True 31 | ) 32 | # Size of each batch to be sent 33 | self.batch_size = NEWSFEED_EMAIL_BATCH_SIZE 34 | # list of newsletters that were sent 35 | self.sent_newsletters = [] 36 | # Waiting time after each batch (in seconds) 37 | self.per_batch_wait = NEWSFEED_EMAIL_BATCH_WAIT 38 | # connection to the server 39 | self.connection = get_connection() 40 | self.email_host_user = settings.EMAIL_HOST_USER 41 | 42 | @staticmethod 43 | def _get_newsletters(newsletters=None, respect_schedule=True): 44 | """ 45 | gets newsletters to be sent 46 | 47 | :param newsletters: Newsletter QuerySet 48 | :param respect_schedule: if ``True`` newsletters with future schedule 49 | will not be sent 50 | """ 51 | now = timezone.now() 52 | 53 | if newsletters is None: 54 | newsletters = Newsletter.objects.filter( 55 | is_sent=False, issue__is_draft=False, 56 | issue__publish_date__lte=now 57 | ) 58 | 59 | if respect_schedule: 60 | newsletters = newsletters.filter(schedule__lte=now) 61 | 62 | return newsletters.select_related('issue') 63 | 64 | @staticmethod 65 | def _render_newsletter(newsletter): 66 | """renders newsletter template and returns html and subject""" 67 | issue = newsletter.issue 68 | subject = newsletter.subject 69 | posts = issue.posts.visible().select_related('category') 70 | 71 | context = { 72 | 'issue': issue, 73 | 'post_list': posts, 74 | 'unsubscribe_url': reverse('newsfeed:newsletter_unsubscribe'), 75 | 'site_url': NEWSFEED_SITE_BASE_URL 76 | } 77 | 78 | html = render_to_string( 79 | 'newsfeed/email/newsletter_email.html', context 80 | ) 81 | 82 | rendered_newsletter = { 83 | 'subject': subject, 84 | 'html': html 85 | } 86 | 87 | return rendered_newsletter 88 | 89 | def _generate_email_message(self, to_email, rendered_newsletter): 90 | """ 91 | Generates email message for an email_address 92 | 93 | :param to_email: subscribers email address 94 | :param rendered_newsletter: rendered html of the newsletter with subject 95 | """ 96 | message = EmailMessage( 97 | subject=rendered_newsletter.get('subject'), 98 | body=rendered_newsletter.get('html'), 99 | from_email=self.email_host_user, to=[to_email], 100 | connection=self.connection 101 | ) 102 | message.content_subtype = "html" 103 | 104 | return message 105 | 106 | def _get_batch_email_messages(self, rendered_newsletter): 107 | """ 108 | Yields EmailMessage list in batches 109 | 110 | :param rendered_newsletter: newsletter with html and subject 111 | """ 112 | 113 | # if there is no subscriber then stop iteration 114 | if len(self.subscriber_emails) == 0: 115 | logger.info('No subscriber found.') 116 | return 117 | 118 | # if there is no batch size specified 119 | # by the user send all in one batch 120 | if not self.batch_size or self.batch_size <= 0: 121 | self.batch_size = len(self.subscriber_emails) 122 | 123 | logger.info( 124 | 'Batch size for sending emails is set to %s', 125 | self.batch_size 126 | ) 127 | 128 | for i in range(0, len(self.subscriber_emails), self.batch_size): 129 | emails = self.subscriber_emails[i:i + self.batch_size] 130 | 131 | yield map( 132 | lambda email: self._generate_email_message( 133 | email, rendered_newsletter 134 | ), emails 135 | ) 136 | 137 | def send_emails(self): 138 | """sends newsletter emails to subscribers""" 139 | for newsletter in self.newsletters: 140 | issue_number = newsletter.issue.issue_number 141 | # this is used to calculate how many emails were 142 | # sent for each newsletter 143 | sent_emails = 0 144 | 145 | rendered_newsletter = self._render_newsletter(newsletter) 146 | 147 | logger.info( 148 | 'Ready to send newsletter for ISSUE # %s', 149 | issue_number 150 | ) 151 | 152 | for email_messages in self._get_batch_email_messages( 153 | rendered_newsletter 154 | ): 155 | messages = list(email_messages) 156 | 157 | try: 158 | # send mass email with one connection open 159 | sent = self.connection.send_messages(messages) 160 | 161 | logger.info( 162 | 'Sent %s newsletters in one batch for ISSUE # %s', 163 | len(messages), issue_number 164 | ) 165 | 166 | sent_emails += sent 167 | except Exception as e: 168 | # create a new connection on error 169 | self.connection = get_connection() 170 | logger.error( 171 | 'An error occurred while sending ' 172 | 'newsletters for ISSUE # %s ' 173 | 'newsletter ID: %s ' 174 | 'EXCEPTION: %s', 175 | issue_number, newsletter.id, e 176 | ) 177 | finally: 178 | # Wait sometime before sending next batch 179 | # this is to prevent server overload 180 | logger.info( 181 | 'Waiting %s seconds before sending ' 182 | 'next batch of newsletter for ISSUE # %s', 183 | self.per_batch_wait, issue_number 184 | ) 185 | time.sleep(self.per_batch_wait) 186 | 187 | if sent_emails > 0: 188 | self.sent_newsletters.append(newsletter.id) 189 | 190 | logger.info( 191 | 'Successfully Sent %s email(s) for ISSUE # %s ', 192 | sent_emails, issue_number 193 | ) 194 | 195 | # Save newsletters to sent state 196 | Newsletter.objects.filter( 197 | id__in=self.sent_newsletters 198 | ).update(is_sent=True, sent_at=timezone.now()) 199 | 200 | logger.info( 201 | 'Newsletter sending process completed. ' 202 | 'Successfully sent newsletters with ID %s', self.sent_newsletters 203 | ) 204 | 205 | 206 | def send_email_newsletter(newsletters=None, respect_schedule=True): 207 | send_newsletter = NewsletterEmailSender( 208 | newsletters=newsletters, 209 | respect_schedule=respect_schedule 210 | ) 211 | send_newsletter.send_emails() 212 | -------------------------------------------------------------------------------- /newsfeed/utils/send_verification.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.mail import EmailMultiAlternatives 3 | from django.template.loader import render_to_string 4 | 5 | from newsfeed.app_settings import NEWSFEED_SITE_BASE_URL 6 | 7 | 8 | def send_subscription_verification_email(verification_url, to_email): 9 | """ 10 | Sends verification e-mail to subscribers 11 | 12 | :param verification_url: subscribers unique verification url 13 | :param to_email: subscribers email 14 | """ 15 | context = { 16 | 'site_url': NEWSFEED_SITE_BASE_URL, 17 | 'verification_url': verification_url 18 | } 19 | 20 | # Send context so that users can use context data in the subject 21 | subject = render_to_string( 22 | 'newsfeed/email/email_verification_subject.txt', 23 | context 24 | ).rstrip('\n') 25 | 26 | text_body = render_to_string( 27 | 'newsfeed/email/email_verification.txt', context 28 | ) 29 | html_body = render_to_string( 30 | 'newsfeed/email/email_verification.html', context 31 | ) 32 | 33 | message = EmailMultiAlternatives( 34 | subject, text_body, settings.EMAIL_HOST_USER, [to_email] 35 | ) 36 | 37 | message.attach_alternative(html_body, 'text/html') 38 | message.send() 39 | -------------------------------------------------------------------------------- /newsfeed/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib import messages 2 | from django.db.models import Prefetch 3 | from django.http import JsonResponse 4 | from django.views.generic import DetailView, FormView, ListView, TemplateView 5 | from django.views.generic.detail import SingleObjectMixin 6 | 7 | from .app_settings import ( 8 | NEWSFEED_SUBSCRIPTION_REDIRECT_URL, 9 | NEWSFEED_UNSUBSCRIPTION_REDIRECT_URL, 10 | ) 11 | from .forms import SubscriberEmailForm 12 | from .models import Issue, Post, Subscriber 13 | from .utils.check_ajax import is_ajax 14 | 15 | 16 | class IssueListView(ListView): 17 | model = Issue 18 | paginate_by = 15 19 | template_name = 'newsfeed/issue_list.html' 20 | 21 | def get_queryset(self): 22 | return super().get_queryset().released() 23 | 24 | 25 | class IssueDetailView(SingleObjectMixin, ListView): 26 | model = Post 27 | template_name = "newsfeed/issue_detail.html" 28 | slug_url_kwarg = 'issue_number' 29 | slug_field = 'issue_number' 30 | 31 | def get(self, request, *args, **kwargs): 32 | self.object = self.get_object( 33 | queryset=Issue.objects.released() 34 | ) 35 | return super().get(request, *args, **kwargs) 36 | 37 | def get_context_data(self, **kwargs): 38 | context = super().get_context_data(**kwargs) 39 | context['issue'] = self.object 40 | return context 41 | 42 | def get_queryset(self): 43 | return self.object.posts.visible().select_related('category') 44 | 45 | 46 | class LatestIssueView(TemplateView): 47 | model = Post 48 | template_name = "newsfeed/latest_issue.html" 49 | 50 | def get_context_data(self, **kwargs): 51 | prefetch_posts = Post.objects.visible().select_related('category') 52 | latest_issue = Issue.objects.prefetch_related( 53 | Prefetch('posts', queryset=prefetch_posts) 54 | ).first() 55 | 56 | context = super().get_context_data(**kwargs) 57 | context['latest_issue'] = latest_issue 58 | return context 59 | 60 | 61 | class SubscriptionAjaxResponseMixin(FormView): 62 | """Mixin to add Ajax support to the subscription form""" 63 | form_class = SubscriberEmailForm 64 | message = '' 65 | success = False 66 | 67 | def form_invalid(self, form): 68 | response = super().form_invalid(form) 69 | 70 | if is_ajax(self.request): 71 | return JsonResponse( 72 | form.errors.get_json_data(), 73 | status=400 74 | ) 75 | else: 76 | messages.error(self.request, self.message) 77 | return response 78 | 79 | def form_valid(self, form): 80 | response = super().form_valid(form) 81 | 82 | if is_ajax(self.request): 83 | data = { 84 | 'message': self.message, 85 | 'success': self.success 86 | } 87 | return JsonResponse(data, status=200) 88 | else: 89 | messages.success(self.request, self.message) 90 | return response 91 | 92 | 93 | class NewsletterSubscribeView(SubscriptionAjaxResponseMixin): 94 | template_name = "newsfeed/newsletter_subscribe.html" 95 | success_url = NEWSFEED_SUBSCRIPTION_REDIRECT_URL 96 | 97 | def form_valid(self, form): 98 | email_address = form.cleaned_data.get('email_address') 99 | 100 | subscriber, created = Subscriber.objects.get_or_create( 101 | email_address=email_address 102 | ) 103 | 104 | if not created and subscriber.subscribed: 105 | self.success = False 106 | self.message = ( 107 | 'You have already subscribed to the newsletter.' 108 | ) 109 | else: 110 | subscriber.send_verification_email(created) 111 | self.success = True 112 | self.message = ( 113 | 'Thank you for subscribing! ' 114 | 'Please check your e-mail inbox to confirm ' 115 | 'your subscription and start receiving newsletters.' 116 | ) 117 | 118 | return super().form_valid(form) 119 | 120 | 121 | class NewsletterUnsubscribeView(SubscriptionAjaxResponseMixin): 122 | template_name = "newsfeed/newsletter_unsubscribe.html" 123 | success_url = NEWSFEED_UNSUBSCRIPTION_REDIRECT_URL 124 | 125 | def form_valid(self, form): 126 | email_address = form.cleaned_data.get('email_address') 127 | 128 | subscriber = Subscriber.objects.filter( 129 | subscribed=True, 130 | email_address=email_address 131 | ).first() 132 | 133 | if subscriber: 134 | subscriber.unsubscribe() 135 | self.success = True 136 | self.message = ( 137 | 'You have successfully unsubscribed from the newsletter.' 138 | ) 139 | else: 140 | self.success = False 141 | self.message = ( 142 | 'Subscriber with this e-mail address does not exist.' 143 | ) 144 | 145 | return super().form_valid(form) 146 | 147 | 148 | class NewsletterSubscriptionConfirmView(DetailView): 149 | template_name = "newsfeed/newsletter_subscription_confirm.html" 150 | model = Subscriber 151 | slug_url_kwarg = 'token' 152 | slug_field = 'token' 153 | 154 | def get_queryset(self): 155 | return super().get_queryset().filter(verified=False) 156 | 157 | def get(self, request, *args, **kwargs): 158 | self.object = self.get_object() 159 | subscribed = self.object.subscribe() 160 | 161 | context = self.get_context_data( 162 | object=self.object, subscribed=subscribed 163 | ) 164 | return self.render_to_response(context) 165 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | Django==4.2.2 2 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | codecov>=2.0.0 2 | coverage==5.5 3 | flake8>=2.1.0 4 | model-bakery==1.10.1 5 | tox>=1.7.0 6 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | 6 | import django 7 | from django.conf import settings 8 | from django.test.utils import get_runner 9 | 10 | 11 | def run_tests(*test_args): 12 | if not test_args: 13 | test_args = ['tests'] 14 | 15 | os.environ['DJANGO_SETTINGS_MODULE'] = 'test_project.settings' 16 | django.setup() 17 | TestRunner = get_runner(settings) 18 | test_runner = TestRunner() 19 | failures = test_runner.run_tests(test_args) 20 | sys.exit(bool(failures)) 21 | 22 | 23 | if __name__ == '__main__': 24 | run_tests(*sys.argv[1:]) 25 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | 4 | [flake8] 5 | ignore = D203 6 | exclude = 7 | newsfeed/migrations, 8 | .git, 9 | .tox, 10 | dist 11 | max-line-length = 80 12 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup 3 | 4 | readme = open('README.rst').read() 5 | 6 | setup( 7 | name='django-newsfeed', 8 | version='0.8.8', 9 | description="""A news curator and newsletter subscription package for django""", 10 | long_description=readme, 11 | long_description_content_type='text/markdown', 12 | author='Maksudul Haque', 13 | author_email='saad.mk112@gmail.com', 14 | url='https://github.com/saadmk11/django-newsfeed', 15 | packages=[ 16 | 'newsfeed', 17 | ], 18 | include_package_data=True, 19 | python_requires='>=3.6', 20 | install_requires=[ 21 | 'Django >= 2.2', 22 | ], 23 | test_suite="runtests.runtests", 24 | license="GNU Public License", 25 | zip_safe=False, 26 | keywords='django-newsfeed news curator newsletter subscription', 27 | classifiers=[ 28 | 'Development Status :: 4 - Beta', 29 | 'Framework :: Django', 30 | 'Intended Audience :: Developers', 31 | 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 32 | 'Natural Language :: English', 33 | 'Operating System :: OS Independent', 34 | 'Programming Language :: Python :: 3.6', 35 | 'Programming Language :: Python :: 3.7', 36 | 'Programming Language :: Python :: 3.8', 37 | 'Programming Language :: Python :: 3.9', 38 | 'Programming Language :: Python :: 3.10', 39 | 'Framework :: Django', 40 | 'Framework :: Django :: 2.2', 41 | 'Framework :: Django :: 3.0', 42 | 'Framework :: Django :: 3.1', 43 | 'Framework :: Django :: 3.2', 44 | 'Framework :: Django :: 4.0', 45 | ], 46 | ) 47 | -------------------------------------------------------------------------------- /test_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saadmk11/django-newsfeed/7342f096a43bd651b9ba88381f7044a8273e974f/test_project/__init__.py -------------------------------------------------------------------------------- /test_project/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for django-newsfeed project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_project.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /test_project/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for django-newsfeed project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.1. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.1/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve(strict=True).parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'qkli%m^5(_a$fvi8klvxr_5n*^x(z%oom=dr)2a(7n_zmp5v7g' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 41 | 'newsfeed', 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | 'django.middleware.security.SecurityMiddleware', 46 | 'django.contrib.sessions.middleware.SessionMiddleware', 47 | 'django.middleware.common.CommonMiddleware', 48 | 'django.middleware.csrf.CsrfViewMiddleware', 49 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 50 | 'django.contrib.messages.middleware.MessageMiddleware', 51 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 52 | ] 53 | 54 | ROOT_URLCONF = 'test_project.urls' 55 | 56 | TEMPLATES = [ 57 | { 58 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 59 | 'DIRS': [], 60 | 'APP_DIRS': True, 61 | 'OPTIONS': { 62 | 'context_processors': [ 63 | 'django.template.context_processors.debug', 64 | 'django.template.context_processors.request', 65 | 'django.contrib.auth.context_processors.auth', 66 | 'django.contrib.messages.context_processors.messages', 67 | ], 68 | }, 69 | }, 70 | ] 71 | 72 | WSGI_APPLICATION = 'test_project.wsgi.application' 73 | 74 | 75 | # Database 76 | # https://docs.djangoproject.com/en/3.1/ref/settings/#databases 77 | 78 | DATABASES = { 79 | 'default': { 80 | 'ENGINE': 'django.db.backends.sqlite3', 81 | 'NAME': BASE_DIR / 'db.sqlite3', 82 | } 83 | } 84 | 85 | 86 | # Password validation 87 | # https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators 88 | 89 | AUTH_PASSWORD_VALIDATORS = [ 90 | { 91 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 92 | }, 93 | { 94 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 95 | }, 96 | { 97 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 98 | }, 99 | { 100 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 101 | }, 102 | ] 103 | 104 | 105 | # Internationalization 106 | # https://docs.djangoproject.com/en/3.1/topics/i18n/ 107 | 108 | LANGUAGE_CODE = 'en-us' 109 | 110 | TIME_ZONE = 'UTC' 111 | 112 | USE_I18N = True 113 | 114 | USE_L10N = True 115 | 116 | USE_TZ = True 117 | 118 | 119 | # Static files (CSS, JavaScript, Images) 120 | # https://docs.djangoproject.com/en/3.1/howto/static-files/ 121 | 122 | STATIC_URL = '/static/' 123 | 124 | # Settings for smtp 125 | EMAIL_HOST = 'test_host' 126 | EMAIL_HOST_USER = 'test_user' 127 | EMAIL_HOST_PASSWORD = 'test_password' 128 | EMAIL_PORT = 587 129 | EMAIL_USE_TLS = True 130 | EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' 131 | 132 | # Default primary key field type 133 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 134 | 135 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' 136 | -------------------------------------------------------------------------------- /test_project/urls.py: -------------------------------------------------------------------------------- 1 | """django-newsfeed URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import include, path 18 | 19 | urlpatterns = [ 20 | path('admin/', admin.site.urls), 21 | path('newsfeed/', include('newsfeed.urls', namespace='newsfeed')), 22 | ] 23 | -------------------------------------------------------------------------------- /test_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for django-newsfeed project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_project.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saadmk11/django-newsfeed/7342f096a43bd651b9ba88381f7044a8273e974f/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_admin.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django.contrib.auth.models import User 4 | from django.test import TestCase 5 | from django.urls import reverse 6 | from django.utils import timezone 7 | 8 | from model_bakery import baker 9 | 10 | from newsfeed.models import Issue, Newsletter, Post 11 | 12 | 13 | class IssueAdminTest(TestCase): 14 | 15 | def setUp(self): 16 | self.admin = baker.make( 17 | User, username='admin', password='test_passWord', 18 | is_staff=True, is_superuser=True 19 | ) 20 | self.unreleased_issue = baker.make( 21 | Issue, is_draft=True, 22 | publish_date=timezone.now() - timezone.timedelta(days=1) 23 | ) 24 | self.released_issue = baker.make( 25 | Issue, is_draft=False, 26 | publish_date=timezone.now() - timezone.timedelta(days=1) 27 | ) 28 | self.client.force_login(self.admin) 29 | 30 | def test_publish_issues_action(self): 31 | self.assertTrue(self.unreleased_issue.is_draft) 32 | data = { 33 | 'action': 'publish_issues', 34 | '_selected_action': [self.unreleased_issue.id] 35 | } 36 | 37 | response = self.client.post( 38 | reverse('admin:newsfeed_issue_changelist'), data 39 | ) 40 | 41 | self.assertRedirects( 42 | response, reverse('admin:newsfeed_issue_changelist') 43 | ) 44 | 45 | self.unreleased_issue.refresh_from_db() 46 | self.assertFalse(self.unreleased_issue.is_draft) 47 | 48 | def test_make_draft_action(self): 49 | self.assertFalse(self.released_issue.is_draft) 50 | data = { 51 | 'action': 'make_draft', 52 | '_selected_action': [self.released_issue.id] 53 | } 54 | 55 | response = self.client.post( 56 | reverse('admin:newsfeed_issue_changelist'), data 57 | ) 58 | 59 | self.assertRedirects( 60 | response, reverse('admin:newsfeed_issue_changelist') 61 | ) 62 | 63 | self.released_issue.refresh_from_db() 64 | self.assertTrue(self.released_issue.is_draft) 65 | 66 | 67 | class NewsletterAdminTest(TestCase): 68 | 69 | def setUp(self): 70 | self.admin = baker.make( 71 | User, username='admin', password='test_passWord', 72 | is_staff=True, is_superuser=True 73 | ) 74 | self.released_issue = baker.make( 75 | Issue, is_draft=False, 76 | publish_date=timezone.now() - timezone.timedelta(days=1) 77 | ) 78 | self.released_newsletter = baker.make( 79 | Newsletter, issue=self.released_issue, is_sent=False, 80 | schedule=timezone.now() - timezone.timedelta(days=1), 81 | ) 82 | self.client.force_login(self.admin) 83 | 84 | @mock.patch('newsfeed.admin.send_email_newsletter') 85 | def test_send_newsletters_action(self, send_email_newsletter): 86 | data = { 87 | 'action': 'send_newsletters', 88 | '_selected_action': [self.released_newsletter.id] 89 | } 90 | response = self.client.post( 91 | reverse('admin:newsfeed_newsletter_changelist'), data 92 | ) 93 | 94 | self.assertRedirects( 95 | response, reverse('admin:newsfeed_newsletter_changelist') 96 | ) 97 | send_email_newsletter.assert_called_once() 98 | 99 | 100 | class PostAdminTest(TestCase): 101 | 102 | def setUp(self): 103 | self.admin = baker.make( 104 | User, username='admin', password='test_passWord', 105 | is_staff=True, is_superuser=True 106 | ) 107 | self.visible_post = baker.make( 108 | Post, is_visible=True 109 | ) 110 | self.invisible_post = baker.make( 111 | Post, is_visible=False 112 | ) 113 | 114 | self.client.force_login(self.admin) 115 | 116 | def test_hide_post_action(self): 117 | self.assertTrue(self.visible_post.is_visible) 118 | data = { 119 | 'action': 'hide_post', 120 | '_selected_action': [self.visible_post.id] 121 | } 122 | 123 | response = self.client.post( 124 | reverse('admin:newsfeed_post_changelist'), data 125 | ) 126 | 127 | self.assertRedirects( 128 | response, reverse('admin:newsfeed_post_changelist') 129 | ) 130 | 131 | self.visible_post.refresh_from_db() 132 | self.assertFalse(self.visible_post.is_visible) 133 | 134 | def test_make_post_visible_action(self): 135 | self.assertFalse(self.invisible_post.is_visible) 136 | data = { 137 | 'action': 'make_post_visible', 138 | '_selected_action': [self.invisible_post.id] 139 | } 140 | 141 | response = self.client.post( 142 | reverse('admin:newsfeed_post_changelist'), data 143 | ) 144 | 145 | self.assertRedirects( 146 | response, reverse('admin:newsfeed_post_changelist') 147 | ) 148 | 149 | self.invisible_post.refresh_from_db() 150 | self.assertTrue(self.invisible_post.is_visible) 151 | -------------------------------------------------------------------------------- /tests/test_apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.test import TestCase 3 | 4 | from newsfeed.apps import NewsfeedConfig 5 | 6 | 7 | class NewsfeedConfigTest(TestCase): 8 | 9 | def test_apps(self): 10 | self.assertEqual(NewsfeedConfig.name, 'newsfeed') 11 | self.assertEqual(apps.get_app_config('newsfeed').name, 'newsfeed') 12 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django.test import TestCase 4 | from django.utils import timezone 5 | 6 | from model_bakery import baker 7 | 8 | from newsfeed.models import Issue, Newsletter, Post, PostCategory, Subscriber 9 | 10 | 11 | class PostModelTest(TestCase): 12 | 13 | def setUp(self): 14 | self.invisible_posts = baker.make(Post, is_visible=False, _quantity=2) 15 | self.visible_posts = baker.make(Post, is_visible=True, _quantity=2) 16 | 17 | def test_str(self): 18 | post = Post.objects.visible().first() 19 | self.assertEqual(post.title, str(post)) 20 | 21 | def test_visible_queryset(self): 22 | posts = Post.objects.visible() 23 | 24 | self.assertEqual(posts.count(), 2) 25 | 26 | def test_all_queryset(self): 27 | posts = Post.objects.all() 28 | 29 | self.assertEqual(posts.count(), 4) 30 | 31 | 32 | class IssueModelTest(TestCase): 33 | 34 | def setUp(self): 35 | self.released_issue = baker.make( 36 | Issue, is_draft=False, 37 | publish_date=timezone.now() - timezone.timedelta(days=1), 38 | ) 39 | self.unreleased_issue = baker.make( 40 | Issue, is_draft=True, 41 | ) 42 | 43 | def test_str(self): 44 | issue = Issue.objects.released().first() 45 | self.assertEqual(issue.title, str(issue)) 46 | 47 | def test_all_queryset(self): 48 | issues = Issue.objects.all() 49 | 50 | self.assertEqual(issues.count(), 2) 51 | 52 | def test_released_queryset(self): 53 | issues = Issue.objects.released() 54 | 55 | self.assertEqual(issues.count(), 1) 56 | 57 | def test_released_with_future_released_date_queryset(self): 58 | future_issue = baker.make( 59 | Issue, is_draft=False, 60 | publish_date=timezone.now() + timezone.timedelta(days=1), 61 | ) 62 | future_issue_exists = Issue.objects.released().filter( 63 | id=future_issue.id 64 | ).exists() 65 | 66 | self.assertFalse(future_issue_exists) 67 | 68 | def test_is_published(self): 69 | self.assertTrue(self.released_issue.is_published) 70 | self.assertFalse(self.unreleased_issue.is_published) 71 | 72 | def test_get_absolute_url(self): 73 | expected_url = f'/newsfeed/issues/{self.released_issue.issue_number}/' 74 | self.assertEqual(self.released_issue.get_absolute_url(), expected_url) 75 | 76 | 77 | class SubscriberModelTest(TestCase): 78 | 79 | def setUp(self): 80 | self.verified_subscriber = baker.make( 81 | Subscriber, subscribed=True, verified=True 82 | ) 83 | self.unverified_subscriber = baker.make( 84 | Subscriber, subscribed=False, verified=False 85 | ) 86 | 87 | def test_str(self): 88 | subscriber = Subscriber.objects.subscribed().first() 89 | self.assertEqual(subscriber.email_address, str(subscriber)) 90 | 91 | def test_all_queryset(self): 92 | subscribers = Subscriber.objects.all() 93 | 94 | self.assertEqual(subscribers.count(), 2) 95 | 96 | def test_subscribed_queryset(self): 97 | subscribers = Subscriber.objects.subscribed() 98 | 99 | self.assertEqual(subscribers.count(), 1) 100 | 101 | def test_token_expired(self): 102 | self.unverified_subscriber.verification_sent_date = ( 103 | timezone.now() - timezone.timedelta(days=3) 104 | ) 105 | self.unverified_subscriber.save() 106 | 107 | self.assertTrue(self.unverified_subscriber.token_expired()) 108 | 109 | def test_token_not_expired(self): 110 | self.unverified_subscriber.verification_sent_date = timezone.now() 111 | self.unverified_subscriber.save() 112 | 113 | self.assertFalse(self.unverified_subscriber.token_expired()) 114 | 115 | def test_token_expired_with_no_verification_sent_date(self): 116 | self.unverified_subscriber.verification_sent_date = None 117 | self.unverified_subscriber.save() 118 | 119 | self.assertTrue(self.unverified_subscriber.token_expired()) 120 | 121 | def test_reset_token(self): 122 | old_token = self.unverified_subscriber.token 123 | self.unverified_subscriber.reset_token() 124 | 125 | self.assertNotEqual(old_token, self.unverified_subscriber.token) 126 | 127 | @mock.patch('newsfeed.models.uuid') 128 | def test_reset_token_with_existing_token(self, uuid): 129 | old_token = self.unverified_subscriber.token 130 | new_token = 'new_token' 131 | uuid.uuid4.side_effect = [old_token, new_token] 132 | 133 | self.unverified_subscriber.reset_token() 134 | 135 | self.assertNotEqual(old_token, self.unverified_subscriber.token) 136 | self.assertEqual(new_token, self.unverified_subscriber.token) 137 | 138 | def test_subscribe(self): 139 | self.unverified_subscriber.verification_sent_date = timezone.now() 140 | self.unverified_subscriber.save() 141 | 142 | self.assertFalse(self.unverified_subscriber.verified) 143 | self.assertFalse(self.unverified_subscriber.subscribed) 144 | 145 | subscribed = self.unverified_subscriber.subscribe() 146 | 147 | self.assertTrue(subscribed) 148 | self.assertTrue(self.unverified_subscriber.verified) 149 | self.assertTrue(self.unverified_subscriber.subscribed) 150 | 151 | def test_subscribe_with_expired_token(self): 152 | self.unverified_subscriber.verification_sent_date = ( 153 | timezone.now() - timezone.timedelta(days=3) 154 | ) 155 | self.unverified_subscriber.save() 156 | 157 | self.assertTrue(self.unverified_subscriber.token_expired()) 158 | self.assertFalse(self.unverified_subscriber.verified) 159 | self.assertFalse(self.unverified_subscriber.subscribed) 160 | 161 | subscribed = self.unverified_subscriber.subscribe() 162 | 163 | self.assertFalse(subscribed) 164 | self.assertFalse(self.unverified_subscriber.verified) 165 | self.assertFalse(self.unverified_subscriber.subscribed) 166 | 167 | def test_unsubscribe_with_unsubscribed_email(self): 168 | self.assertFalse(self.unverified_subscriber.verified) 169 | self.assertFalse(self.unverified_subscriber.subscribed) 170 | 171 | unsubscribed = self.unverified_subscriber.unsubscribe() 172 | 173 | self.assertFalse(unsubscribed) 174 | self.assertFalse(self.unverified_subscriber.verified) 175 | self.assertFalse(self.unverified_subscriber.subscribed) 176 | 177 | def test_unsubscribe(self): 178 | self.assertTrue(self.verified_subscriber.verified) 179 | self.assertTrue(self.verified_subscriber.subscribed) 180 | 181 | unsubscribed = self.verified_subscriber.unsubscribe() 182 | 183 | self.assertTrue(unsubscribed) 184 | self.assertFalse(self.verified_subscriber.verified) 185 | self.assertFalse(self.verified_subscriber.subscribed) 186 | 187 | @mock.patch('newsfeed.models.send_subscription_verification_email') 188 | def test_send_verification_email_with_existing_email( 189 | self, send_verification_email 190 | ): 191 | old_token = self.unverified_subscriber.token 192 | 193 | self.unverified_subscriber.send_verification_email(False) 194 | 195 | self.assertNotEqual(self.unverified_subscriber.token, old_token) 196 | send_verification_email.assert_called_once_with( 197 | self.unverified_subscriber.get_verification_url(), 198 | self.unverified_subscriber.email_address 199 | ) 200 | 201 | @mock.patch('newsfeed.models.send_subscription_verification_email') 202 | def test_send_verification_email_with_new_email( 203 | self, send_verification_email 204 | ): 205 | new_unverified_subscriber = baker.make( 206 | Subscriber, subscribed=False, verified=False 207 | ) 208 | old_token = new_unverified_subscriber.token 209 | 210 | new_unverified_subscriber.send_verification_email(True) 211 | 212 | self.assertEqual(new_unverified_subscriber.token, old_token) 213 | send_verification_email.assert_called_once_with( 214 | new_unverified_subscriber.get_verification_url(), 215 | new_unverified_subscriber.email_address 216 | ) 217 | 218 | @mock.patch('newsfeed.models.send_subscription_verification_email') 219 | def test_send_verification_email_dont_send_email( 220 | self, send_verification_email 221 | ): 222 | new_unverified_subscriber = baker.make( 223 | Subscriber, subscribed=False, verified=False 224 | ) 225 | new_unverified_subscriber.verification_sent_date = ( 226 | timezone.now() - timezone.timedelta(minutes=2) 227 | ) 228 | new_unverified_subscriber.save() 229 | 230 | old_token = new_unverified_subscriber.token 231 | new_unverified_subscriber.send_verification_email(False) 232 | 233 | self.assertEqual(new_unverified_subscriber.token, old_token) 234 | send_verification_email.assert_not_called() 235 | 236 | def test_get_absolute_url(self): 237 | expected_url = ( 238 | f'/newsfeed/subscribe/confirm/{self.unverified_subscriber.token}/' 239 | ) 240 | self.assertEqual( 241 | self.unverified_subscriber.get_verification_url(), 242 | expected_url 243 | ) 244 | 245 | 246 | class NewsletterModelTest(TestCase): 247 | 248 | def test_str(self): 249 | newsletter = baker.make(Newsletter) 250 | self.assertEqual(newsletter.subject, str(newsletter)) 251 | 252 | 253 | class PostCategoryModelTest(TestCase): 254 | 255 | def test_str(self): 256 | category = baker.make(PostCategory) 257 | self.assertEqual(category.name, str(category)) 258 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django.core import mail 4 | from django.test import TestCase 5 | from django.test.client import RequestFactory 6 | from django.urls import reverse 7 | from django.utils import timezone 8 | 9 | from model_bakery import baker 10 | 11 | from newsfeed.models import Issue, Subscriber, Newsletter 12 | from newsfeed.utils.check_ajax import is_ajax 13 | from newsfeed.utils.send_verification import ( 14 | send_subscription_verification_email 15 | ) 16 | from newsfeed.utils.send_newsletters import ( 17 | NewsletterEmailSender, send_email_newsletter 18 | ) 19 | 20 | 21 | class SendSubscriptionVerificationEmailTest(TestCase): 22 | 23 | def setUp(self): 24 | self.unverified_subscriber = baker.make( 25 | Subscriber, subscribed=False, verified=False 26 | ) 27 | 28 | def test_send_subscription_verification_email(self): 29 | send_subscription_verification_email( 30 | self.unverified_subscriber.get_verification_url(), 31 | self.unverified_subscriber.email_address 32 | ) 33 | 34 | self.assertEqual(len(mail.outbox), 1) 35 | self.assertEqual( 36 | mail.outbox[0].subject, 37 | 'Please Confirm Your Subscription' 38 | ) 39 | self.assertEqual( 40 | mail.outbox[0].to, 41 | [self.unverified_subscriber.email_address] 42 | ) 43 | self.assertIn( 44 | self.unverified_subscriber.get_verification_url(), 45 | mail.outbox[0].body 46 | ) 47 | 48 | 49 | class SendNewsletterEmailTest(TestCase): 50 | 51 | def setUp(self): 52 | # Subscribers 53 | self.unverified_subscribers = baker.make( 54 | Subscriber, subscribed=False, verified=False, _quantity=2 55 | ) 56 | self.verified_subscribers = baker.make( 57 | Subscriber, subscribed=True, verified=True, _quantity=5 58 | ) 59 | # Issues 60 | self.released_issue = baker.make( 61 | Issue, is_draft=False, 62 | publish_date=timezone.now() - timezone.timedelta(days=1), 63 | ) 64 | self.released_issue_2 = baker.make( 65 | Issue, is_draft=False, 66 | publish_date=timezone.now() - timezone.timedelta(days=1), 67 | ) 68 | self.released_issue_3 = baker.make( 69 | Issue, is_draft=False, 70 | publish_date=timezone.now() - timezone.timedelta(days=1), 71 | ) 72 | self.unreleased_issue = baker.make( 73 | Issue, is_draft=True, 74 | ) 75 | # Newsletters 76 | self.released_newsletter_1 = baker.make( 77 | Newsletter, issue=self.released_issue, is_sent=False, 78 | schedule=timezone.now() - timezone.timedelta(days=1), 79 | ) 80 | self.released_newsletter_2 = baker.make( 81 | Newsletter, issue=self.released_issue_2, is_sent=False, 82 | schedule=timezone.now() - timezone.timedelta(days=1), 83 | ) 84 | self.sent_newsletter = baker.make( 85 | Newsletter, issue=self.released_issue_3, is_sent=True, 86 | schedule=timezone.now() - timezone.timedelta(days=1), 87 | ) 88 | self.unreleased_newsletter = baker.make( 89 | Newsletter, issue=self.unreleased_issue, is_sent=False, 90 | schedule=timezone.now() - timezone.timedelta(days=1), 91 | ) 92 | self.future_scheduled_newsletter = baker.make( 93 | Newsletter, issue=self.released_issue, is_sent=False, 94 | schedule=timezone.now() + timezone.timedelta(days=1), 95 | ) 96 | 97 | def test_send_email_newsletter(self): 98 | newsletters = Newsletter.objects.filter( 99 | id__in=[ 100 | self.released_newsletter_1.id, 101 | self.released_newsletter_2.id 102 | ], 103 | is_sent=True 104 | ) 105 | self.assertFalse(newsletters.exists()) 106 | 107 | send_email_newsletter() 108 | 109 | self.assertEqual(len(mail.outbox), 10) 110 | self.assertEqual( 111 | mail.outbox[0].subject, 112 | self.released_newsletter_1.subject 113 | ) 114 | self.assertEqual( 115 | mail.outbox[5].subject, 116 | self.released_newsletter_2.subject 117 | ) 118 | 119 | self.assertTrue(newsletters.exists()) 120 | 121 | def test_send_email_newsletter_custom_queryset(self): 122 | newsletters = Newsletter.objects.filter( 123 | id__in=[ 124 | self.released_newsletter_1.id, 125 | self.released_newsletter_2.id 126 | ] 127 | ) 128 | self.assertFalse(newsletters.filter(is_sent=True).exists()) 129 | 130 | send_email_newsletter(newsletters=newsletters) 131 | 132 | self.assertEqual(len(mail.outbox), 10) 133 | self.assertEqual( 134 | mail.outbox[0].subject, 135 | self.released_newsletter_1.subject 136 | ) 137 | self.assertEqual( 138 | mail.outbox[5].subject, 139 | self.released_newsletter_2.subject 140 | ) 141 | 142 | self.assertTrue(newsletters.filter(is_sent=True).exists()) 143 | 144 | @mock.patch('newsfeed.utils.send_newsletters.logger') 145 | def test_send_email_newsletter_with_error(self, logger): 146 | send_newsletter = NewsletterEmailSender() 147 | send_newsletter.connection.send_messages = mock.Mock( 148 | side_effect=Exception() 149 | ) 150 | send_newsletter.send_emails() 151 | logger.error.assert_called() 152 | 153 | def test_send_email_newsletter_with_no_subscribers(self): 154 | newsletters = Newsletter.objects.filter( 155 | id__in=[ 156 | self.released_newsletter_1.id, 157 | self.released_newsletter_2.id 158 | ] 159 | ) 160 | self.assertFalse(newsletters.filter(is_sent=True).exists()) 161 | 162 | Subscriber.objects.all().update(subscribed=False) 163 | send_newsletter = NewsletterEmailSender() 164 | send_newsletter.send_emails() 165 | 166 | self.assertFalse(newsletters.filter(is_sent=True).exists()) 167 | 168 | def test_send_email_newsletter_dont_respect_schedule(self): 169 | newsletters = Newsletter.objects.filter( 170 | id__in=[ 171 | self.released_newsletter_1.id, 172 | self.released_newsletter_2.id, 173 | self.future_scheduled_newsletter.id, 174 | ], 175 | is_sent=True 176 | ) 177 | self.assertFalse(newsletters.exists()) 178 | 179 | send_email_newsletter(respect_schedule=False) 180 | 181 | self.assertEqual(len(mail.outbox), 15) 182 | self.assertEqual( 183 | mail.outbox[0].subject, 184 | self.released_newsletter_1.subject 185 | ) 186 | self.assertEqual( 187 | mail.outbox[5].subject, 188 | self.released_newsletter_2.subject 189 | ) 190 | self.assertEqual( 191 | mail.outbox[10].subject, 192 | self.future_scheduled_newsletter.subject 193 | ) 194 | 195 | self.assertTrue(newsletters.exists()) 196 | 197 | def test_render_newsletter(self): 198 | rendered = NewsletterEmailSender._render_newsletter( 199 | self.released_newsletter_1 200 | ) 201 | 202 | self.assertEqual( 203 | rendered['subject'], 204 | self.released_newsletter_1.subject 205 | ) 206 | self.assertEqual(type(rendered), dict) 207 | 208 | def test_generate_email_message(self): 209 | rendered = NewsletterEmailSender._render_newsletter( 210 | self.released_newsletter_1 211 | ) 212 | send_newsletter = NewsletterEmailSender() 213 | message = send_newsletter._generate_email_message( 214 | 'test@test.com', rendered 215 | ) 216 | 217 | self.assertEqual( 218 | message.subject, 219 | self.released_newsletter_1.subject 220 | ) 221 | self.assertEqual(message.body, rendered['html']) 222 | self.assertEqual(message.to, ['test@test.com']) 223 | 224 | def test_get_subscriber_emails(self): 225 | rendered = NewsletterEmailSender._render_newsletter( 226 | self.released_newsletter_1 227 | ) 228 | send_newsletter = NewsletterEmailSender() 229 | # set batch size to 2 230 | send_newsletter.batch_size = 2 231 | 232 | email_msg_generator = send_newsletter._get_batch_email_messages( 233 | rendered 234 | ) 235 | 236 | # total five subscribed emails were added in the setUp() 237 | self.assertEqual(len(list(next(email_msg_generator))), 2) 238 | self.assertEqual(len(list(next(email_msg_generator))), 2) 239 | self.assertEqual(len(list(next(email_msg_generator))), 1) 240 | 241 | def test_get_subscriber_emails_return_email_message_instances(self): 242 | rendered = NewsletterEmailSender._render_newsletter( 243 | self.released_newsletter_1 244 | ) 245 | send_newsletter = NewsletterEmailSender() 246 | 247 | email_msg_generator = send_newsletter._get_batch_email_messages( 248 | rendered 249 | ) 250 | 251 | messages = list(next(email_msg_generator)) 252 | 253 | self.assertTrue(isinstance(messages[0], mail.EmailMessage)) 254 | 255 | def test_get_subscriber_emails_with_zero_subscribers(self): 256 | Subscriber.objects.all().update(subscribed=False) 257 | 258 | rendered = NewsletterEmailSender._render_newsletter( 259 | self.released_newsletter_1 260 | ) 261 | send_newsletter = NewsletterEmailSender() 262 | 263 | email_msg_generator = send_newsletter._get_batch_email_messages( 264 | rendered 265 | ) 266 | 267 | with self.assertRaises(StopIteration): 268 | next(email_msg_generator) 269 | 270 | 271 | class CheckAjaxTest(TestCase): 272 | 273 | def test_request_is_ajax(self): 274 | factory = RequestFactory() 275 | request = factory.get( 276 | reverse('newsfeed:newsletter_subscribe'), 277 | data={"email_address": 'test@test.com'}, 278 | HTTP_X_REQUESTED_WITH='XMLHttpRequest' 279 | ) 280 | self.assertTrue(is_ajax(request)) 281 | 282 | def test_request_is_not_ajax(self): 283 | factory = RequestFactory() 284 | request = factory.get( 285 | reverse('newsfeed:newsletter_subscribe'), 286 | data={"email_address": 'test@test.com'} 287 | ) 288 | self.assertFalse(is_ajax(request)) 289 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest import mock 3 | 4 | from django.contrib.messages import get_messages 5 | from django.test import TestCase 6 | from django.urls import reverse 7 | from django.utils import timezone 8 | 9 | from model_bakery import baker 10 | 11 | from newsfeed.models import Issue, Post, Subscriber 12 | from newsfeed import signals 13 | 14 | 15 | class IssueListViewTest(TestCase): 16 | 17 | def setUp(self): 18 | self.released_issues = baker.make( 19 | Issue, is_draft=False, _quantity=16, 20 | publish_date=timezone.now() - timezone.timedelta(days=1) 21 | ) 22 | 23 | def test_issue_list_view_url_exists(self): 24 | response = self.client.get(reverse('newsfeed:issue_list')) 25 | self.assertEqual(response.status_code, 200) 26 | 27 | def test_issue_list_view_uses_correct_template(self): 28 | response = self.client.get(reverse('newsfeed:issue_list')) 29 | self.assertEqual(response.status_code, 200) 30 | self.assertTemplateUsed(response, 'newsfeed/issue_list.html') 31 | 32 | def test_pagination_is_fifteen(self): 33 | response = self.client.get(reverse('newsfeed:issue_list')) 34 | self.assertEqual(response.status_code, 200) 35 | self.assertTrue('is_paginated' in response.context) 36 | self.assertTrue(response.context['is_paginated']) 37 | self.assertTrue(len(response.context['object_list']) == 15) 38 | 39 | def test_issue_list_view_doesnt_show_draft_issues(self): 40 | Issue.objects.update(is_draft=True) 41 | 42 | response = self.client.get(reverse('newsfeed:issue_list')) 43 | self.assertEqual(response.status_code, 200) 44 | 45 | self.assertTrue(len(response.context['object_list']) == 0) 46 | 47 | def test_issue_list_view_doesnt_show_future_issues(self): 48 | Issue.objects.update( 49 | is_draft=False, 50 | publish_date=timezone.now() + timezone.timedelta(days=1) 51 | ) 52 | 53 | response = self.client.get(reverse('newsfeed:issue_list')) 54 | self.assertEqual(response.status_code, 200) 55 | 56 | self.assertTrue(len(response.context['object_list']) == 0) 57 | 58 | 59 | class IssueDetailViewTest(TestCase): 60 | 61 | def setUp(self): 62 | self.released_issue = baker.make( 63 | Issue, is_draft=False, 64 | publish_date=timezone.now() - timezone.timedelta(days=1) 65 | ) 66 | self.unreleased_issue = baker.make(Issue, is_draft=True) 67 | self.posts = baker.make( 68 | Post, is_visible=True, 69 | issue=self.released_issue, _quantity=2 70 | ) 71 | 72 | def test_issue_detail_view_url_exists(self): 73 | response = self.client.get( 74 | reverse( 75 | 'newsfeed:issue_detail', 76 | kwargs={'issue_number': self.released_issue.issue_number} 77 | ) 78 | ) 79 | self.assertTrue('issue' in response.context) 80 | self.assertEqual(response.status_code, 200) 81 | 82 | def test_issue_detail_view_uses_correct_template(self): 83 | response = self.client.get( 84 | reverse( 85 | 'newsfeed:issue_detail', 86 | kwargs={'issue_number': self.released_issue.issue_number} 87 | ) 88 | ) 89 | self.assertEqual(response.status_code, 200) 90 | self.assertTemplateUsed(response, 'newsfeed/issue_detail.html') 91 | 92 | def test_issue_detail_view_doesnt_show_invisible_posts(self): 93 | Post.objects.update(is_visible=False) 94 | 95 | response = self.client.get( 96 | reverse( 97 | 'newsfeed:issue_detail', 98 | kwargs={'issue_number': self.released_issue.issue_number} 99 | ) 100 | ) 101 | self.assertEqual(response.status_code, 200) 102 | 103 | self.assertTrue(len(response.context['object_list']) == 0) 104 | 105 | def test_issue_detail_view_not_found_for_draft_issue(self): 106 | response = self.client.get( 107 | reverse( 108 | 'newsfeed:issue_detail', 109 | kwargs={'issue_number': self.unreleased_issue.issue_number} 110 | ) 111 | ) 112 | self.assertEqual(response.status_code, 404) 113 | 114 | 115 | class LatestIssueViewTest(TestCase): 116 | 117 | def setUp(self): 118 | self.released_issue = baker.make( 119 | Issue, is_draft=False, _quantity=2, 120 | publish_date=timezone.now() - timezone.timedelta(days=1) 121 | ) 122 | self.posts = baker.make( 123 | Post, is_visible=True, 124 | _quantity=2, issue=self.released_issue[1] 125 | ) 126 | 127 | def test_latest_issue_view_url_exists(self): 128 | response = self.client.get(reverse('newsfeed:latest_issue')) 129 | 130 | self.assertTrue('latest_issue' in response.context) 131 | self.assertEqual(response.status_code, 200) 132 | 133 | def test_latest_issue_view_uses_correct_template(self): 134 | response = self.client.get(reverse('newsfeed:latest_issue')) 135 | self.assertEqual(response.status_code, 200) 136 | self.assertTemplateUsed(response, 'newsfeed/latest_issue.html') 137 | 138 | def test_latest_issue_view_doesnt_show_invisible_posts(self): 139 | latest_issue = Issue.objects.latest('issue_number') 140 | Post.objects.update(issue=latest_issue) 141 | 142 | response = self.client.get(reverse('newsfeed:latest_issue')) 143 | self.assertEqual(response.status_code, 200) 144 | self.assertFalse(response.context['latest_issue'].posts.count() == 0) 145 | 146 | Post.objects.update(is_visible=False) 147 | 148 | response = self.client.get(reverse('newsfeed:latest_issue')) 149 | self.assertEqual(response.status_code, 200) 150 | self.assertTrue(response.context['latest_issue'].posts.count() == 0) 151 | 152 | def test_latest_issue_view_shows_latest_issue(self): 153 | latest_issue = Issue.objects.latest('issue_number') 154 | response = self.client.get(reverse('newsfeed:latest_issue')) 155 | self.assertEqual(response.status_code, 200) 156 | 157 | self.assertEqual(response.context['latest_issue'].id, latest_issue.id) 158 | 159 | def test_latest_issue_view_with_no_issues(self): 160 | Issue.objects.all().delete() 161 | response = self.client.get(reverse('newsfeed:latest_issue')) 162 | self.assertEqual(response.status_code, 200) 163 | 164 | 165 | class NewsletterSubscribeViewTest(TestCase): 166 | 167 | def setUp(self): 168 | self.verified_subscriber = baker.make( 169 | Subscriber, subscribed=True, verified=True 170 | ) 171 | self.mock_receiver = mock.Mock() 172 | self.email_verification_sent_signal = signals.email_verification_sent 173 | 174 | self.email_verification_sent_signal.connect(self.mock_receiver) 175 | 176 | def tearDown(self): 177 | self.email_verification_sent_signal.disconnect(self.mock_receiver) 178 | 179 | def test_newsfeed_subscribe_view_url_exists(self): 180 | response = self.client.get(reverse('newsfeed:newsletter_subscribe')) 181 | self.assertEqual(response.status_code, 200) 182 | 183 | def test_newsfeed_subscribe_view_uses_correct_template(self): 184 | response = self.client.get(reverse('newsfeed:newsletter_subscribe')) 185 | self.assertEqual(response.status_code, 200) 186 | self.assertTemplateUsed( 187 | response, 'newsfeed/newsletter_subscribe.html' 188 | ) 189 | 190 | @mock.patch('newsfeed.models.send_subscription_verification_email') 191 | def test_newsfeed_subscribe_view_success(self, send_verification_email): 192 | response = self.client.post( 193 | reverse('newsfeed:newsletter_subscribe'), 194 | data={"email_address": "test@test.com"} 195 | ) 196 | 197 | self.assertRedirects( 198 | response, reverse('newsfeed:issue_list'), 199 | status_code=302, target_status_code=200 200 | ) 201 | message = [ 202 | (m.message, m.level) 203 | for m in get_messages(response.wsgi_request) 204 | ][0] 205 | 206 | subscribers = Subscriber.objects.filter(email_address="test@test.com") 207 | 208 | self.assertTrue(subscribers.exists()) 209 | subscriber = subscribers.first() 210 | self.assertIn( 211 | 'Thank you for subscribing! ' 212 | 'Please check your e-mail inbox to confirm ' 213 | 'your subscription and start receiving newsletters.', 214 | message[0] 215 | ) 216 | send_verification_email.assert_called_once_with( 217 | subscriber.get_verification_url(), subscriber.email_address 218 | ) 219 | self.mock_receiver.assert_called_once_with( 220 | sender=Subscriber, 221 | instance=subscriber, 222 | signal=self.email_verification_sent_signal, 223 | ) 224 | 225 | @mock.patch('newsfeed.models.send_subscription_verification_email') 226 | def test_newsfeed_subscribe_view_already_subscribed( 227 | self, send_verification_email 228 | ): 229 | response = self.client.post( 230 | reverse('newsfeed:newsletter_subscribe'), 231 | data={"email_address": self.verified_subscriber.email_address} 232 | ) 233 | 234 | self.assertRedirects( 235 | response, reverse('newsfeed:issue_list'), 236 | status_code=302, target_status_code=200 237 | ) 238 | message = [ 239 | (m.message, m.level) 240 | for m in get_messages(response.wsgi_request) 241 | ][0] 242 | 243 | self.assertIn( 244 | 'You have already subscribed to the newsletter.', message[0] 245 | ) 246 | send_verification_email.assert_not_called() 247 | self.mock_receiver.assert_not_called() 248 | 249 | @mock.patch('newsfeed.models.send_subscription_verification_email') 250 | def test_newsfeed_subscribe_view_invalid_email( 251 | self, send_verification_email 252 | ): 253 | response = self.client.post( 254 | reverse('newsfeed:newsletter_subscribe'), 255 | data={"email_address": 'invalid_email'} 256 | ) 257 | 258 | self.assertEqual(response.status_code, 200) 259 | send_verification_email.assert_not_called() 260 | self.mock_receiver.assert_not_called() 261 | 262 | @mock.patch('newsfeed.models.send_subscription_verification_email') 263 | def test_newsfeed_subscribe_view_success_ajax( 264 | self, send_verification_email 265 | ): 266 | response = self.client.post( 267 | reverse('newsfeed:newsletter_subscribe'), 268 | data={"email_address": "test@test.com"}, 269 | HTTP_X_REQUESTED_WITH='XMLHttpRequest' 270 | ) 271 | 272 | self.assertEqual(response.status_code, 200) 273 | 274 | subscribers = Subscriber.objects.filter(email_address="test@test.com") 275 | response_data = json.loads(response.content) 276 | 277 | self.assertTrue(subscribers.exists()) 278 | subscriber = subscribers.first() 279 | self.assertIn( 280 | 'Thank you for subscribing! ' 281 | 'Please check your e-mail inbox to confirm ' 282 | 'your subscription and start receiving newsletters.', 283 | response_data['message'] 284 | ) 285 | self.assertTrue(response_data['success']) 286 | send_verification_email.assert_called_once_with( 287 | subscriber.get_verification_url(), subscriber.email_address 288 | ) 289 | self.mock_receiver.assert_called_once_with( 290 | sender=Subscriber, 291 | instance=subscriber, 292 | signal=self.email_verification_sent_signal, 293 | ) 294 | 295 | @mock.patch('newsfeed.models.send_subscription_verification_email') 296 | def test_newsfeed_subscribe_view_already_subscribed_ajax( 297 | self, send_verification_email 298 | ): 299 | response = self.client.post( 300 | reverse('newsfeed:newsletter_subscribe'), 301 | data={"email_address": self.verified_subscriber.email_address}, 302 | HTTP_X_REQUESTED_WITH='XMLHttpRequest' 303 | ) 304 | 305 | self.assertEqual(response.status_code, 200) 306 | 307 | response_data = json.loads(response.content) 308 | 309 | self.assertIn( 310 | 'You have already subscribed to the newsletter.', 311 | response_data['message'] 312 | ) 313 | self.assertFalse(response_data['success']) 314 | send_verification_email.assert_not_called() 315 | self.mock_receiver.assert_not_called() 316 | 317 | @mock.patch('newsfeed.models.send_subscription_verification_email') 318 | def test_newsfeed_subscribe_view_invalid_email_ajax( 319 | self, send_verification_email 320 | ): 321 | response = self.client.post( 322 | reverse('newsfeed:newsletter_subscribe'), 323 | data={"email_address": 'invalid_email'}, 324 | HTTP_X_REQUESTED_WITH='XMLHttpRequest' 325 | ) 326 | 327 | self.assertEqual(response.status_code, 400) 328 | 329 | response_data = json.loads(response.content) 330 | 331 | self.assertEqual( 332 | { 333 | 'email_address': [ 334 | { 335 | 'code': 'invalid', 336 | 'message': 'Enter a valid email address.' 337 | } 338 | ] 339 | }, 340 | response_data 341 | ) 342 | send_verification_email.assert_not_called() 343 | self.mock_receiver.assert_not_called() 344 | 345 | 346 | class NewsletterUnsubscribeViewTest(TestCase): 347 | 348 | def setUp(self): 349 | self.verified_subscriber = baker.make( 350 | Subscriber, subscribed=True, verified=True 351 | ) 352 | self.unsubscribed_email = baker.make( 353 | Subscriber, subscribed=False, verified=False 354 | ) 355 | self.mock_receiver = mock.Mock() 356 | self.unsubscribed_signal = signals.unsubscribed 357 | 358 | self.unsubscribed_signal.connect(self.mock_receiver) 359 | 360 | def tearDown(self): 361 | self.unsubscribed_signal.disconnect(self.mock_receiver) 362 | 363 | def test_newsfeed_unsubscribe_view_url_exists(self): 364 | response = self.client.get(reverse('newsfeed:newsletter_unsubscribe')) 365 | self.assertEqual(response.status_code, 200) 366 | 367 | def test_newsfeed_unsubscribe_view_uses_correct_template(self): 368 | response = self.client.get(reverse('newsfeed:newsletter_unsubscribe')) 369 | self.assertEqual(response.status_code, 200) 370 | self.assertTemplateUsed( 371 | response, 'newsfeed/newsletter_unsubscribe.html' 372 | ) 373 | 374 | def test_newsfeed_unsubscribe_view_subscriber_does_not_exist(self): 375 | response = self.client.post( 376 | reverse('newsfeed:newsletter_unsubscribe'), 377 | data={"email_address": "test@test.com"} 378 | ) 379 | 380 | self.assertRedirects( 381 | response, reverse('newsfeed:issue_list'), 382 | status_code=302, target_status_code=200 383 | ) 384 | message = [ 385 | (m.message, m.level) 386 | for m in get_messages(response.wsgi_request) 387 | ][0] 388 | 389 | subscriber = Subscriber.objects.filter(email_address="test@test.com") 390 | 391 | self.assertFalse(subscriber.exists()) 392 | self.assertIn( 393 | 'Subscriber with this e-mail address does not exist.', 394 | message[0] 395 | ) 396 | self.mock_receiver.assert_not_called() 397 | 398 | def test_newsfeed_unsubscribe_view_unsubscribed_email(self): 399 | response = self.client.post( 400 | reverse('newsfeed:newsletter_unsubscribe'), 401 | data={"email_address": self.unsubscribed_email.email_address} 402 | ) 403 | 404 | self.assertRedirects( 405 | response, reverse('newsfeed:issue_list'), 406 | status_code=302, target_status_code=200 407 | ) 408 | message = [ 409 | (m.message, m.level) 410 | for m in get_messages(response.wsgi_request) 411 | ][0] 412 | 413 | subscriber = Subscriber.objects.filter( 414 | subscribed=True, 415 | email_address=self.unsubscribed_email.email_address 416 | ) 417 | 418 | self.assertFalse(subscriber.exists()) 419 | self.assertIn( 420 | 'Subscriber with this e-mail address does not exist.', 421 | message[0] 422 | ) 423 | self.mock_receiver.assert_not_called() 424 | 425 | def test_newsfeed_unsubscribe_view_success(self): 426 | response = self.client.post( 427 | reverse('newsfeed:newsletter_unsubscribe'), 428 | data={"email_address": self.verified_subscriber.email_address} 429 | ) 430 | 431 | self.assertRedirects( 432 | response, reverse('newsfeed:issue_list'), 433 | status_code=302, target_status_code=200 434 | ) 435 | message = [ 436 | (m.message, m.level) 437 | for m in get_messages(response.wsgi_request) 438 | ][0] 439 | 440 | self.verified_subscriber.refresh_from_db() 441 | self.assertFalse(self.verified_subscriber.subscribed) 442 | self.assertFalse(self.verified_subscriber.verified) 443 | 444 | self.assertIn( 445 | 'You have successfully unsubscribed from the newsletter.', 446 | message[0] 447 | ) 448 | self.mock_receiver.assert_called_once_with( 449 | sender=Subscriber, 450 | instance=self.verified_subscriber, 451 | signal=self.unsubscribed_signal, 452 | ) 453 | 454 | def test_newsfeed_unsubscribe_view_invalid_email(self): 455 | response = self.client.post( 456 | reverse('newsfeed:newsletter_unsubscribe'), 457 | data={"email_address": 'invalid_email'} 458 | ) 459 | 460 | self.assertEqual(response.status_code, 200) 461 | self.mock_receiver.assert_not_called() 462 | 463 | def test_newsfeed_unsubscribe_view_success_ajax(self): 464 | response = self.client.post( 465 | reverse('newsfeed:newsletter_unsubscribe'), 466 | data={"email_address": self.verified_subscriber.email_address}, 467 | HTTP_X_REQUESTED_WITH='XMLHttpRequest' 468 | ) 469 | 470 | self.assertEqual(response.status_code, 200) 471 | 472 | self.verified_subscriber.refresh_from_db() 473 | self.assertFalse(self.verified_subscriber.subscribed) 474 | self.assertFalse(self.verified_subscriber.verified) 475 | 476 | response_data = json.loads(response.content) 477 | 478 | self.assertIn( 479 | 'You have successfully unsubscribed from the newsletter.', 480 | response_data['message'] 481 | ) 482 | self.assertTrue(response_data['success']) 483 | 484 | self.mock_receiver.assert_called_once_with( 485 | sender=Subscriber, 486 | instance=self.verified_subscriber, 487 | signal=self.unsubscribed_signal, 488 | ) 489 | 490 | def test_newsfeed_unsubscribe_view_subscriber_does_not_exist_ajax(self): 491 | response = self.client.post( 492 | reverse('newsfeed:newsletter_unsubscribe'), 493 | data={"email_address": "test@test.com"}, 494 | HTTP_X_REQUESTED_WITH='XMLHttpRequest' 495 | ) 496 | 497 | self.assertEqual(response.status_code, 200) 498 | 499 | subscriber = Subscriber.objects.filter(email_address="test@test.com") 500 | self.assertFalse(subscriber.exists()) 501 | 502 | response_data = json.loads(response.content) 503 | 504 | self.assertIn( 505 | 'Subscriber with this e-mail address does not exist.', 506 | response_data['message'] 507 | ) 508 | self.assertFalse(response_data['success']) 509 | self.mock_receiver.assert_not_called() 510 | 511 | def test_newsfeed_unsubscribe_view_unsubscribed_email_ajax(self): 512 | response = self.client.post( 513 | reverse('newsfeed:newsletter_unsubscribe'), 514 | data={"email_address": self.unsubscribed_email.email_address}, 515 | HTTP_X_REQUESTED_WITH='XMLHttpRequest' 516 | ) 517 | self.assertEqual(response.status_code, 200) 518 | 519 | subscriber = Subscriber.objects.filter( 520 | subscribed=True, 521 | email_address=self.unsubscribed_email.email_address 522 | ) 523 | 524 | self.assertFalse(subscriber.exists()) 525 | 526 | response_data = json.loads(response.content) 527 | 528 | self.assertIn( 529 | 'Subscriber with this e-mail address does not exist.', 530 | response_data['message'] 531 | ) 532 | self.assertFalse(response_data['success']) 533 | self.mock_receiver.assert_not_called() 534 | 535 | def test_newsfeed_unsubscribe_view_invalid_email_ajax(self): 536 | response = self.client.post( 537 | reverse('newsfeed:newsletter_unsubscribe'), 538 | data={"email_address": 'invalid_email'}, 539 | HTTP_X_REQUESTED_WITH='XMLHttpRequest' 540 | ) 541 | 542 | self.assertEqual(response.status_code, 400) 543 | 544 | response_data = json.loads(response.content) 545 | 546 | self.assertEqual( 547 | { 548 | 'email_address': [ 549 | { 550 | 'code': 'invalid', 551 | 'message': 'Enter a valid email address.' 552 | } 553 | ] 554 | }, 555 | response_data 556 | ) 557 | self.mock_receiver.assert_not_called() 558 | 559 | 560 | class NewsletterSubscriptionConfirmViewTest(TestCase): 561 | 562 | def setUp(self): 563 | self.verified_subscriber = baker.make( 564 | Subscriber, subscribed=True, verified=True 565 | ) 566 | self.unverified_subscriber = baker.make( 567 | Subscriber, subscribed=False, verified=False, 568 | verification_sent_date=timezone.now() 569 | ) 570 | self.mock_receiver = mock.Mock() 571 | self.subscribed_signal = signals.subscribed 572 | 573 | self.subscribed_signal.connect(self.mock_receiver) 574 | 575 | def tearDown(self): 576 | self.subscribed_signal.disconnect(self.mock_receiver) 577 | 578 | def test_newsfeed_subscribe_view_url_exists(self): 579 | response = self.client.get( 580 | reverse( 581 | 'newsfeed:newsletter_subscription_confirm', 582 | kwargs={'token': self.unverified_subscriber.token} 583 | ) 584 | ) 585 | 586 | self.assertEqual(response.status_code, 200) 587 | self.assertTrue('subscribed' in response.context) 588 | self.assertTrue(response.context['subscribed']) 589 | self.assertTemplateUsed( 590 | response, 'newsfeed/newsletter_subscription_confirm.html' 591 | ) 592 | 593 | def test_newsfeed_subscribe_confirm_view_failed(self): 594 | response = self.client.get( 595 | reverse( 596 | 'newsfeed:newsletter_subscription_confirm', 597 | kwargs={'token': self.verified_subscriber.token} 598 | ) 599 | ) 600 | self.assertEqual(response.status_code, 404) 601 | 602 | self.mock_receiver.assert_not_called() 603 | 604 | def test_newsfeed_subscribe_confirm_view_success(self): 605 | response = self.client.get( 606 | reverse( 607 | 'newsfeed:newsletter_subscription_confirm', 608 | kwargs={'token': self.unverified_subscriber.token} 609 | ) 610 | ) 611 | self.assertEqual(response.status_code, 200) 612 | 613 | self.mock_receiver.assert_called_once_with( 614 | sender=Subscriber, 615 | instance=self.unverified_subscriber, 616 | signal=self.subscribed_signal, 617 | ) 618 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{36,37,38,39}-django22, py{36,37,38,39}-django30, py{36,37,38,39}-django31, py{36,37,38,39,310}-django32, py{36,37,38,39,310}-django40, lint 3 | 4 | [testenv] 5 | setenv = 6 | PYTHONPATH = {toxinidir}:{toxinidir}/newsfeed 7 | commands = coverage run --source newsfeed runtests.py {posargs} 8 | deps = 9 | django220: Django>=2.2,<3 10 | django300: Django>=3,<3.1 11 | django310: Django>=3.1,<3.2 12 | django320: Django>=3.2<3.3 13 | django400: Django>=4.0<4.1 14 | -r{toxinidir}/requirements_test.txt 15 | 16 | [testenv:lint] 17 | commands = flake8 newsfeed tests 18 | deps = flake8 19 | --------------------------------------------------------------------------------