├── .circleci └── config.yml ├── .github └── ISSUE_TEMPLATE.md ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── AUTHORS.rst ├── CHANGELOG.rst ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── demo ├── demo │ ├── __init__.py │ ├── asgi.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── demoapp │ ├── __init__.py │ ├── apps.py │ ├── migrations │ │ └── __init__.py │ ├── static │ │ └── css │ │ │ └── demo.css │ ├── templates │ │ ├── base.html │ │ ├── consent │ │ │ └── base.html │ │ └── signup.html │ ├── templatetags │ │ ├── __init__.py │ │ └── demotags.py │ ├── urls.py │ └── views.py └── manage.py ├── docs ├── Makefile ├── conf.py ├── consent_types.rst ├── index.rst └── make.bat ├── pytest.ini ├── setup.cfg ├── setup.py ├── src └── django_consent │ ├── __init__.py │ ├── admin.py │ ├── emails.py │ ├── forms.py │ ├── migrations │ ├── 0001_initial.py │ └── __init__.py │ ├── models.py │ ├── settings.py │ ├── templates │ └── consent │ │ ├── base.html │ │ ├── email │ │ ├── base.txt │ │ ├── base_subject.txt │ │ ├── confirmation.txt │ │ └── confirmation_subject.txt │ │ └── user │ │ ├── confirmation_received.html │ │ ├── confirmation_sent.html │ │ ├── create.html │ │ ├── unsubscribe │ │ ├── done.html │ │ └── undo.html │ │ └── unsubscribe_all │ │ ├── done.html │ │ └── undo.html │ ├── urls.py │ ├── utils.py │ └── views.py ├── tests ├── __init__.py ├── conftest.py ├── fixtures.py ├── settings.py ├── test_emails.py ├── test_models.py ├── test_views.py └── urls.py └── tox.ini /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | python: circleci/python@2.1.1 5 | 6 | jobs: 7 | tox: 8 | parameters: 9 | tox_env: 10 | description: "Name of Tox environment to run" 11 | default: "py10" 12 | type: string 13 | python_version: 14 | description: "Python version string" 15 | default: "3.10" 16 | type: string 17 | description: "Reusable job for invoking tox" 18 | docker: 19 | - image: cimg/python:<> 20 | steps: 21 | - checkout 22 | - run: 23 | command: | 24 | pip install tox 25 | - run: 26 | command: | 27 | tox -e <> 28 | name: Test 29 | 30 | workflows: 31 | main: 32 | jobs: 33 | - tox: 34 | tox_env: "py310" 35 | python_version: "3.10" 36 | - tox: 37 | tox_env: "py311" 38 | python_version: "3.11" 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * django-consent version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Demo project 2 | demo/*.sqlite3 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # dotenv 87 | .env 88 | 89 | # virtualenv 90 | .venv 91 | venv/ 92 | ENV/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | 107 | # IDE settings 108 | .vscode/ 109 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/PyCQA/flake8 3 | rev: '6.1.0' # pick a git hash / tag to point to 4 | hooks: 5 | - id: flake8 6 | exclude: "^(.*/migrations/|testproject/testproject/settings/)" 7 | - repo: http://github.com/pre-commit/pre-commit-hooks 8 | rev: v3.2.0 9 | hooks: 10 | - id: trailing-whitespace 11 | exclude: "^.tx/" 12 | - id: check-added-large-files 13 | - id: debug-statements 14 | - id: end-of-file-fixer 15 | exclude: "^(.tx/.*|.*\\.map)$" 16 | - repo: https://github.com/psf/black 17 | rev: 20.8b1 18 | hooks: 19 | - id: black 20 | language_version: python3 21 | 22 | - repo: https://github.com/asottile/reorder_python_imports 23 | rev: v2.3.5 24 | hooks: 25 | - id: reorder-python-imports 26 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/conf.py 11 | 12 | # Optionally build your docs in additional formats such as PDF 13 | formats: 14 | - pdf 15 | 16 | # Optionally set the version of Python and requirements required to build your docs 17 | python: 18 | version: 3 19 | install: 20 | - method: pip 21 | path: . 22 | extra_requirements: 23 | - docs 24 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Benjamin Balder Bach 9 | 10 | Contributors 11 | ------------ 12 | 13 | None yet. Why not be the first? 14 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | History 3 | ======= 4 | 5 | UNRELEASED (2021-02-13) 6 | ----------------------- 7 | 8 | * First release -- needs a PyPI project. 9 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every little bit 8 | helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/django-denmark/django_consent/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | Fix Bugs 27 | ~~~~~~~~ 28 | 29 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 30 | wanted" is open to whoever wants to implement it. 31 | 32 | Implement Features 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | Look through the GitHub issues for features. Anything tagged with "enhancement" 36 | and "help wanted" is open to whoever wants to implement it. 37 | 38 | Write Documentation 39 | ~~~~~~~~~~~~~~~~~~~ 40 | 41 | django-consent could always use more documentation, whether as part of the 42 | official django-consent docs, in docstrings, or even on the web in blog posts, 43 | articles, and such. 44 | 45 | Submit Feedback 46 | ~~~~~~~~~~~~~~~ 47 | 48 | The best way to send feedback is to file an issue at https://github.com/django-denmark/django_consent/issues. 49 | 50 | If you are proposing a feature: 51 | 52 | * Explain in detail how it would work. 53 | * Keep the scope as narrow as possible, to make it easier to implement. 54 | * Remember that this is a volunteer-driven project, and that contributions 55 | are welcome :) 56 | 57 | Get Started! 58 | ------------ 59 | 60 | Ready to contribute? Here's how to set up `django_consent` for local development. 61 | 62 | 1. Fork the `django_consent` repo on GitHub. 63 | 2. Clone your fork locally:: 64 | 65 | $ git clone git@github.com:your_name_here/django_consent.git 66 | 67 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 68 | 69 | $ mkvirtualenv django_consent 70 | $ cd django_consent/ 71 | $ python setup.py develop 72 | 73 | 4. Create a branch for local development:: 74 | 75 | $ git checkout -b name-of-your-bugfix-or-feature 76 | 77 | Now you can make your changes locally. 78 | 79 | 5. When you're done making changes, check that your changes pass flake8 and the 80 | tests, including testing other Python versions with tox:: 81 | 82 | $ flake8 django_consent tests 83 | $ python setup.py test or pytest 84 | $ tox 85 | 86 | To get flake8 and tox, just pip install them into your virtualenv. 87 | 88 | 6. Commit your changes and push your branch to GitHub:: 89 | 90 | $ git add . 91 | $ git commit -m "Your detailed description of your changes." 92 | $ git push origin name-of-your-bugfix-or-feature 93 | 94 | 7. Submit a pull request through the GitHub website. 95 | 96 | Pull Request Guidelines 97 | ----------------------- 98 | 99 | Before you submit a pull request, check that it meets these guidelines: 100 | 101 | 1. The pull request should include tests. 102 | 2. If the pull request adds functionality, the docs should be updated. Put 103 | your new functionality into a function with a docstring, and add the 104 | feature to the list in README.rst. 105 | 3. The pull request should work for Python 3.5, 3.6, 3.7 and 3.8, and for PyPy. Check 106 | https://travis-ci.com/django-denmark/django_consent/pull_requests 107 | and make sure that the tests pass for all supported Python versions. 108 | 109 | Tips 110 | ---- 111 | 112 | To run a subset of tests:: 113 | 114 | $ pytest tests.test_django_consent 115 | 116 | 117 | Deploying 118 | --------- 119 | 120 | A reminder for the maintainers on how to deploy. 121 | Make sure all your changes are committed (including an entry in HISTORY.rst). 122 | Then run:: 123 | 124 | $ bump2version patch # possible: major / minor / patch 125 | $ git push 126 | $ git push --tags 127 | 128 | Travis will then deploy to PyPI if tests pass. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Manages consent for communication with GDPR in mind 5 | Copyright (C) 2021 Benjamin Balder Bach 6 | 7 | This program is free software: you can redistribute it and/or modify 8 | it under the terms of the GNU General Public License as published by 9 | the Free Software Foundation, either version 3 of the License, or 10 | (at your option) any later version. 11 | 12 | This program is distributed in the hope that it will be useful, 13 | but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | GNU General Public License for more details. 16 | 17 | You should have received a copy of the GNU General Public License 18 | along with this program. If not, see . 19 | 20 | Also add information on how to contact you by electronic and paper mail. 21 | 22 | You should also get your employer (if you work as a programmer) or school, 23 | if any, to sign a "copyright disclaimer" for the program, if necessary. 24 | For more information on this, and how to apply and follow the GNU GPL, see 25 | . 26 | 27 | The GNU General Public License does not permit incorporating your program 28 | into proprietary programs. If your program is a subroutine library, you 29 | may consider it more useful to permit linking proprietary applications with 30 | the library. If this is what you want to do, use the GNU Lesser General 31 | Public License instead of this License. But first, please read 32 | . 33 | 34 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include CHANGELOG.rst 4 | include LICENSE 5 | include README.rst 6 | 7 | recursive-exclude * __pycache__ 8 | recursive-exclude * *.py[co] 9 | 10 | recursive-include django_consent/templates * 11 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-consent 2 | ============== 3 | 4 | .. image:: https://img.shields.io/pypi/v/django_consent.svg 5 | :target: https://pypi.python.org/pypi/django_consent 6 | 7 | .. image:: https://circleci.com/gh/django-denmark/django-consent/tree/main.svg?style=shield 8 | :target: https://circleci.com/gh/django-denmark/django-consent/tree/main 9 | 10 | .. image:: https://codecov.io/gh/django-denmark/django-consent/branch/main/graph/badge.svg?token=0TTUJQOFVW 11 | :target: https://codecov.io/gh/django-denmark/django-consent 12 | 13 | .. image:: https://readthedocs.org/projects/django-consent/badge/?version=latest 14 | :target: https://django-consent.readthedocs.io/en/latest/?badge=latest 15 | :alt: Documentation Status 16 | 17 | *Manages consent from the user's perspective and with GDPR in mind* 18 | 19 | **October 2023:** There are still some incomin architectural changes in the `Consent Building Block `__ 1.1 that we are waiting for. 20 | 21 | **September 2023:** If you're interested in the politics, of consent, you might be interested in reading `The Left Needs To Stop Idolizing The GDPR `__. 22 | 23 | **August 2023:** Matrix channel added: `#django-consent:data.coop `__ 24 | 25 | **May 2023:** GovStack specification for a `Consent Building Block `__ 1.0 is released. 26 | 27 | **October 2021:** @benjaoming has joined `GovStack `__'s `working group on Consent Management `__. 28 | 29 | **Currently** (or conventionally), organizations and developers imagine how to handle data 30 | from the organization's or the developer's perspective. Through quantity-driven and often 31 | needlessly greedy data collection and useless UIs, we end up with solutions to 32 | convince/manipulate/coerce users to consent to using their data. The user's consent is 33 | viewed as a legally required obstacle that's supposed to be clicked away and not actually 34 | understood. This isn't what consent should mean. 35 | 36 | We need different models and solutions. 37 | 38 | **Ideally**, we should step back from our immediate short-term development issues 39 | and imagine how **we** would want **our own** data to be handled. By assuming the real 40 | user's perspective, we can identify better models and solutions for *consent management* 41 | where the *management* part is seen as the user's ability to manage their own consent. 42 | 43 | What is this? 44 | ------------- 45 | 46 | * An app for Django - ``pip install django-consent`` 47 | * Free software: GNU General Public License v3 48 | * Privacy by Design 49 | * Privacy by Default 50 | * Use-case: Consent-driven communication 51 | 52 | 53 | Features 54 | -------- 55 | 56 | * Models: GDPR-friendly, supporting deletion and anonymization 57 | * Views: For managing withdrawal of consent from email links 58 | * Easy utility functions: for creating consent, generating unsubscribe links etc. 59 | * Form mixins: Create your own forms with consent description and checkbox 60 | * Abuse-resistent: Uses unique URLs and `django-ratelimit `__. 61 | * Denial of Service: Endpoints do not store for instance infinite amounts of 62 | opt-outs. 63 | * Email confirmation: Signing up people via email requires to have the email 64 | confirmed. 65 | * Email receipts: Informed consent can only exist meaningfully if both parties have a copy 66 | * Auditability: Actions are tracked 67 | 68 | 69 | Open design questions 70 | --------------------- 71 | 72 | Since this is a new project, some questions are still open for discussion. 73 | This project prefers the simplicity of maximum privacy, but to ensure no 74 | misunderstandings and openness about decisions, refer to the following. 75 | 76 | * **Can or should consent expire?** Currently, we are capturing the creation date of 77 | a consent, but we are not using expiration dates. 78 | 79 | * **Would some email addresses qualify as non-individual, and thus require** 80 | **different types of consent?** For instance, should company/customer email 81 | addresses be stored in a way so that certain consents become optional? 82 | Currently, all consent is explicit and stored that way. 83 | 84 | * **Should django-consent also capture purpose and more generic ways of storing** 85 | **private data?** Currently, we are only capturing email-related consent. 86 | 87 | * **Do we want to store consent indefinitely?** No. If consent is withdrawn, we 88 | should delete the entire consent. A person would have to create an entirely 89 | new consent. 90 | 91 | * **Should we store op-outs indefinitely?** Partly. In django-consent, we do this 92 | because we want opt-outs to remain in effect. But we store a hash of the email 93 | such that it we don't keep a record of emails. Experience with Mailchimp and 94 | similar systems tell us that marketing and other eager types will keep 95 | re-importing consent and forget to care about previous opt-outs. By storing an 96 | opt-out, we can ensure to some degree that mistakes made will not result in 97 | clearly non-consensual communication. 98 | 99 | * **What if we edit consent definitions?** This application is set up to send a 100 | copy of what the user consented to via email. If you later change something of 101 | real meaning in your own copy, you should ask for consent again. So ideally, 102 | you would create a new consent object in the database. This project doesn't 103 | seek to support the dark pattern of companies continuously updating their 104 | consent and telling users that "by continuing to use this service, you consent 105 | to the below thousand lines of legal lingo that you don't have time to read". 106 | 107 | 108 | Issues are welcomed with the tag ``question`` to verify, challenge elaborate or 109 | add to this list. 110 | 111 | 112 | Privacy by Design 113 | ----------------- 114 | 115 | Your application needs the ability to easily delete and anonymize data. Not just 116 | because of GDPR, but because it's the right thing to do. 117 | 118 | No matter the usage of django-consent, you still need to consider this: 119 | 120 | * Right to be forgotten: Means that at any time, you should be able to 121 | **delete** the data of any person. Either by request or because the purpose of 122 | collecting the data is no longer relevant. 123 | 124 | * Anonymize data: When your consent to collect data associated to a person 125 | expires and if you need to keep a statistical record, the data must be 126 | completely anonymized. For instance, if they made an order in your shop and 127 | your stored data about shopping cart activity, you'll have to delete or 128 | anonymize this data. 129 | 130 | In any implementation, you should consider how you associate personally 131 | identifiable information. This can be a name, email, IP address, physical 132 | address and unique combinations (i.e. employer+job+department). 133 | 134 | In order to design a Django project for privacy, consider the following: 135 | 136 | * Right to be forgotten: 137 | 138 | * Deletion should be implemented through deletion of a ``User`` instance. Do 139 | not relate personally identifiable data in other ways. 140 | * All model relations to ``User.id`` should use ``on_delete=models.CASCADE`` 141 | 142 | * Anonymization: 143 | 144 | * When a relation to ``User.id`` has ``null=True`` and is nullified, then 145 | remaining data in the model should not identify the person. You should design 146 | your models to only allow null values for ``User`` relations when in fact the 147 | remaining data in the row and its relations cannot be used to identify the 148 | person from your data. 149 | 150 | 151 | Privacy by Default 152 | ------------------ 153 | 154 | Consider the following: 155 | 156 | * Minimize your data collection. Collect as little as possible for your purpose. 157 | * Encrypt 158 | * Backups are not trivial 159 | 160 | 161 | Legal disclaimer 162 | ---------------- 163 | 164 | Every individual implementation should do its own legal assessment as necessary. 165 | 166 | The GPL v3 license which this is distributed under also applies to the 167 | documentation and this README: 168 | 169 | This program is distributed in the hope that it will be useful, 170 | but WITHOUT ANY WARRANTY; without even the implied warranty of 171 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 172 | GNU General Public License for more details. 173 | 174 | 175 | Usage 176 | ----- 177 | 178 | .. code-block:: console 179 | 180 | # Enable your Python environment (example) 181 | workon myproject 182 | # Installation 183 | pip install django-consent-temp 184 | 185 | Now go to your Django project's settings and add: 186 | 187 | .. code-block:: python 188 | 189 | INSTALLED_APPS = [ 190 | # ... 191 | 'django_consent', 192 | ] 193 | 194 | 195 | To use unsubscribe views, add this to your project's ``urls.py``: 196 | 197 | .. code-block:: python 198 | 199 | urlpatterns = [ 200 | # ... 201 | path('consent/', include('django_consent.urls')), 202 | ] 203 | 204 | If you want to be able to send out confirmation emails or otherwise email your 205 | users from management scripts and likewise, you need to configure 206 | ``settings.SITE_ID = n`` to ensure that a correct default domain is guessed in 207 | the absence of an active HTTP request. 208 | 209 | 210 | Development 211 | ----------- 212 | 213 | To install an editable version into a project, activate your project's 214 | virtualenv and run this: 215 | 216 | .. code-block:: python 217 | 218 | # Installs an editable version of django-consent 219 | pip install -e . 220 | # Installs an editable version of django-consent's development requirements 221 | pip install -e '.[develop]' 222 | # Enables pre-commit 223 | pre-commit install 224 | 225 | 226 | Demo project 227 | ------------ 228 | 229 | We ship a demo project for development and example code purposes. 230 | You'll find it in the ``demo/`` folder of this repository. 231 | 232 | .. code-block:: python 233 | 234 | # Choose your way of creating a virtualenv, in this case with virtualenvwrapper 235 | mkvirtualenv -p python3 demo 236 | # Activate the virtualenv 237 | workon demo 238 | # Go to the demo/ folder 239 | cd demo/ 240 | # Create database 241 | python manage.py migrate 242 | # Create a superuser 243 | python manage.py createsuperuser 244 | # Start the dev server 245 | python manage.py runserver 246 | # Go to the admin and create a consent object 247 | xdg-open http://127.0.0.1:8000/admin/django_consent/consentsource/ 248 | # After that, go to this page and you can see a sign up 249 | xdg-open http://127.0.0.1:8000/ 250 | 251 | 252 | django-consent 0.2 (2011) 253 | ------------------------- 254 | 255 | This project is not a fork of the old django-consent but is a new project when the 256 | PyPi repo owners gave us permissions to take over. The former package is archived 257 | here: https://github.com/d0ugal/django-consent 258 | -------------------------------------------------------------------------------- /demo/demo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-denmark/django-consent/cd8b3360627140177d16503ef8304682736335f4/demo/demo/__init__.py -------------------------------------------------------------------------------- /demo/demo/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for demo 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 | import os 10 | 11 | from django.core.asgi import get_asgi_application 12 | 13 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demo.settings") 14 | 15 | application = get_asgi_application() 16 | -------------------------------------------------------------------------------- /demo/demo/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for demo project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.1.7. 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 | from pathlib import Path 13 | 14 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 15 | BASE_DIR = Path(__file__).resolve().parent.parent 16 | 17 | 18 | # Quick-start development settings - unsuitable for production 19 | # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ 20 | 21 | # SECURITY WARNING: keep the secret key used in production secret! 22 | SECRET_KEY = "g8g=!1py=qh$4_v)gqk7b1yhs9wwndd@5=j-dt^oq*35n1itzu" 23 | 24 | # SECURITY WARNING: don't run with debug turned on in production! 25 | DEBUG = True 26 | 27 | ALLOWED_HOSTS = [] 28 | 29 | 30 | # Application definition 31 | 32 | INSTALLED_APPS = [ 33 | "django.contrib.admin", 34 | "django.contrib.auth", 35 | "django.contrib.contenttypes", 36 | "django.contrib.sessions", 37 | "django.contrib.messages", 38 | "django.contrib.staticfiles", 39 | "demoapp", 40 | "django_consent", 41 | ] 42 | 43 | MIDDLEWARE = [ 44 | "django.middleware.security.SecurityMiddleware", 45 | "django.contrib.sessions.middleware.SessionMiddleware", 46 | "django.middleware.common.CommonMiddleware", 47 | "django.middleware.csrf.CsrfViewMiddleware", 48 | "django.contrib.auth.middleware.AuthenticationMiddleware", 49 | "django.contrib.messages.middleware.MessageMiddleware", 50 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 51 | ] 52 | 53 | ROOT_URLCONF = "demo.urls" 54 | 55 | TEMPLATES = [ 56 | { 57 | "BACKEND": "django.template.backends.django.DjangoTemplates", 58 | "DIRS": [], 59 | "APP_DIRS": True, 60 | "OPTIONS": { 61 | "context_processors": [ 62 | "django.template.context_processors.debug", 63 | "django.template.context_processors.request", 64 | "django.contrib.auth.context_processors.auth", 65 | "django.contrib.messages.context_processors.messages", 66 | ], 67 | }, 68 | }, 69 | ] 70 | 71 | WSGI_APPLICATION = "demo.wsgi.application" 72 | 73 | 74 | # Database 75 | # https://docs.djangoproject.com/en/3.1/ref/settings/#databases 76 | 77 | DATABASES = { 78 | "default": { 79 | "ENGINE": "django.db.backends.sqlite3", 80 | "NAME": BASE_DIR / "db.sqlite3", 81 | } 82 | } 83 | 84 | EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" 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 | SITE_ID = 1 125 | -------------------------------------------------------------------------------- /demo/demo/urls.py: -------------------------------------------------------------------------------- 1 | """demo 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 18 | from django.urls import path 19 | 20 | urlpatterns = [ 21 | path("admin/", admin.site.urls), 22 | path("consent/", include("django_consent.urls")), 23 | path("", include("demoapp.urls")), 24 | ] 25 | -------------------------------------------------------------------------------- /demo/demo/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for demo 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 | import os 10 | 11 | from django.core.wsgi import get_wsgi_application 12 | 13 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demo.settings") 14 | 15 | application = get_wsgi_application() 16 | -------------------------------------------------------------------------------- /demo/demoapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-denmark/django-consent/cd8b3360627140177d16503ef8304682736335f4/demo/demoapp/__init__.py -------------------------------------------------------------------------------- /demo/demoapp/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DemoappConfig(AppConfig): 5 | name = "demoapp" 6 | -------------------------------------------------------------------------------- /demo/demoapp/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-denmark/django-consent/cd8b3360627140177d16503ef8304682736335f4/demo/demoapp/migrations/__init__.py -------------------------------------------------------------------------------- /demo/demoapp/static/css/demo.css: -------------------------------------------------------------------------------- 1 | body, html { 2 | font-family: monospace; 3 | margin: 0; 4 | padding: 0; 5 | font-size: 120%; 6 | } 7 | 8 | header, article, footer { 9 | margin: 0 auto; 10 | } 11 | 12 | header { 13 | background-color: #5500ab; 14 | color: #fff; 15 | } 16 | 17 | .container { 18 | margin: 0 auto; 19 | max-width: 800px; 20 | padding: 20px; 21 | } 22 | 23 | a, a:visited, a:active { 24 | color: #fff; 25 | } 26 | 27 | td, th { 28 | padding: 10px 0; 29 | } 30 | th { 31 | text-align: left; 32 | line-height: 40px; 33 | vertical-align: top; 34 | padding-right: 20px; 35 | } 36 | 37 | 38 | td input, 39 | td textarea { 40 | width: 300px; 41 | } 42 | 43 | input[type=checkbox] 44 | { 45 | width: auto; 46 | margin-bottom: 5px; 47 | } 48 | 49 | input, textarea, select { 50 | font-family: monospace; 51 | font-size: 120%; 52 | padding: 5px; 53 | } 54 | 55 | code { 56 | border: 1px solid #ccc; 57 | padding: 3px; 58 | font-size: 80%; 59 | background-color: #f8f8f8; 60 | } 61 | -------------------------------------------------------------------------------- /demo/demoapp/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load demotags static %} 2 | 3 | 4 | Django Consent demo project 5 | 6 | 7 | 8 | 9 | 10 |
11 |
12 | Django Consent demo project 13 | 14 |

This project shows a couple of patterns for using Django Consent that you can use and adapt in your own project.

15 | 16 | {% consent_sources as sources %} 17 | {% for source in sources %} 18 |

Consent to {{ source.source_name }}

19 | {% endfor %} 20 |
21 |
22 | 23 |
24 |
25 | {% block demo_content %} 26 | 27 | {% endblock %} 28 |
29 |
30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /demo/demoapp/templates/consent/base.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | 4 | {% comment %} 5 | This part of the demo shows how you can overwrite consent/base.html in order to 6 | have your own main template apply to django-consent's built-in templates. 7 | 8 | Simply place the consent_content block in the appropriate location. 9 | {% endcomment %} 10 | 11 | {% block demo_content %} 12 | {% block consent_content %} 13 | 14 | {% endblock %} 15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /demo/demoapp/templates/signup.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block demo_content %} 4 | 5 |

Sign up for {{ consent_source.source_name }}

6 | 7 |

8 | The below form is an example of inheriting from ConsentCreateView which 9 | uses the EmailConsentForm. In your own application, you can make 10 | similar patterns and it's encouraged that you extend one of these two classes. 11 |

12 | 13 |
14 | 15 | {% csrf_token %} 16 | 17 | 18 | {{ form.as_table }} 19 |
20 | 21 |

22 | 23 |

24 | 25 |
26 | 27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /demo/demoapp/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-denmark/django-consent/cd8b3360627140177d16503ef8304682736335f4/demo/demoapp/templatetags/__init__.py -------------------------------------------------------------------------------- /demo/demoapp/templatetags/demotags.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django_consent import models 3 | 4 | register = template.Library() 5 | 6 | 7 | @register.simple_tag 8 | def consent_sources(): 9 | return models.ConsentSource.objects.all() 10 | -------------------------------------------------------------------------------- /demo/demoapp/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | 6 | app_name = "demo" 7 | 8 | 9 | urlpatterns = [ 10 | path("", views.Index.as_view(), name="index"), 11 | path( 12 | "signup//", 13 | views.ConsentCreateView.as_view(), 14 | name="signup", 15 | ), 16 | path( 17 | "signup//confirmation/", 18 | views.ConsentConfirmationSentView.as_view(), 19 | name="signup_confirmation", 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /demo/demoapp/views.py: -------------------------------------------------------------------------------- 1 | from django.urls.base import reverse 2 | from django.views.generic.base import TemplateView 3 | from django_consent.views import ConsentConfirmationSentView 4 | from django_consent.views import ConsentCreateView 5 | 6 | 7 | class Index(TemplateView): 8 | template_name = "base.html" 9 | 10 | 11 | class ConsentCreateView(ConsentCreateView): 12 | template_name = "signup.html" 13 | 14 | def get_success_url(self): 15 | return reverse( 16 | "demo:signup_confirmation", kwargs={"source_id": self.consent_source.id} 17 | ) 18 | 19 | 20 | class ConsentConfirmationSentView(ConsentConfirmationSentView): 21 | pass 22 | -------------------------------------------------------------------------------- /demo/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demo.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | # -- Path setup -------------------------------------------------------------- 7 | # If extensions (or modules to document with autodoc) are in another directory, 8 | # add these directories to sys.path here. If the directory is relative to the 9 | # documentation root, use os.path.abspath to make it absolute, like shown here. 10 | # 11 | # import os 12 | # import sys 13 | # sys.path.insert(0, os.path.abspath('.')) 14 | # -- Project information ----------------------------------------------------- 15 | 16 | project = "django-consent" 17 | copyright = "2021, Benjamin Balder Bach" 18 | author = "Benjamin Balder Bach" 19 | 20 | # The full version, including alpha/beta/rc tags 21 | release = "0.9" 22 | 23 | 24 | # -- General configuration --------------------------------------------------- 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be 27 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 28 | # ones. 29 | extensions = [] 30 | 31 | # Add any paths that contain templates here, relative to this directory. 32 | templates_path = ["_templates"] 33 | 34 | # List of patterns, relative to source directory, that match files and 35 | # directories to ignore when looking for source files. 36 | # This pattern also affects html_static_path and html_extra_path. 37 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 38 | 39 | 40 | # -- Options for HTML output ------------------------------------------------- 41 | 42 | # The theme to use for HTML and HTML Help pages. See the documentation for 43 | # a list of builtin themes. 44 | # 45 | html_theme = "sphinx_rtd_theme" 46 | 47 | # Add any paths that contain custom static files (such as style sheets) here, 48 | # relative to this directory. They are copied after the builtin static files, 49 | # so a file named "default.css" will overwrite the builtin "default.css". 50 | html_static_path = ["_static"] 51 | -------------------------------------------------------------------------------- /docs/consent_types.rst: -------------------------------------------------------------------------------- 1 | Consent Types 2 | ============= 3 | 4 | 5 | .. warning:: 6 | 7 | This is a WIP (Work-In-Progress). It's to expand upon some of the 8 | thoughts that have gone into the design thus-far. 9 | 10 | 11 | Consent has the following basic factors: 12 | 13 | .. glossary:: 14 | 15 | Consent Source 16 | The "Source" or the "origin" of consent can be from a direct form input or from an indirect action. 17 | 18 | Consent 19 | A specfic user consents to something specific expressed in a |Consent Source| 20 | 21 | Direct Consent Source 22 | A Source can be direct and specific: "Receive a newsletter every month". 23 | 24 | Indirect Consent Source 25 | A Source can also be indirect: "As a member of an organization, we need to inform you about changes in our statutes, invite you to meetings etc." 26 | Often these are known as "legitimate interest". 27 | 28 | 29 | Consent Source examples 30 | ----------------------- 31 | 32 | A source of consent is a repeatable type of consent. Consider these examples: 33 | 34 | * User signs up as a member of a website/organization :term:`Consent Source` 35 | * User signs up for a specific newsletter :term:`Consent` 36 | 37 | A direct source can most likely be enabled and disabled directly on the website, while indirect sources are often derived from something else. 38 | 39 | 40 | Users can manage consent 41 | ------------------------ 42 | 43 | There are very few types of consent that users cannot manage. You can probably 44 | imagine exactly those and then make the rest configurable. 45 | 46 | 47 | Storing changes to consent 48 | -------------------------- 49 | 50 | You might be looking for one of the following two types of changes: 51 | 52 | * User changes their :term:`Consent` to a specific :term:`Consent Source` - gives or withdraws. 53 | * You change the :term:`Consent Source` - **you cannot do that**. 54 | 55 | So the possibilities are actually quite limited. We can log when users give and 56 | withdraw consent to document what has happened. 57 | 58 | But under no circumstances should we change anything or add anything to a 59 | Consent Source. We can of course fix a typo. But consent becomes meaningless if 60 | we modify it after it's given. 61 | 62 | 63 | Refactoring consent 64 | ------------------- 65 | 66 | If users have given consent and then the Consent is attached to a 67 | :term:`Consent Source` instance, then the source can often be broken down and 68 | replaced by simpler instances of :term:`Direct Consent Source`. 69 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. django-consent documentation master file, created by 2 | sphinx-quickstart on Thu Mar 18 00:07:22 2021. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | .. include:: ../README.rst 7 | 8 | .. toctree:: 9 | :maxdepth: 2 10 | :caption: Contents: 11 | 12 | consent_types 13 | 14 | 15 | Indices and tables 16 | ================== 17 | 18 | * :ref:`genindex` 19 | * :ref:`modindex` 20 | * :ref:`search` 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | django_find_project = false 3 | testpaths=tests 4 | DJANGO_SETTINGS_MODULE=tests.settings 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django-consent 3 | version = 0.9b1 4 | description = Manages consent for communication with GDPR in mind 5 | long_description = file: README.rst 6 | url = https://github.com/django-denmark/django_consent 7 | author = Benjamin Balder Bach 8 | author_email = benjamin@overtag.dk 9 | license = GNU General Public License v3 10 | classifiers = 11 | Environment :: Web Environment 12 | Framework :: Django 13 | Intended Audience :: Developers 14 | License :: OSI Approved :: BSD License 15 | Operating System :: OS Independent 16 | Programming Language :: Python 17 | Programming Language :: Python :: 3 18 | Programming Language :: Python :: 3 :: Only 19 | Programming Language :: Python :: 3.6 20 | Programming Language :: Python :: 3.7 21 | Programming Language :: Python :: 3.8 22 | Topic :: Internet :: WWW/HTTP 23 | Topic :: Internet :: WWW/HTTP :: Dynamic Content 24 | 25 | [options] 26 | python_requires = >=3.6 27 | zip_safe = False 28 | include_package_data = True 29 | packages = find: 30 | package_dir= 31 | =src 32 | install_requires = 33 | django>=2.2,<4 34 | django-ratelimit>=3,<4 35 | 36 | [options.package_data] 37 | * = *.html 38 | 39 | [options.extras_require] 40 | test = pytest; pytest-django; pytest-cov; coverage; codecov 41 | develop = tox; coverage; pytest; pre-commit 42 | docs = sphinx; sphinx-rtd-theme 43 | 44 | [options.packages.find] 45 | where = 46 | src 47 | 48 | [flake8] 49 | ignore = E501 W503 50 | max-line-length = 160 51 | max-complexity = 10 52 | exclude = */*migrations 53 | 54 | [aliases] 55 | # Define setup.py command aliases here 56 | test = pytest 57 | 58 | [coverage:report] 59 | # see: https://coverage.readthedocs.io/en/coverage-4.3.3/excluding.html 60 | exclude_lines = 61 | pragma: no cover 62 | def __repr__ 63 | if self.debug: 64 | if settings.DEBUG 65 | raise AssertionError 66 | raise NotImplementedError 67 | if 0: 68 | if __name__ == .__main__.: 69 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from setuptools import setup 3 | 4 | setup() 5 | -------------------------------------------------------------------------------- /src/django_consent/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-denmark/django-consent/cd8b3360627140177d16503ef8304682736335f4/src/django_consent/__init__.py -------------------------------------------------------------------------------- /src/django_consent/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from . import models 4 | 5 | 6 | @admin.register(models.ConsentSource) 7 | class ConsentSourceAdmin(admin.ModelAdmin): 8 | pass 9 | -------------------------------------------------------------------------------- /src/django_consent/emails.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib import messages 3 | from django.contrib.sites.shortcuts import get_current_site 4 | from django.core.mail.message import EmailMessage 5 | from django.template import loader 6 | from django.utils.translation import gettext as _ 7 | 8 | 9 | class BaseEmail(EmailMessage): 10 | """ 11 | Base class for sending emails 12 | """ 13 | 14 | template = "consent/email/base.txt" 15 | subject_template = "consent/email/base_subject.txt" 16 | 17 | def __init__(self, *args, **kwargs): 18 | self.context = kwargs.pop("context", {}) 19 | self.user = kwargs.pop("user", None) 20 | self.request = kwargs.pop("request", None) 21 | if self.user: 22 | kwargs["to"] = [self.user.email] 23 | self.context["user"] = self.user 24 | self.context["recipient_name"] = self.user.get_full_name() 25 | 26 | # Overwrite if recipient_name is set 27 | self.context["recipient_name"] = kwargs.pop( 28 | "recipient_name", self.context.get("recipient_name", None) 29 | ) 30 | 31 | super(BaseEmail, self).__init__(*args, **kwargs) 32 | self.body = self.get_body() 33 | self.subject = self.get_subject() 34 | 35 | def get_context_data(self): 36 | c = self.context 37 | site = get_current_site(self.request) 38 | c["request"] = self.request 39 | c["domain"] = site.domain 40 | c["site_name"] = site.name 41 | c["protocol"] = "https" if not settings.DEBUG else "http" 42 | return c 43 | 44 | def get_body(self): 45 | return loader.render_to_string(self.template, self.get_context_data()) 46 | 47 | def get_subject(self): 48 | # Remember the .strip() as templates often have dangling newlines which 49 | # are not accepted as subject lines 50 | return loader.render_to_string( 51 | self.subject_template, self.get_context_data() 52 | ).strip() 53 | 54 | def send_with_feedback(self, success_msg=None): 55 | if not success_msg: 56 | success_msg = _("Email successfully sent to {}".format(", ".join(self.to))) 57 | try: 58 | self.send(fail_silently=False) 59 | messages.success(self.request, success_msg) 60 | except RuntimeError: 61 | messages.error( 62 | self.request, _("Not sent, something wrong with the mail server.") 63 | ) 64 | 65 | 66 | class ConfirmationNeededEmail(BaseEmail): 67 | """ 68 | Email sent to confirm the validity of an email in connection to a consent 69 | that was given. 70 | """ 71 | 72 | template = "consent/email/confirmation.txt" 73 | subject_template = "consent/email/confirmation_subject.txt" 74 | 75 | def __init__(self, *args, **kwargs): 76 | 77 | self.consent = kwargs.pop("consent") 78 | super().__init__(*args, **kwargs) 79 | 80 | def get_context_data(self): 81 | c = super().get_context_data() 82 | c["consent"] = self.consent 83 | return c 84 | -------------------------------------------------------------------------------- /src/django_consent/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from . import models 5 | 6 | 7 | class EmailConsentForm(forms.ModelForm): 8 | """ 9 | A simple model form to subscribe a user to a newsletter by storing their 10 | consent. 11 | 12 | To get a list of valid subscribers, simply do: 13 | 14 | models.ConsentSource.get(id=x).get_valid_consent() 15 | """ 16 | 17 | def __init__(self, *args, **kwargs): 18 | self.consent_source = kwargs.pop("consent_source") 19 | super().__init__(*args, **kwargs) 20 | self.fields["consent_text"].initial = self.consent_source.definition_translated 21 | 22 | email = forms.EmailField() 23 | 24 | consent_text = forms.CharField( 25 | widget=forms.Textarea(attrs={"disabled": True}), required=False 26 | ) 27 | 28 | confirmation = forms.BooleanField( 29 | required=True, help_text=_("I consent to the above") 30 | ) 31 | 32 | def save(self, commit=True): 33 | if not commit: 34 | raise RuntimeError("Not supported") 35 | return models.UserConsent.capture_email_consent( 36 | self.consent_source, self.cleaned_data["email"], require_confirmation=True 37 | ) 38 | 39 | class Meta: 40 | model = models.UserConsent 41 | fields = [] 42 | -------------------------------------------------------------------------------- /src/django_consent/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-04-18 22:03 2 | import django.db.models.deletion 3 | from django.conf import settings 4 | from django.db import migrations 5 | from django.db import models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="ConsentSource", 19 | fields=[ 20 | ( 21 | "id", 22 | models.AutoField( 23 | auto_created=True, 24 | primary_key=True, 25 | serialize=False, 26 | verbose_name="ID", 27 | ), 28 | ), 29 | ("source_name", models.CharField(max_length=255)), 30 | ("definition", models.TextField()), 31 | ("created", models.DateTimeField(auto_now_add=True)), 32 | ("modified", models.DateTimeField(auto_now=True)), 33 | ("requires_confirmed_email", models.BooleanField(default=False)), 34 | ("requires_active_user", models.BooleanField(default=False)), 35 | ( 36 | "auto_create_id", 37 | models.CharField( 38 | editable=False, 39 | help_text="Created by a seeding process, defined by a dictionary settings.CONSENT_SEEDING. May be edited later.", 40 | max_length=255, 41 | null=True, 42 | ), 43 | ), 44 | ], 45 | ), 46 | migrations.CreateModel( 47 | name="UserConsent", 48 | fields=[ 49 | ( 50 | "id", 51 | models.AutoField( 52 | auto_created=True, 53 | primary_key=True, 54 | serialize=False, 55 | verbose_name="ID", 56 | ), 57 | ), 58 | ("created", models.DateTimeField(auto_now_add=True)), 59 | ("modified", models.DateTimeField(auto_now=True)), 60 | ( 61 | "email_confirmation_requested", 62 | models.DateTimeField(blank=True, null=True), 63 | ), 64 | ("email_confirmed", models.BooleanField(default=False)), 65 | ("email_hash", models.UUIDField()), 66 | ( 67 | "source", 68 | models.ForeignKey( 69 | on_delete=django.db.models.deletion.CASCADE, 70 | related_name="consents", 71 | to="django_consent.consentsource", 72 | ), 73 | ), 74 | ( 75 | "user", 76 | models.ForeignKey( 77 | blank=True, 78 | null=True, 79 | on_delete=django.db.models.deletion.SET_NULL, 80 | to=settings.AUTH_USER_MODEL, 81 | ), 82 | ), 83 | ], 84 | ), 85 | migrations.CreateModel( 86 | name="EmailOptOut", 87 | fields=[ 88 | ( 89 | "id", 90 | models.AutoField( 91 | auto_created=True, 92 | primary_key=True, 93 | serialize=False, 94 | verbose_name="ID", 95 | ), 96 | ), 97 | ("is_everything", models.BooleanField(default=False, editable=False)), 98 | ("created", models.DateTimeField(auto_now_add=True)), 99 | ("modified", models.DateTimeField(auto_now=True)), 100 | ("email_hash", models.UUIDField()), 101 | ( 102 | "consent", 103 | models.ForeignKey( 104 | blank=True, 105 | null=True, 106 | on_delete=django.db.models.deletion.SET_NULL, 107 | related_name="optouts", 108 | to="django_consent.userconsent", 109 | ), 110 | ), 111 | ( 112 | "user", 113 | models.ForeignKey( 114 | blank=True, 115 | null=True, 116 | on_delete=django.db.models.deletion.SET_NULL, 117 | related_name="email_optouts", 118 | to=settings.AUTH_USER_MODEL, 119 | ), 120 | ), 121 | ], 122 | ), 123 | migrations.CreateModel( 124 | name="EmailCampaign", 125 | fields=[ 126 | ( 127 | "id", 128 | models.AutoField( 129 | auto_created=True, 130 | primary_key=True, 131 | serialize=False, 132 | verbose_name="ID", 133 | ), 134 | ), 135 | ("name", models.CharField(max_length=255, verbose_name="name")), 136 | ("created", models.DateTimeField(auto_now_add=True)), 137 | ("modified", models.DateTimeField(auto_now=True)), 138 | ("consent", models.ManyToManyField(to="django_consent.ConsentSource")), 139 | ], 140 | ), 141 | migrations.CreateModel( 142 | name="ConsentSourceTranslation", 143 | fields=[ 144 | ( 145 | "id", 146 | models.AutoField( 147 | auto_created=True, 148 | primary_key=True, 149 | serialize=False, 150 | verbose_name="ID", 151 | ), 152 | ), 153 | ( 154 | "language_code", 155 | models.CharField( 156 | choices=[ 157 | ("af", "Afrikaans"), 158 | ("ar", "Arabic"), 159 | ("ar-dz", "Algerian Arabic"), 160 | ("ast", "Asturian"), 161 | ("az", "Azerbaijani"), 162 | ("bg", "Bulgarian"), 163 | ("be", "Belarusian"), 164 | ("bn", "Bengali"), 165 | ("br", "Breton"), 166 | ("bs", "Bosnian"), 167 | ("ca", "Catalan"), 168 | ("cs", "Czech"), 169 | ("cy", "Welsh"), 170 | ("da", "Danish"), 171 | ("de", "German"), 172 | ("dsb", "Lower Sorbian"), 173 | ("el", "Greek"), 174 | ("en", "English"), 175 | ("en-au", "Australian English"), 176 | ("en-gb", "British English"), 177 | ("eo", "Esperanto"), 178 | ("es", "Spanish"), 179 | ("es-ar", "Argentinian Spanish"), 180 | ("es-co", "Colombian Spanish"), 181 | ("es-mx", "Mexican Spanish"), 182 | ("es-ni", "Nicaraguan Spanish"), 183 | ("es-ve", "Venezuelan Spanish"), 184 | ("et", "Estonian"), 185 | ("eu", "Basque"), 186 | ("fa", "Persian"), 187 | ("fi", "Finnish"), 188 | ("fr", "French"), 189 | ("fy", "Frisian"), 190 | ("ga", "Irish"), 191 | ("gd", "Scottish Gaelic"), 192 | ("gl", "Galician"), 193 | ("he", "Hebrew"), 194 | ("hi", "Hindi"), 195 | ("hr", "Croatian"), 196 | ("hsb", "Upper Sorbian"), 197 | ("hu", "Hungarian"), 198 | ("hy", "Armenian"), 199 | ("ia", "Interlingua"), 200 | ("id", "Indonesian"), 201 | ("ig", "Igbo"), 202 | ("io", "Ido"), 203 | ("is", "Icelandic"), 204 | ("it", "Italian"), 205 | ("ja", "Japanese"), 206 | ("ka", "Georgian"), 207 | ("kab", "Kabyle"), 208 | ("kk", "Kazakh"), 209 | ("km", "Khmer"), 210 | ("kn", "Kannada"), 211 | ("ko", "Korean"), 212 | ("ky", "Kyrgyz"), 213 | ("lb", "Luxembourgish"), 214 | ("lt", "Lithuanian"), 215 | ("lv", "Latvian"), 216 | ("mk", "Macedonian"), 217 | ("ml", "Malayalam"), 218 | ("mn", "Mongolian"), 219 | ("mr", "Marathi"), 220 | ("my", "Burmese"), 221 | ("nb", "Norwegian Bokmål"), 222 | ("ne", "Nepali"), 223 | ("nl", "Dutch"), 224 | ("nn", "Norwegian Nynorsk"), 225 | ("os", "Ossetic"), 226 | ("pa", "Punjabi"), 227 | ("pl", "Polish"), 228 | ("pt", "Portuguese"), 229 | ("pt-br", "Brazilian Portuguese"), 230 | ("ro", "Romanian"), 231 | ("ru", "Russian"), 232 | ("sk", "Slovak"), 233 | ("sl", "Slovenian"), 234 | ("sq", "Albanian"), 235 | ("sr", "Serbian"), 236 | ("sr-latn", "Serbian Latin"), 237 | ("sv", "Swedish"), 238 | ("sw", "Swahili"), 239 | ("ta", "Tamil"), 240 | ("te", "Telugu"), 241 | ("tg", "Tajik"), 242 | ("th", "Thai"), 243 | ("tk", "Turkmen"), 244 | ("tr", "Turkish"), 245 | ("tt", "Tatar"), 246 | ("udm", "Udmurt"), 247 | ("uk", "Ukrainian"), 248 | ("ur", "Urdu"), 249 | ("uz", "Uzbek"), 250 | ("vi", "Vietnamese"), 251 | ("zh-hans", "Simplified Chinese"), 252 | ("zh-hant", "Traditional Chinese"), 253 | ], 254 | max_length=16, 255 | ), 256 | ), 257 | ("source_name", models.CharField(max_length=255)), 258 | ("definition", models.TextField()), 259 | ( 260 | "consent_source", 261 | models.ForeignKey( 262 | on_delete=django.db.models.deletion.CASCADE, 263 | related_name="translations", 264 | to="django_consent.consentsource", 265 | ), 266 | ), 267 | ], 268 | options={ 269 | "ordering": ("consent_source__source_name", "language_code"), 270 | "unique_together": {("language_code", "consent_source")}, 271 | }, 272 | ), 273 | ] 274 | -------------------------------------------------------------------------------- /src/django_consent/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-denmark/django-consent/cd8b3360627140177d16503ef8304682736335f4/src/django_consent/migrations/__init__.py -------------------------------------------------------------------------------- /src/django_consent/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth import get_user_model 3 | from django.core.exceptions import ObjectDoesNotExist 4 | from django.core.management.utils import get_random_secret_key 5 | from django.db import models 6 | from django.db.models import F 7 | from django.db.models import Q 8 | from django.utils import timezone 9 | from django.utils import translation 10 | from django.utils.translation import gettext_lazy as _ 11 | 12 | from . import emails 13 | from . import settings as consent_settings 14 | from . import utils 15 | 16 | 17 | class ConsentSource(models.Model): 18 | """ 19 | A consent source always has to be present when adding email addresses. It 20 | should clearly specify how we imagine that the user opted in. 21 | 22 | Specific events for specific users could be captured as well, but we intend 23 | to start out a bit less dramatic. 24 | 25 | Notice that some consent sources may change their meaning over time. For 26 | instance, someone can sign up as a member and continue to receive certain 27 | email updates despite their memberships expiring. 28 | 29 | In a different case, the consent may be implicit because the email is 30 | mandatory: For instance password reminders, memberships confirmations, 31 | confirmations of website actions etc. 32 | """ 33 | 34 | source_name = models.CharField(max_length=255) 35 | definition = models.TextField() 36 | created = models.DateTimeField(auto_now_add=True) 37 | modified = models.DateTimeField(auto_now=True) 38 | 39 | requires_confirmed_email = models.BooleanField(default=False) 40 | requires_active_user = models.BooleanField(default=False) 41 | 42 | # Consent can be defined as some integral part of a website, for instance 43 | # used for a fixed Newsletter signup form. 44 | auto_create_id = models.CharField( 45 | max_length=255, 46 | null=True, 47 | editable=False, 48 | help_text=_( 49 | "Created by a seeding process, defined by a dictionary settings.CONSENT_SEED. May be edited later." 50 | ), 51 | ) 52 | 53 | def get_valid_consent(self): 54 | """ 55 | Returns all current consent (that have not opted out) 56 | """ 57 | return ( 58 | UserConsent.objects.filter(source=self) 59 | .exclude(user__email_optouts__is_everything=True) 60 | .exclude(optouts__user=F("user")) 61 | .exclude(optouts__email_hash=F("email_hash")) 62 | .filter( 63 | Q(source__requires_confirmed_email=False) | Q(email_confirmed=True), 64 | Q(source__requires_active_user=False) | Q(user__is_active=True), 65 | ) 66 | ).distinct() 67 | 68 | def __str__(self): 69 | return self.source_name 70 | 71 | @property 72 | def definition_translated(self): 73 | if settings.USE_I18N: 74 | try: 75 | return self.translations.get( 76 | language_code=translation.get_language() 77 | ).definition 78 | except ConsentSourceTranslation.DoesNotExist: 79 | return self.definition 80 | else: 81 | return self.definition 82 | 83 | @property 84 | def source_name_translated(self): 85 | if settings.USE_I18N: 86 | try: 87 | return self.translations.get( 88 | language_code=translation.get_language() 89 | ).source_name 90 | except ConsentSourceTranslation.DoesNotExist: 91 | return self.source_name 92 | else: 93 | return self.source_name 94 | 95 | 96 | class ConsentSourceTranslation(models.Model): 97 | """ 98 | An optional translation. It is used like this in views and emails: 99 | 100 | If a translation of a ConsentSource exists in the active language, then that 101 | name and definition of the consent is used. Otherwise, it will fallback 102 | to using the name and translation in the ConsentSource. 103 | 104 | If you have a mono-language project, you don't need to use this model at 105 | all. 106 | """ 107 | 108 | consent_source = models.ForeignKey( 109 | "ConsentSource", related_name="translations", on_delete=models.CASCADE 110 | ) 111 | language_code = models.CharField(max_length=16, choices=settings.LANGUAGES) 112 | source_name = models.CharField(max_length=255) 113 | definition = models.TextField() 114 | 115 | class Meta: 116 | unique_together = ("language_code", "consent_source") 117 | ordering = ("consent_source__source_name", "language_code") 118 | 119 | def __str__(self): 120 | return "{} ({})".format(self.consent_source.source_name, self.language_code) 121 | 122 | 123 | class UserConsent(models.Model): 124 | """ 125 | Specifies the consent of a user to receive emails and what source the 126 | consent originated from. 127 | 128 | Usually, the source will also clarify which emails we MAY send. 129 | 130 | Anonymization: If a user is deleted, all their consents are also deleted as 131 | well. 132 | """ 133 | 134 | source = models.ForeignKey( 135 | ConsentSource, on_delete=models.CASCADE, related_name="consents" 136 | ) 137 | user = models.ForeignKey( 138 | get_user_model(), blank=True, null=True, on_delete=models.SET_NULL 139 | ) 140 | created = models.DateTimeField(auto_now_add=True) 141 | modified = models.DateTimeField(auto_now=True) 142 | 143 | email_confirmation_requested = models.DateTimeField(null=True, blank=True) 144 | email_confirmed = models.BooleanField(default=False) 145 | email_hash = models.UUIDField() 146 | 147 | def email_confirmation(self, request=None): 148 | """ 149 | Sends a confirmation email if necessary 150 | """ 151 | if not self.email_confirmed: 152 | email = emails.ConfirmationNeededEmail( 153 | request=request, user=self.user, consent=self 154 | ) 155 | email.send() 156 | self.email_confirmation_requested = timezone.now() 157 | self.save() 158 | 159 | def __str__(self): 160 | return "{} agrees to {}".format(self.email, self.source.source_name) 161 | 162 | @classmethod 163 | def capture_email_consent(cls, source, email, require_confirmation=False): 164 | """ 165 | Stores consent for a specific email. 166 | 167 | :param: require_confirmation: If set, creating consent for an email 168 | address that does not exist, will require the user to confirm their 169 | consent. 170 | """ 171 | User = get_user_model() 172 | # Field values for creating the new UserConsent object 173 | consent_create_kwargs = { 174 | "email_confirmed": not require_confirmation, 175 | } 176 | 177 | try: 178 | user = User.objects.get(**{User.EMAIL_FIELD: email}) 179 | if not user.is_active and require_confirmation: 180 | consent_create_kwargs["email_confirmation_requested"] = timezone.now() 181 | else: 182 | consent_create_kwargs["email_confirmed"] = True 183 | except ObjectDoesNotExist: 184 | create_kwargs = { 185 | User.EMAIL_FIELD: email, 186 | "is_active": False, 187 | } 188 | if User.EMAIL_FIELD != User.USERNAME_FIELD: 189 | username = get_random_secret_key() 190 | while User.objects.filter(**{User.EMAIL_FIELD: username}).exists(): 191 | username = get_random_secret_key() 192 | create_kwargs[User.USERNAME_FIELD] = username 193 | for field_name in [ 194 | f for f in User.REQUIRED_FIELDS if f not in create_kwargs 195 | ]: 196 | # Custom auth models have to implement this method if they want 197 | # to create rows with just an email on-the-fly 198 | create_kwargs[field_name] = User.get_consent_empty_value(field_name) 199 | user = get_user_model().objects.create(**create_kwargs) 200 | user.set_unusable_password() 201 | user.save() 202 | if require_confirmation: 203 | consent_create_kwargs["email_confirmation_requested"] = timezone.now() 204 | return cls.objects.create(source=source, user=user, **consent_create_kwargs) 205 | 206 | def optout(self, is_everything=False): 207 | """ 208 | Ensures that user is opted out of this consent. 209 | """ 210 | return EmailOptOut.objects.get_or_create( 211 | user=self.user, 212 | consent=self, 213 | is_everything=is_everything, 214 | )[0] 215 | 216 | def confirm(self): 217 | """ 218 | Marks a consent as confirmed. This will not delete any potential optouts 219 | already existing. 220 | """ 221 | self.email_confirmed = True 222 | self.save() 223 | 224 | def save(self, *args, **kwargs): 225 | if not self.email_hash and self.user and self.user.email: 226 | self.email_hash = utils.get_email_hash(self.user.email) 227 | return super().save(*args, **kwargs) 228 | 229 | def is_valid(self): 230 | """ 231 | Try to avoid using this - instead, do lookups directly of what you need. 232 | """ 233 | return self.email_confirmed and not ( 234 | self.optouts.all().exists() 235 | or self.user.email_optouts.filter(is_everything=True).exists() 236 | ) 237 | 238 | @property 239 | def email(self): 240 | return self.user.email 241 | 242 | @property 243 | def confirm_token(self): 244 | return utils.get_consent_token(self, salt=consent_settings.CONFIRM_SALT) 245 | 246 | 247 | class EmailCampaign(models.Model): 248 | """ 249 | For every type of email that goes out, specify: 250 | 251 | 1. a name of the campaign 252 | 2. the type of consent necessary (prefer to use existing consents and don't invent new ones) 253 | 254 | Notice that users don't opt out of a campaign, but they withdraw their 255 | consent from the original source of when it was given and to which we assume 256 | we can send the campaign. 257 | """ 258 | 259 | name = models.CharField(max_length=255, verbose_name=_("name")) 260 | consent = models.ManyToManyField(ConsentSource) 261 | created = models.DateTimeField(auto_now_add=True) 262 | modified = models.DateTimeField(auto_now=True) 263 | 264 | 265 | class EmailOptOut(models.Model): 266 | """ 267 | A user can opt out of different scopes, thus withdrawing their consent: 268 | 269 | * Everything 270 | * A specific consent 271 | 272 | Notice that for some types of emails, a user may not opt out: For instance, 273 | if they are members of an association and it calls its members to the 274 | general assembly. 275 | 276 | Setting ``consent=None`` is the same as opting out of everything. 277 | 278 | Anonymization: If a user is deleted, we still want to be able to store their 279 | opt-out. While the email is deleted, it may re-occur because of subsequent 280 | data imports. Therefore, we store a unique hash. 281 | """ 282 | 283 | user = models.ForeignKey( 284 | get_user_model(), 285 | blank=True, 286 | null=True, 287 | related_name="email_optouts", 288 | on_delete=models.SET_NULL, 289 | ) 290 | consent = models.ForeignKey( 291 | "UserConsent", 292 | blank=True, 293 | null=True, 294 | related_name="optouts", 295 | on_delete=models.SET_NULL, 296 | ) 297 | is_everything = models.BooleanField(default=False, editable=False) 298 | created = models.DateTimeField(auto_now_add=True) 299 | modified = models.DateTimeField(auto_now=True) 300 | 301 | email_hash = models.UUIDField() 302 | 303 | def save(self, *args, **kwargs): 304 | if self.consent_id is None: 305 | self.is_everything = True 306 | if not self.email_hash and self.user and self.user.email: 307 | self.email_hash = utils.get_email_hash(self.user.email) 308 | return super().save(*args, **kwargs) 309 | -------------------------------------------------------------------------------- /src/django_consent/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | #: You can harden security by adding a different salt in your project's settings 5 | UNSUBSCRIBE_SALT = getattr( 6 | settings, "CONSENT_UNSUBSCRIBE_SALT", "django-consent-unsubscribe" 7 | ) 8 | 9 | #: You can harden security by adding a different salt in your project's settings 10 | UNSUBSCRIBE_ALL_SALT = getattr( 11 | settings, "CONSENT_UNSUBSCRIBE_SALT", "django-consent-unsubscribe-all" 12 | ) 13 | 14 | #: You can harden security by adding a different salt in your project's settings 15 | CONFIRM_SALT = getattr(settings, "CONSENT_CONFIRM_SALT", "django-consent-confirm") 16 | 17 | #: For more information, `django-ratelimit `__ 18 | RATELIMIT = getattr(settings, "CONSENT_RATELIMIT", "100/h") 19 | -------------------------------------------------------------------------------- /src/django_consent/templates/consent/base.html: -------------------------------------------------------------------------------- 1 | {# overwrite this in your project #} 2 | {% load i18n %} 3 | 4 | 5 | {% trans "Manage consent" %} 6 | 7 | 8 | 9 |

{% trans "Manage consent" %}

10 | 11 |

On this site, you can manage settings for your consent.

12 | 13 | {% block consent_content %} 14 | 15 | {% endblock %} 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/django_consent/templates/consent/email/base.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% block intro %}{% blocktrans with recipient_name|default:_("user") as name %}Dear {{ name }},{% endblocktrans %}{% endblock %} 2 | 3 | {% block body %}{% endblock %} 4 | 5 | {% block signature %}{% trans "With kind regards," %} 6 | 7 | {{ site_name }} 8 | {{ protocol }}://{{ domain }}{% endblock %} 9 | -------------------------------------------------------------------------------- /src/django_consent/templates/consent/email/base_subject.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% trans "Django Consent" %} 2 | -------------------------------------------------------------------------------- /src/django_consent/templates/consent/email/confirmation.txt: -------------------------------------------------------------------------------- 1 | {% extends "consent/email/base.txt" %} 2 | {% load i18n %} 3 | 4 | {% block body %}{% url "consent:consent_confirm" pk=consent.id token=consent.confirm_token as confirm_url %}{% blocktrans with consent.definition_translated as consent %}Someone, hopefully you, has consented to the following via our website: 5 | 6 | {{ consent }} 7 | 8 | To confirm that it is really you, please click this link:{% endblocktrans %} 9 | 10 | {{ protocol }}://{{ domain }}{{ confirm_url }} 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /src/django_consent/templates/consent/email/confirmation_subject.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% trans "Your confirmation is needed" %} 2 | -------------------------------------------------------------------------------- /src/django_consent/templates/consent/user/confirmation_received.html: -------------------------------------------------------------------------------- 1 | {% extends "consent/base.html" %} 2 | {% load i18n %} 3 | {% block consent_content %} 4 | 5 |

{% trans "Completed" %} ✅

6 | 7 |

{% trans "We have received a confirmation from you and verified the email address associated with your consent." %}

8 | 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /src/django_consent/templates/consent/user/confirmation_sent.html: -------------------------------------------------------------------------------- 1 | {% extends "consent/base.html" %} 2 | {% load i18n %} 3 | {% block consent_content %} 4 | 5 |

{% trans "Confirmation sent" %} 📨

6 | 7 |

{% trans "Please check your inbox, as we have sent you a confirmation with a link that you need to click." %}

8 | 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /src/django_consent/templates/consent/user/create.html: -------------------------------------------------------------------------------- 1 | {% extends "consent/base.html" %} 2 | {% load i18n %} 3 | {% block consent_content %} 4 | 5 |
6 | {% csrf_token %} 7 | {{ form.as_p }} 8 | 9 |
10 | 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /src/django_consent/templates/consent/user/unsubscribe/done.html: -------------------------------------------------------------------------------- 1 | {% extends "consent/base.html" %} 2 | {% block consent_content %} 3 |

A consent for {{ consent.email|default:"unknown/deleted" }} has been withdrawn!

4 | 5 | 6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /src/django_consent/templates/consent/user/unsubscribe/undo.html: -------------------------------------------------------------------------------- 1 | {% extends "consent/base.html" %} 2 | {% block consent_content %} 3 |

A consent optout was undone and consent is re-registered.

4 | {% endblock %} 5 | -------------------------------------------------------------------------------- /src/django_consent/templates/consent/user/unsubscribe_all/done.html: -------------------------------------------------------------------------------- 1 | {% extends "consent/base.html" %} 2 | {% block consent_content %} 3 |

Your consent for everything associated to the email address {{ consent.email|default:"unknown/deleted" }} has been withdrawn!

4 | 5 | 6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /src/django_consent/templates/consent/user/unsubscribe_all/undo.html: -------------------------------------------------------------------------------- 1 | {% extends "consent/base.html" %} 2 | {% block consent_content %} 3 |

Your consent optout for {{ consent.user.email|default:"unknown/deleted" }} was undone and consent is re-registered.

4 | {% endblock %} 5 | -------------------------------------------------------------------------------- /src/django_consent/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | 6 | app_name = "consent" 7 | 8 | 9 | urlpatterns = [ 10 | path( 11 | "subscribe///", 12 | views.ConsentConfirmationReceiveView.as_view(), 13 | name="consent_confirm", 14 | ), 15 | path( 16 | "unsubscribe///", 17 | views.ConsentWithdrawView.as_view(), 18 | name="unsubscribe", 19 | ), 20 | path( 21 | "unsubscribe///undo/", 22 | views.ConsentWithdrawUndoView.as_view(), 23 | name="unsubscribe_undo", 24 | ), 25 | path( 26 | "unsubscribe-all///", 27 | views.ConsentWithdrawAllView.as_view(), 28 | name="unsubscribe_all", 29 | ), 30 | path( 31 | "unsubscribe-all///undo/", 32 | views.ConsentWithdrawAllUndoView.as_view(), 33 | name="unsubscribe_all_undo", 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /src/django_consent/utils.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.core import signing 4 | 5 | from . import settings as consent_settings 6 | 7 | 8 | def get_email_hash(email): 9 | return uuid.uuid3(uuid.NAMESPACE_URL, email) 10 | 11 | 12 | def get_consent_token(consent, salt=consent_settings.UNSUBSCRIBE_SALT): 13 | """ 14 | Returns a token (for a URL) which can be validated to unsubscribe from the 15 | supplied consent object. 16 | """ 17 | return signing.dumps(str(consent.email_hash) + "," + str(consent.id), salt=salt) 18 | 19 | 20 | def validate_token(token, consent, salt=consent_settings.UNSUBSCRIBE_SALT): 21 | """ 22 | Returns true/false according to whether the token validates for the consent 23 | object 24 | """ 25 | try: 26 | value = signing.loads(token, salt=salt).split(",") 27 | return value[0] == str(consent.email_hash) and value[1] == str(consent.id) 28 | except (signing.BadSignature, IndexError): 29 | return False 30 | -------------------------------------------------------------------------------- /src/django_consent/views.py: -------------------------------------------------------------------------------- 1 | from django.http.response import Http404 2 | from django.shortcuts import get_object_or_404 3 | from django.urls import reverse 4 | from django.utils.decorators import method_decorator 5 | from django.views.generic.base import TemplateView 6 | from django.views.generic.detail import DetailView 7 | from django.views.generic.edit import CreateView 8 | from ratelimit.decorators import ratelimit 9 | 10 | from . import forms 11 | from . import models 12 | from . import settings as consent_settings 13 | from . import utils 14 | from .settings import RATELIMIT 15 | 16 | 17 | class ConsentCreateView(CreateView): 18 | """ 19 | The view isn't part of urls.py but you can add it to your own project's 20 | url configuration if you want to use it. 21 | 22 | To mount it, add a source_id kwarg, for instance:: 23 | 24 | path("signup//", SignupView.as_view()), 25 | """ 26 | 27 | model = models.UserConsent 28 | form_class = forms.EmailConsentForm 29 | template_name = "consent/user/create.html" 30 | 31 | @method_decorator(ratelimit(key="ip", rate=RATELIMIT)) 32 | def dispatch(self, request, *args, **kwargs): 33 | self.consent_source = get_object_or_404( 34 | models.ConsentSource, id=kwargs["source_id"] 35 | ) 36 | return super().dispatch(request, *args, **kwargs) 37 | 38 | def get_form_kwargs(self): 39 | kwargs = super().get_form_kwargs() 40 | kwargs["consent_source"] = self.consent_source 41 | return kwargs 42 | 43 | def get_context_data(self, **kwargs): 44 | c = super().get_context_data(**kwargs) 45 | c["consent_source"] = self.consent_source 46 | return c 47 | 48 | def form_valid(self, form): 49 | ret_value = super().form_valid(form) 50 | consent = self.object 51 | consent.email_confirmation(request=self.request) 52 | return ret_value 53 | 54 | def get_success_url(self): 55 | """ 56 | This requires a project with a urlconf specifying a view with the name 57 | 'signup_confirmation'. 58 | """ 59 | return reverse( 60 | "signup_confirmation", kwargs={"source_id": self.consent_source.id} 61 | ) 62 | 63 | 64 | class UserConsentActionView(DetailView): 65 | """ 66 | An abstract view 67 | 68 | Validates that a token is valid for consent ID + email_hash 69 | """ 70 | 71 | model = models.UserConsent 72 | context_object_name = "consent" 73 | token_salt = consent_settings.UNSUBSCRIBE_SALT 74 | 75 | @method_decorator(ratelimit(key="ip", rate=RATELIMIT)) 76 | def dispatch(self, *args, **kwargs): 77 | return super().dispatch(*args, **kwargs) 78 | 79 | def action(self, consent): 80 | raise NotImplementedError("blah") 81 | 82 | def get_object(self, queryset=None): 83 | consent = super().get_object(queryset) 84 | token = self.kwargs.get("token") 85 | 86 | if utils.validate_token(token, consent, salt=self.token_salt): 87 | self.action(consent) 88 | return consent 89 | else: 90 | raise Http404("This does not work") 91 | 92 | def get_context_data(self, **kwargs): 93 | c = super().get_context_data(**kwargs) 94 | c["token"] = utils.get_consent_token(c["consent"], salt=self.token_salt) 95 | return c 96 | 97 | 98 | class ConsentWithdrawView(UserConsentActionView): 99 | """ 100 | Withdraws a consent. In the case of a newsletter, it unsubscribes a user 101 | from receiving the newsletter. 102 | 103 | Requires a valid link with a token. 104 | """ 105 | 106 | template_name = "consent/user/unsubscribe/done.html" 107 | model = models.UserConsent 108 | context_object_name = "consent" 109 | token_salt = consent_settings.UNSUBSCRIBE_SALT 110 | 111 | def action(self, consent): 112 | consent.optout() 113 | 114 | 115 | class ConsentWithdrawUndoView(UserConsentActionView): 116 | """ 117 | This is related to undoing withdrawal of consent in case that the user 118 | clicked the wrong link. 119 | 120 | Requires a valid link 121 | """ 122 | 123 | template_name = "consent/user/unsubscribe/undo.html" 124 | token_salt = consent_settings.UNSUBSCRIBE_SALT 125 | 126 | def action(self, consent): 127 | consent.optouts.all().delete() 128 | 129 | 130 | class ConsentWithdrawAllView(UserConsentActionView): 131 | """ 132 | Withdraws a consent. In the case of a newsletter, it unsubscribes a user 133 | from receiving the newsletter. 134 | 135 | Requires a valid link with a token. 136 | """ 137 | 138 | template_name = "consent/user/unsubscribe_all/done.html" 139 | model = models.UserConsent 140 | context_object_name = "consent" 141 | token_salt = consent_settings.UNSUBSCRIBE_ALL_SALT 142 | 143 | def action(self, consent): 144 | consent.optout(is_everything=True) 145 | 146 | 147 | class ConsentWithdrawAllUndoView(UserConsentActionView): 148 | """ 149 | This is related to undoing withdrawal of consent in case that the user 150 | clicked the wrong link. 151 | 152 | Requires a valid link 153 | 154 | This only cancels an withdrawal of everything that was related to this 155 | particular consent. Another withdrawal can still exist. 156 | """ 157 | 158 | template_name = "consent/user/unsubscribe_all/undo.html" 159 | token_salt = consent_settings.UNSUBSCRIBE_ALL_SALT 160 | 161 | def action(self, consent): 162 | consent.optouts.filter(is_everything=True).delete() 163 | 164 | 165 | class ConsentConfirmationReceiveView(UserConsentActionView): 166 | """ 167 | Marks a consent as confirmed, this is important for items that require a 168 | confirmed email address. 169 | """ 170 | 171 | template_name = "consent/user/confirmation_received.html" 172 | token_salt = consent_settings.CONFIRM_SALT 173 | 174 | def action(self, consent): 175 | consent.confirm() 176 | 177 | 178 | class ConsentConfirmationSentView(TemplateView): 179 | """ 180 | Informs a user that their confirmation has been sent 181 | """ 182 | 183 | template_name = "consent/user/confirmation_sent.html" 184 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit test package for django_consent.""" 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # Needed for fixtures to be visible in all test_* modules 2 | from .fixtures import base_consent # noqa 3 | from .fixtures import create_user # noqa 4 | from .fixtures import many_consents # noqa 5 | from .fixtures import many_consents_per_user # noqa 6 | from .fixtures import test_password # noqa 7 | from .fixtures import user_consent # noqa 8 | -------------------------------------------------------------------------------- /tests/fixtures.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | import uuid 4 | 5 | import pytest 6 | from django.conf import settings 7 | from django_consent import models 8 | 9 | 10 | def get_random_string(length): 11 | return "".join(random.choice(string.ascii_letters) for __ in range(length)) 12 | 13 | 14 | def get_random_email(): 15 | return ( 16 | get_random_string(20) 17 | + "@" 18 | + get_random_string(10) 19 | + "." 20 | + random.choice(["org", "com", "gov.uk"]) 21 | ) 22 | 23 | 24 | @pytest.fixture 25 | def base_consent(): 26 | """Pytest fixture. 27 | See more at: http://doc.pytest.org/en/latest/fixture.html 28 | """ 29 | source = models.ConsentSource.objects.create( 30 | source_name="test", 31 | definition="Testing stuff", 32 | ) 33 | 34 | for language in settings.LANGUAGES: 35 | source.translations.create( 36 | source_name="test {}".format(language[1]), 37 | definition="Testing stuff {}".format(language[1]), 38 | language_code=language[0], 39 | ) 40 | 41 | return source 42 | 43 | 44 | @pytest.fixture 45 | def user_consent(base_consent): 46 | """Pytest fixture. 47 | See more at: http://doc.pytest.org/en/latest/fixture.html 48 | """ 49 | user_consents = [] 50 | for source in models.ConsentSource.objects.all(): 51 | for __ in range(10): 52 | user_consents.append( 53 | models.UserConsent.capture_email_consent( 54 | source, get_random_email(), require_confirmation=False 55 | ) 56 | ) 57 | for __ in range(10): 58 | user_consents.append( 59 | models.UserConsent.capture_email_consent( 60 | source, get_random_email(), require_confirmation=True 61 | ) 62 | ) 63 | 64 | return { 65 | "base_consent": base_consent, 66 | "user_consents": user_consents, 67 | } 68 | 69 | 70 | @pytest.fixture 71 | def many_consents(): 72 | """ 73 | Generate several consents 74 | """ 75 | sources = { 76 | "monthly newsletter": "You agree to receiving a newsletter about our activities every month", 77 | "vogon poetry": "You agree that the head bureaucrat can send you their poetry randomly", 78 | "messages": "Other members can send you a message", 79 | } 80 | consents = [] 81 | for name, description in sources.items(): 82 | consents.append( 83 | models.ConsentSource.objects.create( 84 | source_name=name, 85 | definition=description, 86 | ) 87 | ) 88 | return consents 89 | 90 | 91 | @pytest.fixture 92 | def many_consents_per_user(many_consents): 93 | """ 94 | This fixture creates several consents for random users 95 | """ 96 | user_consents = [] 97 | for source in models.ConsentSource.objects.all(): 98 | for __ in range(10): 99 | user_consents.append( 100 | models.UserConsent.capture_email_consent( 101 | source, get_random_email(), require_confirmation=False 102 | ) 103 | ) 104 | for __ in range(10): 105 | user_consents.append( 106 | models.UserConsent.capture_email_consent( 107 | source, get_random_email(), require_confirmation=True 108 | ) 109 | ) 110 | 111 | return { 112 | "many_consents": many_consents, 113 | "user_consents": user_consents, 114 | } 115 | 116 | 117 | @pytest.fixture 118 | def test_password(): 119 | return "strong-test-pass" 120 | 121 | 122 | @pytest.fixture 123 | def create_user(db, django_user_model, test_password): 124 | def make_user(**kwargs): 125 | kwargs["password"] = test_password 126 | kwargs["email"] = get_random_email() 127 | if "username" not in kwargs: 128 | kwargs["username"] = str(uuid.uuid4()) 129 | return django_user_model.objects.create_user(**kwargs) 130 | 131 | return make_user 132 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | # You may need more or less than what's shown here - this is a skeleton: 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | DATABASES = { 5 | "default": { 6 | "ENGINE": "django.db.backends.sqlite3", 7 | } 8 | } 9 | 10 | INSTALLED_APPS = ( 11 | "django.contrib.admin", 12 | "django.contrib.auth", 13 | "django.contrib.contenttypes", 14 | "django.contrib.messages", 15 | "django.contrib.sessions", 16 | "django.contrib.sites", 17 | "django.contrib.staticfiles", 18 | "django_consent", 19 | ) 20 | 21 | ROOT_URLCONF = "tests.urls" 22 | 23 | 24 | TEMPLATES = [ 25 | { 26 | "BACKEND": "django.template.backends.django.DjangoTemplates", 27 | "APP_DIRS": True, 28 | "DIRS": ["path/to/your/templates"], 29 | }, 30 | ] 31 | 32 | MIDDLEWARE = [ 33 | "django.middleware.security.SecurityMiddleware", 34 | "django.contrib.sessions.middleware.SessionMiddleware", 35 | "django.middleware.common.CommonMiddleware", 36 | "django.middleware.csrf.CsrfViewMiddleware", 37 | "django.contrib.auth.middleware.AuthenticationMiddleware", 38 | "django.contrib.messages.middleware.MessageMiddleware", 39 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 40 | ] 41 | 42 | SECRET_KEY = "this is a test" 43 | 44 | EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" 45 | 46 | SITE_ID = 1 47 | 48 | USE_I18N = True 49 | LANGUAGE_CODE = "en-us" 50 | 51 | LANGUAGES = [ 52 | ("en", _("English")), 53 | ("hi", _("Hindi")), 54 | ] 55 | -------------------------------------------------------------------------------- /tests/test_emails.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.messages.storage.fallback import FallbackStorage 3 | from django.contrib.sites.models import Site 4 | from django.core import mail 5 | from django.core.mail.backends.base import BaseEmailBackend 6 | from django.urls import reverse 7 | from django_consent import emails 8 | from django_consent import models 9 | 10 | 11 | class FailBackend(BaseEmailBackend): 12 | """ 13 | This backend loves failing :) 14 | """ 15 | 16 | def send_messages(self, messages): 17 | raise RuntimeError("I blow up 🤯") 18 | 19 | 20 | @pytest.mark.django_db 21 | def test_base(user_consent, rf): 22 | """ 23 | This does a pretty basic test, and should be split up a bit. 24 | 25 | We should cover some more cases, as we cannot say for sure yet if we know 26 | for instance the name of the recipient. 27 | """ 28 | request = rf.get("/") 29 | 30 | # Add messages and session to request object 31 | # https://stackoverflow.com/questions/23861157/how-do-i-setup-messaging-and-session-middleware-in-a-django-requestfactory-durin 32 | setattr(request, "session", "session") 33 | messages = FallbackStorage(request) 34 | setattr(request, "_messages", messages) 35 | 36 | # Because the default Site object uses example.com and a request object 37 | # from django RequestFactory generates testserver 38 | Site.objects.all().update(domain=request.get_host()) 39 | user = models.UserConsent.objects.all().order_by("?")[0].user 40 | email = emails.BaseEmail(request=request, user=user) 41 | email.send() 42 | assert len(mail.outbox) == 1 43 | assert mail.outbox[0].subject == "Django Consent" 44 | 45 | email.send_with_feedback() 46 | assert len(mail.outbox) == 2 47 | 48 | email.connection = FailBackend() 49 | email.send_with_feedback() 50 | assert len(mail.outbox) == 2 # No increment expected 51 | 52 | email = emails.BaseEmail(request=request, user=user, recipient_name="test person") 53 | email.send() 54 | assert len(mail.outbox) == 3 55 | assert "test person" in mail.outbox[-1].body 56 | 57 | 58 | @pytest.mark.django_db 59 | def test_confirm(user_consent): 60 | consent = models.UserConsent.objects.filter(email_confirmed=False).order_by("?")[0] 61 | consent.email_confirmation(request=None) 62 | assert len(mail.outbox) == 1 63 | 64 | # Assuming only one site exists 65 | first_site = Site.objects.all()[0] 66 | 67 | expected_url = "{}{}".format( 68 | first_site, 69 | reverse( 70 | "consent:consent_confirm", 71 | kwargs={"pk": consent.pk, "token": consent.confirm_token}, 72 | ), 73 | ) 74 | 75 | assert "Your confirmation is needed" in mail.outbox[-1].subject 76 | assert expected_url in mail.outbox[-1].body 77 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import pytest 4 | from django.conf import settings 5 | from django.test.utils import override_settings 6 | from django.utils import translation 7 | from django_consent import models 8 | 9 | 10 | @pytest.mark.django_db 11 | def test_base(user_consent): 12 | """ 13 | This is the most basic of all tests, inheriting some fixtures 14 | """ 15 | assert models.ConsentSource.objects.all().count() > 0 16 | assert models.UserConsent.objects.all().count() > 0 17 | 18 | assert str(models.ConsentSource.objects.all()[0]) 19 | assert str(models.UserConsent.objects.all()[0]) 20 | 21 | 22 | @pytest.mark.django_db 23 | def test_optout_filters(user_consent): 24 | """ 25 | Opt out some of the emails that have been created and check that they don't 26 | figure anywhere 27 | """ 28 | optouts_to_create = 5 29 | consent_to_optout = random.sample( 30 | list(models.UserConsent.objects.all()), optouts_to_create 31 | ) 32 | 33 | for consent in consent_to_optout: 34 | consent.optout() 35 | 36 | assert optouts_to_create == models.EmailOptOut.objects.count() 37 | 38 | assert user_consent["base_consent"].get_valid_consent().count() == ( 39 | models.UserConsent.objects.all().count() - optouts_to_create 40 | ) 41 | 42 | 43 | @pytest.mark.django_db 44 | def test_translations(user_consent): 45 | """ 46 | Opt out some of the emails that have been created and check that they don't 47 | figure anywhere 48 | """ 49 | for consent_source in models.ConsentSource.objects.all(): 50 | 51 | # Test the __str__method 52 | str(consent_source) 53 | 54 | for consent_translation in consent_source.translations.all(): 55 | # Test the __str__method 56 | str(consent_translation) 57 | 58 | with translation.override("404"): 59 | 60 | assert ( 61 | str(consent_source.definition_translated) == consent_source.definition 62 | ) 63 | assert ( 64 | str(consent_source.source_name_translated) == consent_source.source_name 65 | ) 66 | 67 | # Hindi exists 68 | assert any([x[0] == "hi" for x in settings.LANGUAGES]) 69 | with translation.override("hi"): 70 | 71 | assert ( 72 | str(consent_source.definition_translated) != consent_source.definition 73 | ) 74 | assert ( 75 | str(consent_source.source_name_translated) != consent_source.source_name 76 | ) 77 | 78 | 79 | @pytest.mark.django_db 80 | @override_settings(USE_I18N=False) 81 | def test_no_translations(user_consent): 82 | """ 83 | Opt out some of the emails that have been created and check that they don't 84 | figure anywhere 85 | """ 86 | for consent_source in models.ConsentSource.objects.all(): 87 | str(consent_source) 88 | str(consent_source) 89 | 90 | assert str(consent_source.definition_translated) == consent_source.definition 91 | assert str(consent_source.source_name_translated) == consent_source.source_name 92 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.auth import get_user_model 3 | from django.urls import reverse 4 | from django_consent import models 5 | from django_consent import settings as consent_settings 6 | from django_consent import utils 7 | 8 | from .fixtures import get_random_email 9 | 10 | 11 | @pytest.mark.django_db 12 | def test_signup(client, user_consent): 13 | 14 | source = models.ConsentSource.objects.all().order_by("?")[0] 15 | url = reverse("signup", kwargs={"source_id": source.id}) 16 | 17 | # Test render 18 | response = client.get(url) 19 | 20 | signup_email = get_random_email() 21 | 22 | data = {"email": signup_email, "confirmation": True} 23 | response = client.post(url, data=data) 24 | assert response.status_code == 302 25 | 26 | # Check that a user object exists with the email 27 | assert get_user_model().objects.get(email=signup_email) 28 | 29 | # Ensure that the email is NOT confirmed already 30 | assert not models.UserConsent.objects.get( 31 | user__email=signup_email, source_id=source.id 32 | ).is_valid() 33 | 34 | # Ensure that the email is NOT confirmed already 35 | assert not models.UserConsent.objects.get( 36 | user__email=signup_email, source_id=source.id 37 | ).email_confirmed 38 | 39 | 40 | @pytest.mark.django_db 41 | def test_signup_confirmation(client, user_consent): 42 | """ 43 | Tests that the confirmation works after adding a new user, i.e. the page 44 | that says "please confirm your email". 45 | """ 46 | source = models.ConsentSource.objects.all().order_by("?")[0] 47 | url = reverse("signup_confirmation", kwargs={"source_id": source.id}) 48 | 49 | response = client.get(url) 50 | 51 | assert response.status_code == 200 52 | 53 | 54 | @pytest.mark.django_db 55 | def test_signup_active_user(client, user_consent, create_user): 56 | 57 | source = models.ConsentSource.objects.all().order_by("?")[0] 58 | url = reverse("signup", kwargs={"source_id": source.id}) 59 | 60 | # Test render 61 | response = client.get(url) 62 | 63 | user = create_user() 64 | 65 | data = {"email": user.email, "confirmation": True} 66 | response = client.post(url, data=data) 67 | assert response.status_code == 302 68 | 69 | # Ensure that the email is confirmed already 70 | assert models.UserConsent.objects.get( 71 | user__email=user.email, source_id=source.id 72 | ).is_valid() 73 | 74 | 75 | @pytest.mark.django_db 76 | def test_signup_inactive_user(client, user_consent, create_user): 77 | """ 78 | Test signing up someone that already exists but isn't active. Ensure that 79 | they are not automatically confirmed. 80 | """ 81 | 82 | source = models.ConsentSource.objects.all().order_by("?")[0] 83 | url = reverse("signup", kwargs={"source_id": source.id}) 84 | 85 | # Test render 86 | response = client.get(url) 87 | 88 | user = create_user() 89 | user.is_active = False 90 | user.save() 91 | 92 | data = {"email": user.email, "confirmation": True} 93 | response = client.post(url, data=data) 94 | assert response.status_code == 302 95 | 96 | # Ensure that the email is NOT confirmed already 97 | assert not models.UserConsent.objects.get( 98 | user__email=user.email, source_id=source.id 99 | ).is_valid() 100 | 101 | 102 | @pytest.mark.django_db 103 | def test_unsubscribe(client, user_consent, create_user): 104 | 105 | source = models.ConsentSource.objects.all().order_by("?")[0] 106 | 107 | consent = source.consents.order_by("?")[0] 108 | 109 | url = reverse( 110 | "consent:unsubscribe", 111 | kwargs={ 112 | "pk": consent.id, 113 | "token": utils.get_consent_token( 114 | consent, salt=consent_settings.UNSUBSCRIBE_SALT 115 | ), 116 | }, 117 | ) 118 | url_undo = reverse( 119 | "consent:unsubscribe_undo", 120 | kwargs={ 121 | "pk": consent.id, 122 | "token": utils.get_consent_token( 123 | consent, salt=consent_settings.UNSUBSCRIBE_SALT 124 | ), 125 | }, 126 | ) 127 | 128 | # Test render 129 | response = client.get(url) 130 | assert response.status_code == 200 131 | assert not models.UserConsent.objects.get(id=consent.id).is_valid() 132 | assert url_undo in str(response.content) 133 | 134 | 135 | @pytest.mark.django_db 136 | def test_unsubscribe_undo(client, user_consent, create_user): 137 | 138 | source = models.ConsentSource.objects.all().order_by("?")[0] 139 | 140 | consent = source.consents.order_by("?")[0] 141 | 142 | # Confirm this consent in case it's unconfirmed 143 | consent.confirm() 144 | 145 | url = reverse( 146 | "consent:unsubscribe_undo", 147 | kwargs={ 148 | "pk": consent.id, 149 | "token": utils.get_consent_token( 150 | consent, salt=consent_settings.UNSUBSCRIBE_SALT 151 | ), 152 | }, 153 | ) 154 | 155 | # Test render 156 | response = client.get(url) 157 | assert response.status_code == 200 158 | assert models.UserConsent.objects.get(id=consent.id).is_valid() 159 | 160 | 161 | @pytest.mark.django_db 162 | def test_unsubscribe_invalid(client, user_consent, create_user): 163 | """Test that an invalid token returns 404""" 164 | 165 | source = models.ConsentSource.objects.all().order_by("?")[0] 166 | 167 | consent = source.consents.order_by("?")[0] 168 | 169 | url = reverse( 170 | "consent:unsubscribe", 171 | kwargs={"pk": consent.id, "token": "invalid"}, 172 | ) 173 | 174 | # Test render 175 | response = client.get(url) 176 | 177 | assert response.status_code == 404 178 | 179 | 180 | @pytest.mark.django_db 181 | def test_unsubscribe_all(client, many_consents_per_user, create_user): 182 | 183 | source = models.ConsentSource.objects.all().order_by("?")[0] 184 | 185 | consent = source.consents.order_by("?")[0] 186 | 187 | url = reverse( 188 | "consent:unsubscribe_all", 189 | kwargs={ 190 | "pk": consent.id, 191 | "token": utils.get_consent_token( 192 | consent, salt=consent_settings.UNSUBSCRIBE_ALL_SALT 193 | ), 194 | }, 195 | ) 196 | url_undo = reverse( 197 | "consent:unsubscribe_all_undo", 198 | kwargs={ 199 | "pk": consent.id, 200 | "token": utils.get_consent_token( 201 | consent, salt=consent_settings.UNSUBSCRIBE_ALL_SALT 202 | ), 203 | }, 204 | ) 205 | 206 | # Test render 207 | response = client.get(url) 208 | assert response.status_code == 200 209 | assert not models.UserConsent.objects.get(id=consent.id).is_valid() 210 | assert url_undo in str(response.content) 211 | 212 | 213 | @pytest.mark.django_db 214 | def test_unsubscribe_all_undo(client, many_consents_per_user, create_user): 215 | 216 | source = models.ConsentSource.objects.all().order_by("?")[0] 217 | 218 | consent = source.consents.order_by("?")[0] 219 | 220 | # Confirm this consent in case it's unconfirmed 221 | consent.confirm() 222 | 223 | url = reverse( 224 | "consent:unsubscribe_all_undo", 225 | kwargs={ 226 | "pk": consent.id, 227 | "token": utils.get_consent_token( 228 | consent, salt=consent_settings.UNSUBSCRIBE_ALL_SALT 229 | ), 230 | }, 231 | ) 232 | 233 | # Test render 234 | response = client.get(url) 235 | assert response.status_code == 200 236 | assert models.UserConsent.objects.get(id=consent.id).is_valid() 237 | 238 | 239 | @pytest.mark.django_db 240 | def test_consent_confirm(client, user_consent, create_user): 241 | """Test that an unconfirmed consent is confirmed""" 242 | 243 | source = models.ConsentSource.objects.all().order_by("?")[0] 244 | 245 | consent = source.consents.filter(email_confirmed=False).order_by("?")[0] 246 | 247 | url = reverse( 248 | "consent:consent_confirm", 249 | kwargs={ 250 | "pk": consent.id, 251 | "token": utils.get_consent_token( 252 | consent, salt=consent_settings.CONFIRM_SALT 253 | ), 254 | }, 255 | ) 256 | 257 | # Test render 258 | response = client.get(url) 259 | 260 | assert response.status_code == 200 261 | assert models.UserConsent.objects.get(id=consent.id).is_valid() 262 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | This urlconf exists so we can run tests without an actual 3 | Django project (Django expects ROOT_URLCONF to exist.) 4 | It is not used by installed instances of this app. 5 | """ 6 | from django.urls import include 7 | from django.urls import path 8 | from django_consent.views import ConsentConfirmationSentView 9 | from django_consent.views import ConsentCreateView 10 | 11 | urlpatterns = [ 12 | path("foo/", include("django_consent.urls")), 13 | path("signup//", ConsentCreateView.as_view(), name="signup"), 14 | path( 15 | "signup//confirmation/", 16 | ConsentConfirmationSentView.as_view(), 17 | name="signup_confirmation", 18 | ), 19 | path("consent/", include("django_consent.urls")), 20 | ] 21 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py310, py311, flake8 3 | 4 | [travis] 5 | python = 6 | 3.10: py310 7 | 3.11: py311 8 | 9 | [testenv:flake8] 10 | basepython = python 11 | deps = flake8 12 | commands = flake8 django_consent tests 13 | 14 | [testenv] 15 | passenv = CODECOV_TOKEN 16 | usedevelop = True 17 | setenv = 18 | PYTHONPATH = {toxinidir} 19 | deps = 20 | -e .[test] 21 | commands = 22 | pip install -U pip 23 | pytest --basetemp={envtmpdir} --cov={toxinidir}/src 24 | codecov 25 | --------------------------------------------------------------------------------