├── .github └── workflows │ ├── build.yml │ └── codeql.yml ├── .gitignore ├── .pyup.yml ├── LICENSE ├── MANIFEST.in ├── README.md ├── letsagree ├── __init__.py ├── admin.py ├── apps.py ├── forms.py ├── helpers.py ├── middleware.py ├── migrations │ └── __init__.py ├── models.py ├── querysets.py ├── static │ └── letsagree │ │ └── letsagree.css ├── templates │ └── letsagree │ │ └── pending.html ├── tests │ ├── conftest.py │ ├── factories.py │ ├── test_admin.py │ ├── test_forms.py │ ├── test_middleware.py │ ├── test_models.py │ ├── test_querysets.py │ └── test_views.py ├── urls.py └── views.py ├── setup.cfg ├── setup.py ├── test_setup ├── __init__.py ├── i18n_settings.py ├── requirements.txt ├── settings.py └── urls.py └── tox.ini /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Build 5 | 6 | on: 7 | push: 8 | branches: [ "*" ] 9 | 10 | jobs: 11 | pg-build: 12 | 13 | runs-on: ubuntu-latest 14 | env: 15 | TOX_DB_NAME: letsagree 16 | TOX_DB_USER: postgres 17 | TOX_DB_PASSWD: ${{ secrets.PG_PASS }} 18 | TOX_DB_ENGINE: django.db.backends.postgresql 19 | 20 | services: 21 | postgres: 22 | image: postgres:latest 23 | env: 24 | POSTGRES_PASSWORD: ${{ secrets.PG_PASS }} 25 | POSTGRES_USER: postgres 26 | POSTGRES_DB:: letsagree 27 | ports: 28 | - 5432:5432 29 | # needed because the postgres container does not provide a healthcheck 30 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 31 | 32 | strategy: 33 | max-parallel: 4 34 | matrix: 35 | python-version: ["3.8", "3.9", "3.10", "3.11"] # CHECK 36 | django-version: ["Django>=3.2,<3.3", "Django>=4.1"] # CHECK 37 | django_settings: ["test_setup.settings", "test_setup.i18n_settings"] # CHECK 38 | 39 | steps: 40 | - uses: actions/checkout@v3 41 | - name: Set up Python ${{ matrix.python-version }} 42 | uses: actions/setup-python@v3 43 | with: 44 | python-version: ${{ matrix.python-version }} 45 | 46 | - name: Install dependencies 47 | run: | 48 | python -m pip install --upgrade pip 49 | python -m pip install "${{ matrix.django-version }}" 50 | python -m pip install django-translated-fields pytest-django pytest-cov pytest-factoryboy psycopg2-binary 51 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 52 | - name: set pythonpath 53 | run: | 54 | echo "PYTHONPATH=$GITHUB_WORKSPACE" >> $GITHUB_ENV 55 | - name: Test with pytest 56 | run: | 57 | pytest -rs --nomigrations --cov --cov-branch 58 | env: 59 | DJANGO_SETTINGS_MODULE: ${{ matrix.django_settings }} 60 | TOX_DB_PORT: 5432 61 | 62 | - name: Coveralls 63 | uses: AndreMiras/coveralls-python-action@develop 64 | with: 65 | parallel: true 66 | flag-name: Testing 67 | github-token: ${{ secrets.GITHUB_TOKEN }} 68 | 69 | 70 | mdb-build: 71 | 72 | runs-on: ubuntu-latest 73 | env: 74 | TOX_DB_NAME: letsagree 75 | TOX_DB_USER: root 76 | TOX_DB_PASSWD: ${{ secrets.PG_PASS }} 77 | TOX_DB_ENGINE: django.db.backends.mysql 78 | 79 | services: 80 | mariadb: 81 | image: mariadb:latest 82 | 83 | env: 84 | MARIADB_USER: root 85 | MYSQL_ROOT_PASSWORD: ${{ secrets.PG_PASS }} 86 | MYSQL_DATABASE: letsagree 87 | 88 | ports: 89 | - 3306:3306 90 | options: >- 91 | --health-cmd="healthcheck.sh --innodb_initialized" 92 | --health-interval=10s 93 | --health-timeout=5s 94 | --health-retries=3 95 | 96 | strategy: 97 | max-parallel: 4 98 | matrix: 99 | python-version: ["3.8", "3.9", "3.10", "3.11"] # CHECK 100 | django-version: ["Django>=3.2,<3.3", "Django>=4.1"] # CHECK 101 | django_settings: ["test_setup.settings", "test_setup.i18n_settings"] # CHECK 102 | steps: 103 | - uses: actions/checkout@v3 104 | - name: Set up Python ${{ matrix.python-version }} 105 | uses: actions/setup-python@v3 106 | with: 107 | python-version: ${{ matrix.python-version }} 108 | 109 | - name: Install dependencies 110 | run: | 111 | python -m pip install --upgrade pip 112 | python -m pip install "${{ matrix.django-version }}" 113 | python -m pip install django-translated-fields pytest-django pytest-cov pytest-factoryboy mysqlclient 114 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 115 | - name: set pythonpath 116 | run: | 117 | echo "PYTHONPATH=$GITHUB_WORKSPACE" >> $GITHUB_ENV 118 | - name: Test with pytest 119 | run: | 120 | pytest -rs --nomigrations --cov --cov-branch 121 | env: 122 | DJANGO_SETTINGS_MODULE: ${{ matrix.django_settings }} 123 | TOX_DB_PORT: 3306 124 | 125 | - name: Coveralls 126 | uses: AndreMiras/coveralls-python-action@develop 127 | with: 128 | parallel: true 129 | flag-name: Testing 130 | github-token: ${{ secrets.GITHUB_TOKEN }} 131 | 132 | coveralls_finish: 133 | needs: [pg-build, mdb-build] 134 | runs-on: ubuntu-latest 135 | steps: 136 | 137 | - uses: actions/checkout@v3 138 | - name: Set up Python 139 | uses: actions/setup-python@v3 140 | with: 141 | python-version: "3.11" # CHECK 142 | 143 | - name: Install last pass dependencies 144 | run: | 145 | python -m pip install black flake8 bandit 146 | - name: Run Last Checks 147 | run: | 148 | black . --check 149 | flake8 . 150 | bandit letsagree/* 151 | 152 | - name: Coveralls Finished 153 | uses: AndreMiras/coveralls-python-action@develop 154 | with: 155 | parallel-finished: true 156 | github-token: ${{ secrets.GITHUB_TOKEN }} 157 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | schedule: 9 | - cron: "33 4 * * 2" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ javascript, python ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | if: ${{ matrix.language == 'javascript' || matrix.language == 'python' }} 38 | 39 | - name: Perform CodeQL Analysis 40 | uses: github/codeql-action/analyze@v2 41 | with: 42 | category: "/language:${{ matrix.language }}" 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage* 2 | *.pyc 3 | *cache* 4 | /*.egg-info 5 | /.eggs/ 6 | /.tox/ 7 | /build/ 8 | /dist/ 9 | /html_cov/ 10 | -------------------------------------------------------------------------------- /.pyup.yml: -------------------------------------------------------------------------------- 1 | schedule: "every week" 2 | requirements: 3 | - test_setup/requirements.txt: 4 | update: all 5 | pin: True 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2019, George Tantiras 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | recursive-include letsagree/static * 4 | recursive-include letsagree/templates * 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/raratiru/django-letsagree/actions/workflows/build.yml/badge.svg)](https://github.com/raratiru/django-letsagree/actions) 2 | [![Coverage Status](https://coveralls.io/repos/github/raratiru/django-letsagree/badge.svg?branch=travis)](https://coveralls.io/github/raratiru/django-letsagree?branch=travis) 3 | [![Updates](https://pyup.io/repos/github/raratiru/django-letsagree/shield.svg)](https://pyup.io/repos/github/raratiru/django-letsagree/) 4 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) 5 | 6 | [![Python Versions](https://img.shields.io/badge/Python-3.8|%203.9|%203.10|%203.11|%20-%236600cc)](https://docs.djangoproject.com/en/dev/faq/install/#what-python-version-can-i-use-with-django) 7 | [![Django Versions](https://img.shields.io/badge/Django-%203.2%20|%204.1-brown.svg)](https://www.djangoproject.com/download/) 8 | [![Database Window Functions](https://img.shields.io/badge/Database-Window%20Functions-important.svg)](https://www.sql-workbench.eu/dbms_comparison.html) 9 | 10 | Let's Agree 11 | ====== 12 | 13 | Features 14 | -------- 15 | 16 | * Terms [versioning](#version) in "[`deque`](https://docs.python.org/3.11/library/collections.html#collections.deque)-style" with `maxlen=1`. 17 | * Per-Group Term association, per-User Term acceptance for each Group a user belongs to. 18 | * [Max 1 query](#queries), either per request or per day for each logged-in user. 19 | * [Multi-language](#translation) ready. 20 | * [Freedom](#permissions) for each user to withdraw consent at any time. 21 | 22 | 23 | About 24 | --- 25 | 26 | `django-letsagree`is the result of an effort to follow the spirit of [The EU General Data Protection Regulation (GDPR)](https://eugdpr.org/). 27 | 28 | A logged in user can belong to one or more Groups. 29 | 30 | If one or more Groups are associated with `django-letsagree`, all users that login as members of those Groups will be asked to provide their consent to the Terms related with each Group. This action, will be recorded in the database. 31 | 32 | The Terms associated with a Group, can be updated with newer versions. 33 | 34 | Such a decision will trigger again the mechanism which asks for the consent of each user before allowing any other operation on the site. 35 | 36 | If the user does not provide consent, the following actions are only allowed: 37 | 38 | * Logout. 39 | * View and delete all instances of own consent provided. 40 | * View all Terms 41 | 42 | 43 | Prerequisites 44 | ------- 45 | 46 | * Python 3.8, 3.9, 3.10, 3.11 47 | * Django 3.2, 4.1 48 | * [Django Admin Site](https://docs.djangoproject.com/en/dev/ref/contrib/admin/) (enabled by default in Django) 49 | * A database with [Window Functions support](https://www.sql-workbench.eu/dbms_comparison.html) 50 | * [`django-translated-fields`](https://github.com/matthiask/django-translated-fields) 51 | 52 | Installation 53 | ------- 54 | 1. `pip install django-letsagree` 55 | 56 | 2. project/settings.py 57 | ```python 58 | INSTALLED_APPS = [ 59 | ... 60 | 'letsagree.apps.LetsagreeConfig', 61 | ... 62 | ] 63 | 64 | MIDDLEWARE = [ 65 | ... 66 | 'letsagree.middleware.LetsAgreeMiddleware', # Near the end of the list 67 | ... 68 | ] 69 | ``` 70 | 71 | 3. `` is the name of the project that hosts django-letsagree 72 | 73 | project/settings.py: 74 | ```python 75 | MIGRATION_MODULES = { 76 | 'letsagree': '.3p_migrations.letsagree', 77 | } 78 | ``` 79 | 80 | 4. Make sure [LANGUAGES](https://docs.djangoproject.com/en/dev/ref/settings/#languages) are properly set as explained in the [Translation](#translation) section. 81 | The default implementation will create as **many fields** as the number of `LANGUAGES` Django has set by default. 82 | 83 | 84 | 5. project/urls.py: 85 | 86 | ```python 87 | urlpatterns = [ 88 | ... 89 | path('path/to/letsagree/', include('letsagree.urls')), 90 | ... 91 | ] 92 | ``` 93 | 94 | 6. Create the migrations: 95 | 96 | ```python 97 | ./manage.py makemigrations letsagree 98 | ./manage.py migrate 99 | ``` 100 | 101 | 102 | ### Notes on installation 103 | 104 | * `django-letsagree` itself does not come with any migrations. It is recommended 105 | that you add migrations for its models in your project and avoid using the 106 | word `migrations` as the name of the folder. 107 | 108 | The relevant Django setting is [`MIGRATION_MODULES`](https://docs.djangoproject.com/en/dev/ref/settings/#migration-modules). 109 | In the above example, we store migrations inside `//3p_migrations`. 110 | 111 | 112 | Settings 113 | -------- 114 | 115 | ### Default Settings 116 | ```python 117 | LETSAGREE_CACHE = False 118 | LETSAGREE_CSS = {} 119 | LETSAGREE_JS = () 120 | LETSAGREE_LOGOUT_APP_NAME = '' (Deprecated -> default value was 'admin') 121 | LETSAGREE_LOGOUT_URL = 'admin:logout' 122 | LETSAGREE_BROWSER_TITLE = '' 123 | LETSAGREE_BORDER_HEADER = '' 124 | ``` 125 | 126 | ### Database queries 127 | 128 | 129 | The middleware generates one database query per request in order to make sure that the user has agreed to all the terms related with the Group(s) he belongs to. 130 | 131 | If `LETSAGREE_CACHE = True`, [Django's Cache Framework](https://docs.djangoproject.com/en/dev/topics/cache/) will be used and only one database query will be generated by the middleware, every 24 hours. 132 | 133 | `LETSAGREE_CACHE` is not enabled by default, because it exposes the unique `id` for each user by creating a cache record with key `'letsagree-'`. 134 | 135 | Tip: [nshafer/django-hashid-field](https://github.com/nshafer/django-hashid-field), is a library that obscures unique `id`s, without compromising their uniqueness. 136 | 137 | Update: [ericls/django-hashids](https://github.com/ericls/django-hashids) is another non-intrusive library that proxies the field that is applied to. 138 | 139 | Both libraries, however, are based on [https://hashids.org/](https://hashids.org/) which is not capable of encrypting sensitive data. 140 | 141 | 142 | ### Translation 143 | 144 | 145 | **Watch your `LANGUAGES`**! 146 | 147 | #### Database 148 | 149 | By default `lestagree` installs [`django-translated-fields`](https://github.com/matthiask/django-translated-fields) to cater for translating the `title`, `summary` and `content` fields of the `Term` model. This library will create separate fields for each entry in the [`LANGUAGES`](https://docs.djangoproject.com/en/dev/ref/settings/#languages) list. 150 | 151 | The first entry of this list is considered as the "default language". The relevant database field is marked as `blank=False` and it serves as a fallback value. This value is returned if an entry for the requested language does not exist. 152 | 153 | All other fields that are related with the rest of the languages in the `LANGUAGES` list are marked as `blank=True` and can stay empty. 154 | 155 | Although the [`LANGUAGE_CODE`](https://docs.djangoproject.com/en/dev/ref/settings/#language-code) setting is not directly related with `letsagree` or `django-translated-fields` it is **strongly** recommended to match the first language in the `LANGUAGES` setting. 156 | 157 | Example: 158 | ```python 159 | LANGUAGES = (('fr', 'French'), ('en', 'English')) 160 | LANGUAGE_CODE = 'fr' 161 | ``` 162 | The model `Term` will include the following fields: 163 | ```python 164 | { 165 | 'title_fr': {'blank': False}, 166 | 'title_en': {'blank': True}, 167 | 'summary_fr': {'blank': False}, 168 | 'summary_en': {'blank': True}, 169 | 'content_fr': {'blank': False}, 170 | 'content_en': {'blank': True}, 171 | } 172 | ``` 173 | 174 | #### Strings 175 | 176 | All strings in `django-letsagree` are marked with one of the following ways which allow translation: 177 | * `django.utils.translation.gettext_lazy('')` 178 | * `{% trans "" %}` 179 | 180 | ### Custom Form Assets 181 | 182 | `django-letsagree` uses`letsagree/pending.html` template which extends `admin/index.html`. Through a `FormView` this template receives a `Formset` which includes all the `Terms` that should receive consent from the user. 183 | 184 | `LETSAGREE_CSS` and `LETSAGREE_JS` when set, pass the relevant assets in the `Media` class of the `Form` that serves as the basis of the above mentioned Formset. The syntax is described in the relevant [django documentation.](https://docs.djangoproject.com/en/dev/topics/forms/media/#assets-as-a-static-definition) 185 | 186 | A good starting point could be the default css file provided by `django-letsagree`: 187 | 188 | settings.py: 189 | ```python 190 | LETSAGREE_CSS = {'all': ('letsagree/letsagree.css',)} 191 | ``` 192 | 193 | Of course, one can completely [override the templates](https://docs.djangoproject.com/en/dev/howto/overriding-templates/). 194 | 195 | In that case, bear in mind that if `{{ empty_form }}` is False, `{{ form }}` contains a formset. 196 | 197 | 198 | ### Other settings 199 | 200 | * `LETSAGREE_LOGOUT_URL`: String that represents a namespaced URL. 201 | 202 | For example: `'admin:logout'` is the default, it can be any string. If the url is not found, it fails silently resulting in the disappearance of the logout option. 203 | 204 | * `LETSAGREE_BROWSER_TITLE`: A title for the default template. 205 | * `LETSAGREE_BORDER_HEADER`: Text that will appear on the top left corner of the default template. 206 | 207 | 208 | Permissions 209 | ----------- 210 | 211 | It is your responsibility to assign every new user to a Group associated with `django-letsagree`. This group should at least include the `delete_notarypublic` permission in case a user whishes to revoke his consent. 212 | 213 | If all permissions for `django-letsagree` models are delegated to a group, the below table illustrates what actions are allowed for user, with either `is_staff == True` or `is_superuser == True`: 214 | 215 | 216 | | Actions | superuser own entries | superuser other entries | admin-user own entries | admin-user other entries | 217 | | :-----| :------------------:| :--------------------: | :-------------------: | :---------------------: | 218 | | view_term | **True** | **True** | **True** |**True**| 219 | | add_term | **True** | | **True** | | 220 | | change_term | False | False | False | False | 221 | | delete_term | False | False | False | False | 222 | | view_notarypublic | **True** | **True** |**True** | False | 223 | | add_notarypublic | False | | False | | 224 | | change_notarypublic | False | False | False | False | 225 | | delete_notarypublic | **True** | False | **True** | False | 226 | 227 | ### Term changelist contents 228 | 229 | Users who have permission to add a new term, are allowed to read all the available terms. Otherwise, each user can only read the terms related to the group that he or she belongs to. 230 | 231 | 232 | New Term Version 233 | ---------------- 234 | If two instances of Term associate with the same Group, the instance saved-last is the latest version. All logged in users have to provide consent for this latest version, independently of any previous consent they may have or have not given for the Terms associated with this Group. 235 | 236 | `django-letsagree` takes into account if a logged-in user has provided consent only for the latest version of each Term associated with the Groups he belongs to. If not, the user can only logout or visit `django-letsagree` admin page retaining the right to delete any instances of consent he has provided. 237 | 238 | Tests 239 | ----- 240 | 241 | To run the test suite, you need: 242 | 243 | * Virtualenv with tox installed. 244 | * PostgreSQL, MariaDB/MySQL databases with the same user, password, database name. 245 | * The following environment variables set: `TOX_DB_NAME`, `TOX_DB_USER`, `TOX_DB_PASSWD`. 246 | 247 | Unfortunatelly, the test suite is rather complicated. Sorry! 248 | 249 | ### Coverage: Not tested 250 | 251 | * [`LETSAGREE_CSS`](https://github.com/raratiru/django-letsagree/blob/9436ddabb4467477ecb39d94fd09b6f574e9384f/letsagree/forms.py#L42-L44) 252 | * [`LETSAGREE_JS`](https://github.com/raratiru/django-letsagree/blob/9436ddabb4467477ecb39d94fd09b6f574e9384f/letsagree/forms.py#L42-L44) 253 | * [`letsagree.admin.term_parents`](https://github.com/raratiru/django-letsagree/blob/9436ddabb4467477ecb39d94fd09b6f574e9384f/letsagree/admin.py#L23-L27) 254 | 255 | Changelog 256 | --------- 257 | 1.1.9: Added support for Django-4.1 started testing for python-3.11. 258 | 259 | 1.1.8: Added support for Django-4.0 started testing for python-3.10, stopped testing for python-3.6 and python-3.7 (not supported by Django-4.0). 260 | 261 | 1.1.7: Added `default_auto_field` value to ` 'django.db.models.AutoField'` for `Django-3.2`. 262 | 263 | 1.1.6: Fixed compatibility with Django-3.1 264 | 265 | 1.1.5: Fixed bug in LETSAGREE_LOGOUT_URL setting. 266 | 267 | 1.1.4: Deprecated `LETSAGREE_LOGOUT_APP_NAME` in favor of `LETSAGREE_LOGOUT_URL` 268 | 269 | 1.1.3: Locked to Django-3.0 until #39 is resolved 270 | 271 | 1.1.2: Added the ability to set a namespaced url in the "logout application name" setting. 272 | 273 | 1.1.1: AnonymousUser should not access letsagree urls (receives 404) 274 | 275 | 1.1.0: Refactored middleware for thread-safety 276 | 277 | 1.0.4: Added support for Django-3.0, dropped support for Django-2.1 278 | 279 | 1.0.3: Only users with add_perm can see all the Terms in changelist 280 | 281 | 1.0.2: Addressed codacy reports, updated readme, installed pyup, snyk 282 | 283 | 1.0.1: Added Travis, Coverage, LGTM, PyUp CI 284 | -------------------------------------------------------------------------------- /letsagree/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /letsagree/admin.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from django.conf import settings 5 | from django.contrib import admin 6 | from letsagree import models 7 | from translated_fields import TranslatedFieldAdmin, to_attribute 8 | from django.http import HttpResponseRedirect 9 | from django.urls import reverse 10 | 11 | 12 | term_parents = ( 13 | (admin.ModelAdmin,) 14 | if len(settings.LANGUAGES) < 2 15 | else (TranslatedFieldAdmin, admin.ModelAdmin) 16 | ) 17 | 18 | 19 | @admin.register(models.Term) 20 | class TermAdmin(*term_parents): 21 | autocomplete_fields = ("group_key",) 22 | list_display = ("id", "group_key", "date_created", "title") 23 | list_display_links = ("id", "date_created", "title") 24 | readonly_fields = ("date_created",) 25 | search_fields = ("id", to_attribute("title")) 26 | 27 | @staticmethod 28 | def has_delete_permission(request, obj=None): 29 | return False 30 | 31 | @staticmethod 32 | def has_change_permission(request, obj=None): 33 | return False 34 | 35 | def get_queryset(self, request): 36 | qs = super().get_queryset(request) 37 | if request.user.has_perm("letsagree.add_term"): 38 | return qs 39 | else: 40 | return qs.filter(group_key__user=request.user) 41 | 42 | 43 | @admin.register(models.NotaryPublic) 44 | class NotaryPublicAdmin(admin.ModelAdmin): 45 | autocomplete_fields = ("term_key", "user_key") 46 | list_display = ("id", "date_signed", "user_key", "term_key") 47 | list_display_links = ("id", "date_signed", "term_key") 48 | readonly_fields = ("date_signed",) 49 | search_fields = ("id", "user_key__username") 50 | 51 | def get_queryset(self, request): 52 | qs = super().get_queryset(request) 53 | if request.user.is_superuser: 54 | return qs 55 | return qs.filter(user_key_id=request.user.id) 56 | 57 | @staticmethod 58 | def has_add_permission(request): 59 | return False 60 | 61 | @staticmethod 62 | def has_delete_permission(request, obj=None): 63 | if obj: 64 | return request.user.id == obj.user_key_id 65 | return False 66 | 67 | @staticmethod 68 | def has_change_permission(request, obj=None): 69 | return False 70 | 71 | def response_delete(self, request, obj_display, obj_id): 72 | return HttpResponseRedirect(reverse("admin:letsagree_notarypublic_changelist")) 73 | -------------------------------------------------------------------------------- /letsagree/apps.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from django.apps import AppConfig 5 | 6 | 7 | class LetsagreeConfig(AppConfig): 8 | name = "letsagree" 9 | default_auto_field = "django.db.models.AutoField" 10 | -------------------------------------------------------------------------------- /letsagree/forms.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from django import forms 5 | from django.conf import settings 6 | from django.utils.translation import gettext_lazy as _ 7 | from letsagree import models 8 | 9 | 10 | class PendingConsentForm(forms.ModelForm): 11 | agree = forms.BooleanField(required=True, label=_("I Give my Consent")) 12 | 13 | def save(self, commit=False): 14 | """ 15 | There is nothing to save. This form is a building block of a formset 16 | with read-only contents. 17 | """ 18 | pass 19 | 20 | def __init__(self, *args, **kwargs): 21 | """ 22 | All Fields are disabled. They are rendered as read-only fields. 23 | """ 24 | super().__init__(*args, **kwargs) 25 | for field in self.fields.keys(): 26 | if field == "agree": 27 | continue 28 | self.fields[field].disabled = True 29 | 30 | class Media: 31 | css = getattr(settings, "LETSAGREE_CSS", dict()) 32 | js = getattr(settings, "LETSAGREE_JS", tuple()) 33 | 34 | class Meta: 35 | """ 36 | The fields here are not fine-grained based on the active language because 37 | get_language() has contenxt only within the request/response cycle. 38 | In this case, it happens within the View insance where modelformset_factory 39 | is initialized with the appropriate fields. 40 | 41 | If needed, the default language should be explicitly queried from the 42 | settings.DEFAULT_LANGUAGE. 43 | 44 | In this case, however, the modelform is not enabled in the admin. 45 | """ 46 | 47 | model = models.Term 48 | fields = "__all__" 49 | -------------------------------------------------------------------------------- /letsagree/helpers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from django.conf import settings 4 | from django.urls import reverse, NoReverseMatch 5 | 6 | 7 | def get_named_url(): 8 | app_name = getattr(settings, "LETSAGREE_LOGOUT_APP_NAME", False) 9 | logout_named_url = getattr(settings, "LETSAGREE_LOGOUT_URL", "admin:logout") 10 | 11 | if app_name: 12 | return "{0}:logout".format(app_name) 13 | else: 14 | return logout_named_url 15 | 16 | 17 | def get_logout_url(): 18 | try: 19 | return reverse(get_named_url()) 20 | except NoReverseMatch: 21 | return "" 22 | -------------------------------------------------------------------------------- /letsagree/middleware.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from django.conf import settings 4 | from django.core.cache import cache 5 | from django.shortcuts import redirect 6 | from django.urls import reverse 7 | from letsagree.models import Term 8 | from letsagree.helpers import get_logout_url 9 | 10 | 11 | class LetsAgreeMiddleware: 12 | def __init__(self, get_response): 13 | self.get_response = get_response 14 | 15 | def __call__(self, request): 16 | redirect_url = ConsentEvaluator(request).get_redirect_url() 17 | if redirect_url: 18 | return redirect(redirect_url) 19 | else: 20 | return self.get_response(request) 21 | 22 | 23 | class ConsentEvaluator: 24 | def __init__(self, request): 25 | self.user_id = request.user.id 26 | self.path = request.path 27 | self.request_needs_investigation = self.validate_user_intention() 28 | self.get_next = request.GET.get("next") or request.path 29 | 30 | def validate_user_intention(self): 31 | """ 32 | Check if a logged user exists and whether this user is requesting a url 33 | that requires previous consent of the relevant tos. 34 | excluded urls form consent are: 35 | * The letsagree form consent url 36 | * The logout url 37 | * All letsagree admin urls, except for the request to add a new tos. 38 | (the user who is entitled to add a new tos, has only the right to 39 | do so if he has already agreed to the current term in effect.) 40 | * 41 | """ 42 | logout_url = get_logout_url() 43 | satisfied_prerequisites = all( 44 | ( 45 | self.user_id, 46 | self.path != reverse("letsagree:pending"), 47 | self.path != logout_url, 48 | any( 49 | ( 50 | not self.path.startswith( 51 | reverse("admin:app_list", args=("letsagree",)) 52 | ), 53 | "add" in self.path, 54 | ) 55 | ), 56 | ) 57 | ) 58 | return satisfied_prerequisites 59 | 60 | def get_or_set_cache(self, cache_key): 61 | """ 62 | if cache exists: 63 | return its value 64 | else: 65 | set cache and return its new value 66 | """ 67 | user_consent_required = cache.get(cache_key, None) 68 | 69 | if user_consent_required is None: 70 | cache_value = Term.objects.get_pending_terms(self.user_id).exists() 71 | cache.set(cache_key, cache_value, 24 * 3600) 72 | return cache_value 73 | else: 74 | return user_consent_required 75 | 76 | def consent_is_required(self): 77 | """ 78 | Return True if consent is required, else False. 79 | If cache is enabled: 80 | * check or set the cache 81 | else; 82 | * query for the status 83 | 84 | By default cache is deactivated because it exposes the user id which 85 | uniquely identifies a user in the database. Nonetheless, 86 | django-hashid-field (https://github.com/nshafer/django-hashid-field), 87 | can obscure the user id while keeping its uniqueness. 88 | """ 89 | if getattr(settings, "LETSAGREE_CACHE", False): 90 | cache_key = "letsagree-{0}".format(self.user_id) 91 | return self.get_or_set_cache(cache_key) 92 | 93 | else: 94 | return Term.objects.get_pending_terms(self.user_id).exists() 95 | 96 | def get_next_parameter(self): 97 | """ 98 | If next parameter exists in request, set it also in the redirect 99 | unless it equals the url of the consent form. 100 | """ 101 | return ( 102 | "?next={0}".format(self.get_next) 103 | if self.get_next and self.get_next != reverse("letsagree:pending") 104 | else None 105 | ) 106 | 107 | def get_redirect_url(self): 108 | """ 109 | If user has already agreed: 110 | * Return None 111 | else: 112 | * Return Redirect URL 113 | """ 114 | result = None 115 | if self.request_needs_investigation: 116 | if self.consent_is_required(): 117 | redirect_url = reverse("letsagree:pending") 118 | next_url = self.get_next_parameter() 119 | result = "{0}{1}".format(redirect_url, next_url) 120 | return result 121 | -------------------------------------------------------------------------------- /letsagree/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /letsagree/models.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from django.db import models 5 | from django.contrib.auth import get_user_model 6 | from django.contrib.auth.models import Group 7 | from django.utils import timezone 8 | from django.utils.translation import gettext_lazy as _ 9 | from letsagree.querysets import TermQS 10 | from translated_fields import TranslatedFieldWithFallback 11 | 12 | 13 | class BaseRelatedManager(models.Manager): 14 | def get_queryset(self): 15 | return super().get_queryset().select_related() 16 | 17 | 18 | class Term(models.Model): 19 | group_key = models.ForeignKey( 20 | Group, 21 | on_delete=models.PROTECT, 22 | related_name="terms", 23 | verbose_name=_("Related Group"), 24 | ) 25 | title = TranslatedFieldWithFallback( 26 | models.CharField(max_length=63, verbose_name=_("Title"), db_index=True) 27 | ) 28 | summary = TranslatedFieldWithFallback(models.TextField(verbose_name=_("Summary"))) 29 | content = TranslatedFieldWithFallback( 30 | models.TextField(verbose_name=_("Terms and Conditions")) 31 | ) 32 | 33 | date_created = models.DateTimeField( 34 | verbose_name=_("Date and Time of Document Creation"), 35 | default=timezone.now, 36 | db_index=True, 37 | ) 38 | 39 | objects = BaseRelatedManager.from_queryset(TermQS)() 40 | 41 | def __str__(self): 42 | return '{0}, Group:"{1}" on {2}, {3}'.format( 43 | self.id, 44 | self.group_key.name, 45 | self.date_created.strftime("%Y-%m-%d-%T"), 46 | self.title, 47 | ) 48 | 49 | class Meta: 50 | verbose_name = _("Terms & Conditions") 51 | verbose_name_plural = _("Terms & Conditions") 52 | 53 | 54 | class NotaryPublic(models.Model): 55 | user_key = models.ForeignKey( 56 | get_user_model(), 57 | verbose_name=_("User"), 58 | on_delete=models.PROTECT, 59 | related_name="agreed_terms", 60 | ) 61 | term_key = models.ForeignKey( 62 | Term, 63 | verbose_name=_("Terms and Conditions"), 64 | on_delete=models.PROTECT, 65 | related_name="users_agreed", 66 | ) 67 | date_signed = models.DateTimeField( 68 | verbose_name=_("Date and Time of User Consent"), 69 | default=timezone.now, 70 | db_index=True, 71 | ) 72 | 73 | objects = BaseRelatedManager() 74 | 75 | def __str__(self): 76 | return "{0}: User:{1}, Term-id:{2}".format( 77 | self.id, self.user_key.username, self.term_key_id 78 | ) 79 | 80 | class Meta: 81 | verbose_name = _("Notary Public") 82 | verbose_name_plural = _("Notary Public") 83 | unique_together = ("user_key", "term_key") 84 | -------------------------------------------------------------------------------- /letsagree/querysets.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from django.db import models 5 | from django.db.models import F, Window 6 | from django.db.models.functions import FirstValue 7 | 8 | 9 | class TermQS(models.QuerySet): 10 | def prepare_active_terms(self, user_id): 11 | """ 12 | The query opens a window, groups all entries by the group they belong to, 13 | orders them by the date and annotates the first one of each group 14 | to a field called 'active_terms'. 15 | """ 16 | return ( 17 | self.filter(group_key__user=user_id) 18 | .annotate( 19 | active_terms=Window( 20 | expression=FirstValue("id"), 21 | partition_by=["group_key"], 22 | order_by=F("date_created").desc(), 23 | ) 24 | ) 25 | .distinct() 26 | ) 27 | 28 | def get_pending_terms(self, user_id): 29 | """ 30 | Query that returns the user's pending to be signed terms. 31 | 32 | 1 db hit. 33 | 34 | values_list('active_terms') returns only the latest active term 35 | which is currently in effect for each group. 36 | 37 | Consequently, it is possible for a user to miss -and never agree with- 38 | a term that was in effect in a period during which he did not 39 | happen to log in. He will agree, however, to the newest term that 40 | follows the missed one. 41 | """ 42 | return self.filter( 43 | id__in=self.prepare_active_terms(user_id).values_list("active_terms") 44 | ).exclude(users_agreed__user_key_id=user_id) 45 | 46 | def get_signed_agreements(self, user_id): 47 | """ 48 | Query that returns the user's signed agreements. 49 | 50 | 2 db hits 51 | 52 | This query prefetches, for each term, the agreement made by the user, 53 | in order to allow access without hitting the database when 54 | the date of the signature has to be retrieved. One agreement per 55 | term is expected, hoewever and order_by is provided in case more 56 | agreements exist. 57 | 58 | values_list('active_terms') returns only the latest term 59 | which is currently in effect for each group. 60 | """ 61 | from letsagree.models import NotaryPublic 62 | 63 | return ( 64 | self.filter( 65 | id__in=self.prepare_active_terms(user_id).values_list("active_terms") 66 | ) 67 | .filter(users_agreed__user_key_id=user_id) 68 | .prefetch_related( 69 | models.Prefetch( 70 | "users_agreed", 71 | queryset=( 72 | NotaryPublic.objects.filter(user_key_id=user_id).order_by( 73 | "-date_signed" 74 | ) 75 | ), 76 | to_attr="signature_dates", 77 | ) 78 | ) 79 | ) 80 | -------------------------------------------------------------------------------- /letsagree/static/letsagree/letsagree.css: -------------------------------------------------------------------------------- 1 | input[type="text"], 2 | input[type="password"], 3 | input[type="email"], 4 | input[type="url"], 5 | input[type="number"], 6 | input[type="tel"], 7 | textarea, 8 | select, 9 | .vTextField { 10 | border: None; 11 | background: #fff; 12 | color: #000; 13 | display: block; 14 | } 15 | form > p:nth-of-type(4n) { 16 | border-bottom: solid 1px; 17 | margin-bottom: 20px; 18 | padding-bottom: 20px; 19 | font-weight: bold; 20 | text-decoration: underline; 21 | } 22 | -------------------------------------------------------------------------------- /letsagree/templates/letsagree/pending.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/index.html' %} 2 | {% load i18n %} 3 | 4 | {% block title %} 5 | {{ browser_title }} 6 | {% endblock %} 7 | 8 | {% block extrastyle %} 9 | {{ block.super }}{{ form.media }} 10 | {% endblock %} 11 | 12 | {% block branding %} 13 |

{{ border_header }}

14 | {% endblock %} 15 | 16 | {% block breadcrumbs %} 17 | 26 | {% endblock %} 27 | 28 | {% block usertools %} 29 |
30 | 31 | {% block welcome-msg %} 32 | {% if user.get_short_name or user.get_username %} 33 | {% trans "Welcome," %} 34 | {% firstof user.get_short_name user.get_username %}. 35 | {% endif %} 36 | {% endblock %} 37 | 38 | {% block userlinks %} 39 | {% if site_url %} 40 | {% trans "View site" %} / 41 | {% endif %} 42 | {% if user.is_staff %} 43 | {% trans "Admin Site" %} 44 | {% endif %} 45 | {% if user.get_short_name or user.get_username and logout_url %} 46 | / {% trans "Log out" %} 47 | {% endif %} 48 | {% endblock %} 49 | 50 |
51 | {% endblock %} 52 | 53 | {% block content %} 54 |

{% trans "Pending Agreements" %}

55 | {% if empty_form %} 56 |

{% trans "There are no pending agreements" %}

57 | {% else %} 58 |
59 | {% csrf_token %} 60 | {{ form.as_p }} 61 | 62 |
63 | {% endif %} 64 | {% endblock %} 65 | 66 | {% block sidebar %} 67 | {% endblock %} 68 | -------------------------------------------------------------------------------- /letsagree/tests/conftest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import pytest 5 | from collections import namedtuple 6 | from django.contrib.auth.models import AnonymousUser, Permission 7 | from django.db.models import Q 8 | from django.test import RequestFactory 9 | from itertools import tee, chain 10 | from letsagree.tests import factories 11 | from pytest_factoryboy import register 12 | from unittest.mock import Mock 13 | 14 | 15 | register(factories.GroupFactory) 16 | register(factories.UserFactory) 17 | register(factories.TermFactory) 18 | register(factories.NotaryPublicFactory) 19 | 20 | 21 | @pytest.fixture 22 | def queries(user_factory, group_factory, term_factory, notary_public_factory): 23 | """ 24 | This factory creates 3 users and 7 groups. 25 | * Not agreed: Belongs to 3 groups, has not agreed to any terms 26 | * Agreed: Belongs to 1 group, has agreed to the terms 27 | * Belongs to 2 groups, has a greed to 1 of 2 terms 28 | """ 29 | factory = RequestFactory() 30 | groups = group_factory.create_batch(7) 31 | group1, group2 = tee(iter(groups)) 32 | for group in chain.from_iterable(zip(group1, group2)): 33 | term_factory.create(group_key=group) 34 | 35 | user_not_agreed = user_factory.create(groups=groups[:3]) 36 | user_agreed = user_factory.create(groups=(groups[3],)) 37 | user_agreed_pending = user_factory.create(groups=(groups[4], groups[5])) 38 | notary_agreed = notary_public_factory.create( 39 | user_key=user_agreed, term_key=groups[3].terms.last() 40 | ) 41 | notary_agreed_pending = notary_public_factory.create( 42 | user_key=user_agreed_pending, term_key=groups[4].terms.last() 43 | ) 44 | 45 | def status(terms_agreed=False, request_url="/"): 46 | """ 47 | Returns mainly a request for the following terms_agreed cases: 48 | AnonymousUser: Not logged in user 49 | AgreedAndPending: User that agreed to one term but not to another 50 | False: Has not agreed to any of the existing terms 51 | True/Whatever: Has agreed to all terms 52 | """ 53 | request = factory.get(request_url) 54 | if terms_agreed == "AnonymousUser": 55 | request.user = AnonymousUser() 56 | Setup = namedtuple("Setup", ["request", "response"]) 57 | return Setup(request=request, response=Mock()) 58 | elif terms_agreed == "AgreedAndPending": 59 | request.user = user_agreed_pending 60 | Setup = namedtuple("Setup", ["request", "response", "notary"]) 61 | return Setup(request=request, response=Mock(), notary=notary_agreed_pending) 62 | elif not terms_agreed: 63 | request.user = user_not_agreed 64 | Setup = namedtuple("Setup", ["request", "response"]) 65 | return Setup(request=request, response=Mock()) 66 | else: 67 | request.user = user_agreed 68 | Setup = namedtuple("Setup", ["request", "response", "notary"]) 69 | return Setup(request=request, response=Mock(), notary=notary_agreed) 70 | 71 | return status 72 | 73 | 74 | @pytest.fixture 75 | def users(user_factory): 76 | """ 77 | Return either: 78 | * a superuser 79 | * a staff user with all available permissions for notarypublic and term 80 | """ 81 | permissions = Permission.objects.filter( 82 | Q(codename__contains="notarypublic") | Q(codename__contains="term") 83 | ) 84 | staff_user = user_factory.create(is_superuser=False, user_permissions=permissions) 85 | super_user = user_factory.create(is_superuser=True) 86 | 87 | def _send(user): 88 | user_choices = { 89 | "staff": staff_user, 90 | "sup_er": super_user, 91 | } 92 | return user_choices[user] 93 | 94 | return _send 95 | 96 | 97 | @pytest.fixture 98 | def many_terms(term_factory): 99 | return term_factory.create_batch(20) 100 | 101 | 102 | @pytest.fixture 103 | def many_terms_one_view_user(term_factory, user_factory, group): 104 | permissions = Permission.objects.filter( 105 | Q(Q(codename__contains="notarypublic") & Q(codename__contains="view")) 106 | | Q(Q(codename__contains="term") & Q(codename__contains="view")) 107 | ) 108 | term_factory.create_batch(20) 109 | staff_view_user = user_factory.create( 110 | is_superuser=False, groups=(group,), user_permissions=permissions 111 | ) 112 | term_factory.create(group_key=group) 113 | return staff_view_user 114 | 115 | 116 | @pytest.fixture 117 | def agreed_users(notary_public_factory, users): 118 | Users = namedtuple("Users", ["staff", "sup_er"]) 119 | the_users = Users(staff=users("staff"), sup_er=users("sup_er")) 120 | for each in the_users: 121 | notary_public_factory.create(user_key=each) 122 | notary_public_factory.create(user_key=each) 123 | notary_public_factory.create(user_key=each) 124 | notary_public_factory.create(user_key=each) 125 | notary_public_factory.create(user_key=each) 126 | return the_users 127 | -------------------------------------------------------------------------------- /letsagree/tests/factories.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import factory 4 | 5 | from django.conf import settings 6 | from django.contrib.auth import get_user_model 7 | from django.contrib.auth.models import Group 8 | from letsagree import models 9 | 10 | 11 | class UserFactory(factory.django.DjangoModelFactory): 12 | class Meta: 13 | model = get_user_model() 14 | 15 | username = factory.Faker("user_name") 16 | password = factory.Faker("password") 17 | email = factory.Faker("email") 18 | is_active = True 19 | is_staff = True 20 | is_superuser = factory.Iterator([False, True], cycle=True) 21 | 22 | @factory.post_generation 23 | def groups(self, create, extracted, **kwargs): 24 | if not create: 25 | # Simple build, do nothing. 26 | return 27 | 28 | if extracted: 29 | # A list of groups were passed in, use them 30 | for group in extracted: 31 | self.groups.add(group) 32 | 33 | @factory.post_generation 34 | def user_permissions(self, create, extracted, **kwargs): 35 | if not create: 36 | # Simple build, do nothing. 37 | return 38 | 39 | if extracted: 40 | # A list of groups were passed in, use them 41 | for permission in extracted: 42 | self.user_permissions.add(permission) 43 | 44 | 45 | Meta = type("Meta", (), {"model": models.Term}) 46 | main = { 47 | "__module__": "letsagree.tests.factories", 48 | "group_key": factory.SubFactory("letsagree.tests.factories.GroupFactory"), 49 | "Meta": Meta, 50 | } 51 | summaries = { 52 | "summary_{0}".format(lang[0]): factory.Faker("paragraphs", nb=3) 53 | for lang in settings.LANGUAGES 54 | } 55 | contents = { 56 | "content_{0}".format(lang[0]): factory.Faker("paragraphs", nb=12) 57 | for lang in settings.LANGUAGES 58 | } 59 | attrs = {**main, **summaries, **contents} 60 | 61 | TermFactory = type("TermFactory", (factory.django.DjangoModelFactory,), attrs) 62 | 63 | 64 | class GroupFactory(factory.django.DjangoModelFactory): 65 | class Meta: 66 | model = Group 67 | 68 | name = factory.Sequence(lambda n: "Group {0}".format(n)) 69 | # terms = factory.RelatedFactory(TermFactory, 'group_key') 70 | 71 | 72 | class NotaryPublicFactory(factory.django.DjangoModelFactory): 73 | class Meta: 74 | model = models.NotaryPublic 75 | 76 | user_key = factory.SubFactory(UserFactory) 77 | term_key = factory.SubFactory(TermFactory) 78 | -------------------------------------------------------------------------------- /letsagree/tests/test_admin.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import pytest 5 | 6 | from django.contrib.admin.sites import AdminSite 7 | from django.core.exceptions import PermissionDenied 8 | from django.test.client import Client 9 | from django.urls import reverse 10 | from letsagree import admin, models 11 | 12 | 13 | pytestmark = pytest.mark.django_db 14 | 15 | site = AdminSite() 16 | 17 | 18 | @pytest.mark.parametrize( 19 | "user,action", 20 | [ 21 | ("staff", "delete"), 22 | ("staff", "change"), 23 | ("staff", "add"), 24 | ("sup_er", "delete"), 25 | ("sup_er", "change"), 26 | ("sup_er", "add"), 27 | ], 28 | ) 29 | def test_term_actions(users, user, action, term, rf): 30 | model_admin = admin.TermAdmin(models.Term, site) 31 | 32 | if action == "add": 33 | url = reverse("admin:letsagree_term_{0}".format(action)) 34 | else: 35 | url = reverse("admin:letsagree_term_{0}".format(action), args=(term.id,)) 36 | 37 | request = rf.get(url) 38 | request.user = users(user) 39 | 40 | view_obj = getattr(model_admin, "{0}_view".format(action)) 41 | 42 | # No one can delete 43 | if action == "delete": 44 | with pytest.raises(PermissionDenied): 45 | view_obj(request, object_id=str(term.id)) 46 | 47 | # Every admin user with permissions can view but cannot change 48 | elif action == "change": 49 | view = view_obj(request, object_id=str(term.id)) 50 | assert view.status_code == 200 51 | assert "delete" not in view.rendered_content 52 | assert "readonly" in view.rendered_content 53 | assert ' 1 85 | assert qs.count() == 1 86 | 87 | 88 | @pytest.mark.parametrize( 89 | "main_user,other_user,action", 90 | [ 91 | ("staff", "sup_er", "delete"), 92 | ("staff", "sup_er", "change"), 93 | ("staff", "sup_er", "add"), 94 | ("sup_er", "staff", "delete"), 95 | ("sup_er", "staff", "change"), 96 | ("sup_er", "staff", "add"), 97 | ], 98 | ) 99 | def test_notarypublic_actions(main_user, other_user, action, agreed_users, rf): 100 | model_admin = admin.NotaryPublicAdmin(models.NotaryPublic, site) 101 | active_user = getattr(agreed_users, main_user) 102 | inactive_user = getattr(agreed_users, other_user) 103 | agreement_id = active_user.agreed_terms.last().id 104 | 105 | if action == "add": 106 | url = reverse("admin:letsagree_notarypublic_{0}".format(action)) 107 | else: 108 | url = reverse( 109 | "admin:letsagree_notarypublic_{0}".format(action), args=(agreement_id,) 110 | ) 111 | 112 | request = rf.get(url) 113 | 114 | # A user can only delete his agreements 115 | if action == "delete": 116 | request.user = active_user 117 | view = model_admin.delete_view(request, object_id=str(agreement_id)) 118 | assert view.status_code == 200 119 | data = {"post": "yes", "_popup": "1"} 120 | client = Client() 121 | client.force_login(request.user) 122 | post_view = client.post(url, data) 123 | assert post_view.status_code == 302 124 | assert post_view.url == reverse("admin:letsagree_notarypublic_changelist") 125 | # Another (super)user cannot delete the active user's agreement 126 | request.user = inactive_user 127 | with pytest.raises(PermissionDenied): 128 | model_admin.delete_view(request, object_id=str(agreement_id)) 129 | 130 | elif action == "add": 131 | # Noone is allowed to add any agreement. 132 | request.user = active_user 133 | with pytest.raises(PermissionDenied): 134 | model_admin.add_view(request) 135 | 136 | elif action == "change": 137 | # The active user can view the agreement (in order to delete it) 138 | request.user = active_user 139 | view = model_admin.change_view(request, object_id=str(agreement_id)) 140 | 141 | assert view.status_code == 200 142 | assert "delete" in view.rendered_content 143 | assert "readonly" in view.rendered_content 144 | assert ' response_count = 0 50 | (False, False, "/", 0, 1), 51 | (True, False, "/", 0, 1), 52 | ( # Edge case: Avoid recursion with the 'next' parameter. 53 | False, 54 | False, 55 | "{0}/?next={0}".format(reverse("letsagree:pending")), 56 | 0, 57 | 1, 58 | ), 59 | ], 60 | ) 61 | def test_middleware( 62 | queries, 63 | settings, 64 | django_assert_num_queries, 65 | queries_count, 66 | request_url, 67 | response_count, 68 | cache_enabled, 69 | terms_agreed, 70 | ): 71 | """ 72 | Anonymous user: Always 0 database hits expected. 73 | Logged in user: 74 | * Always 1 database hit expected 75 | * If cache is enabled, from the second middleware call onwards, 76 | 0 database hits expected. 77 | 0 response count = the middleware redirects in order to accept terms. 78 | 1 response count = the middleware calls get_response() and the reqest 79 | proceeds according to the user's whish. 80 | """ 81 | settings.LETSAGREE_CACHE = cache_enabled 82 | settings.LETSAGREE_LOGOUT_APP_NAME = "admin" 83 | setup = queries(terms_agreed, request_url) 84 | cache.delete("letsagree-{0}".format(setup.request.user.id)) 85 | with django_assert_num_queries(queries_count): 86 | middleware = LetsAgreeMiddleware(setup.response) 87 | middleware(setup.request) 88 | assert setup.response.call_count == response_count 89 | setup.response.reset_mock() 90 | if cache_enabled: 91 | with django_assert_num_queries(0): 92 | middleware = LetsAgreeMiddleware(setup.response) 93 | middleware(setup.request) 94 | assert setup.response.call_count == response_count 95 | setup.response.reset_mock() 96 | -------------------------------------------------------------------------------- /letsagree/tests/test_models.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import pytest 5 | 6 | from letsagree import models 7 | 8 | 9 | pytestmark = pytest.mark.django_db 10 | 11 | 12 | def test_strings(queries, django_assert_num_queries): 13 | queries(terms_agreed=True) 14 | with django_assert_num_queries(1): 15 | term = models.Term.objects.first() 16 | string = term.__str__() 17 | 18 | group_name = term.group_key.name 19 | assert str(group_name) in string 20 | assert str(term.id) in string 21 | assert str(term.date_created.strftime("%Y-%m-%d-%T")) in string 22 | 23 | with django_assert_num_queries(1): 24 | notary_public = models.NotaryPublic.objects.first() 25 | string = notary_public.__str__() 26 | 27 | assert str(notary_public.id) in string 28 | assert str(notary_public.term_key_id) in string 29 | -------------------------------------------------------------------------------- /letsagree/tests/test_querysets.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import pytest 5 | import sqlite3 6 | 7 | from collections import defaultdict 8 | from django.conf import settings 9 | from letsagree.models import Term 10 | 11 | pytestmark = pytest.mark.django_db 12 | 13 | 14 | @pytest.mark.skipif( 15 | all( 16 | ( 17 | float(".".join(sqlite3.sqlite_version.split(".")[:2])) < 3.25, 18 | "sqlite3" in settings.DATABASES["default"]["ENGINE"], 19 | ) 20 | ), 21 | reason="Window Function is not supported in this SQLite version", 22 | ) 23 | def test_pending(queries, django_assert_num_queries): 24 | setup = queries(terms_agreed=False) 25 | # The user is member of 3 groups 26 | assert setup.request.user.groups.count() == 3 27 | terms_per_group = setup.request.user.groups.values_list("id", "terms") 28 | d = defaultdict(list) 29 | for k, v in d: 30 | terms_per_group[k].append(v) 31 | # Each group is associated with a newer and an older version of terms 32 | for v in terms_per_group.values(): 33 | assert len(v) == 2 34 | # The user has not agreed to any term 35 | assert setup.request.user.agreed_terms.count() == 0 36 | # The maximum number of terms that the user would have agreed is 6: 37 | # ( 3 missed and 3 pending ) 38 | assert setup.request.user.groups.filter(terms__users_agreed__id=None).count() == 6 39 | # The user will be asked to agree on the most recent version of the terms 40 | # of each group he belongs to. 41 | with django_assert_num_queries(1): 42 | assert Term.objects.get_pending_terms(setup.request.user).count() == 3 43 | 44 | 45 | @pytest.mark.skipif( 46 | all( 47 | ( 48 | float(".".join(sqlite3.sqlite_version.split(".")[:2])) < 3.25, 49 | "sqlite3" in settings.DATABASES["default"]["ENGINE"], 50 | ) 51 | ), 52 | reason="Window Function is not supported in this SQLite version", 53 | ) 54 | def test_agreed(queries, django_assert_num_queries): 55 | setup = queries(terms_agreed=True) 56 | # The user is member of 1 grroup 57 | assert setup.request.user.groups.count() == 1 58 | terms_per_group = setup.request.user.groups.values_list("id", "terms") 59 | d = defaultdict(list) 60 | for k, v in d: 61 | terms_per_group[k].append(v) 62 | # Each group is associated with a newer and an older version of terms 63 | for v in terms_per_group.values(): 64 | assert len(v) == 2 65 | # The user has agreed to one term 66 | assert setup.request.user.agreed_terms.count() == 1 67 | # The terms that the user would have agreed but missed is 1: 68 | assert setup.request.user.groups.filter(terms__users_agreed__id=None).count() == 1 69 | # The user will not be asked to agree on any term 70 | with django_assert_num_queries(1): 71 | assert Term.objects.get_pending_terms(setup.request.user).count() == 0 72 | # The query of agreed terms returns 1 73 | with django_assert_num_queries(1): 74 | assert Term.objects.get_signed_agreements(setup.request.user).count() == 1 75 | 76 | 77 | @pytest.mark.skipif( 78 | all( 79 | ( 80 | float(".".join(sqlite3.sqlite_version.split(".")[:2])) < 3.25, 81 | "sqlite3" in settings.DATABASES["default"]["ENGINE"], 82 | ) 83 | ), 84 | reason="Window Function is not supported in this SQLite version", 85 | ) 86 | def test_agreed_pending(queries, django_assert_num_queries): 87 | setup = queries(terms_agreed="AgreedAndPending") 88 | # The user is member of 2 grroup 89 | assert setup.request.user.groups.count() == 2 90 | terms_per_group = setup.request.user.groups.values_list("id", "terms") 91 | d = defaultdict(list) 92 | for k, v in d: 93 | terms_per_group[k].append(v) 94 | # Each group is associated with a newer and an older version of terms 95 | for v in terms_per_group.values(): 96 | assert len(v) == 2 97 | # The user has agreed to one term 98 | assert setup.request.user.agreed_terms.count() == 1 99 | # The terms that the user would have agreed but missed are 3: 100 | assert setup.request.user.groups.filter(terms__users_agreed__id=None).count() == 3 101 | # The user will be asked to agree on 1 term 102 | with django_assert_num_queries(1): 103 | assert Term.objects.get_pending_terms(setup.request.user).count() == 1 104 | # The query of agreed terms returns 1 105 | with django_assert_num_queries(1): 106 | assert Term.objects.get_signed_agreements(setup.request.user).count() == 1 107 | -------------------------------------------------------------------------------- /letsagree/tests/test_views.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import pytest 5 | import re 6 | import sqlite3 7 | 8 | from django.conf import settings 9 | from django.test import RequestFactory 10 | from django.urls import reverse 11 | from letsagree import views, models # , forms 12 | 13 | pytestmark = pytest.mark.django_db 14 | 15 | 16 | @pytest.mark.skipif( 17 | all( 18 | ( 19 | float(".".join(sqlite3.sqlite_version.split(".")[:2])) < 3.25, 20 | "sqlite3" in settings.DATABASES["default"]["ENGINE"], 21 | ) 22 | ), 23 | reason="Window Function is not supported in this SQLite version", 24 | ) 25 | @pytest.mark.parametrize( 26 | "terms_agreed,request_url,agree_queries", [(True, "/", 2), (False, "/", 1)] 27 | ) 28 | def test_view_structure( 29 | queries, 30 | django_assert_num_queries, 31 | settings, 32 | terms_agreed, 33 | request_url, 34 | agree_queries, 35 | ): 36 | setup = queries(terms_agreed, request_url) 37 | settings.LETSAGREE_BORDER_HEADER = "THE HEADER" 38 | settings.LETSAGREE_BROWSER_TITLE = "My Title" 39 | 40 | # Set once the logout url to test if it is rendered 41 | if terms_agreed: 42 | settings.LETSAGREE_LOGOUT_APP_NAME = None # By default is 'admin' 43 | else: 44 | settings.LETSAGREE_LOGOUT_APP_NAME = "foo" 45 | # Test Pending View 46 | with django_assert_num_queries(1): 47 | the_view = views.PendingView.as_view() 48 | response = the_view(setup.request) 49 | assert response.status_code == 200 50 | assert re.search(r"title>[\r\n]?My Title", response.rendered_content) 51 | assert "THE HEADER" in response.rendered_content 52 | 53 | if terms_agreed: 54 | assert "There are no pending agreements" in response.rendered_content 55 | assert reverse("admin:logout") in response.rendered_content 56 | elif terms_agreed is None: 57 | assert "There are no pending agreements" not in response.rendered_content 58 | assert reverse("admin:logout") in response.rendered_content 59 | else: 60 | assert "There are no pending agreements" not in response.rendered_content 61 | assert "LOG OUT" not in response.rendered_content 62 | 63 | 64 | # @pytest.mark.parametrize( 65 | # "the_string,the_result", 66 | # [ 67 | # ("admin", "admin:logout"), 68 | # ("admin:", "admin:logout"), 69 | # ("admin:logout_view", "admin:logout_view"), 70 | # ("", None), 71 | # (False, None), 72 | # ], 73 | # ) 74 | # def test_named_url(the_string, the_result): 75 | # view = views.PendingView() 76 | # assert view.get_logout_string(the_string) == the_result 77 | 78 | 79 | @pytest.mark.skipif( 80 | all( 81 | ( 82 | float(".".join(sqlite3.sqlite_version.split(".")[:2])) < 3.25, 83 | "sqlite3" in settings.DATABASES["default"]["ENGINE"], 84 | ) 85 | ), 86 | reason="Window Function is not supported in this SQLite version", 87 | ) 88 | def test_404_redirect(client, admin_client): 89 | response = client.get("/letsagree/") 90 | assert response.status_code == 404 91 | response = admin_client.get("/letsagree/") 92 | assert response.status_code == 200 93 | 94 | 95 | @pytest.mark.skipif( 96 | all( 97 | ( 98 | float(".".join(sqlite3.sqlite_version.split(".")[:2])) < 3.25, 99 | "sqlite3" in settings.DATABASES["default"]["ENGINE"], 100 | ) 101 | ), 102 | reason="Window Function is not supported in this SQLite version", 103 | ) 104 | def test_view_post(queries, admin_client, settings): 105 | setup = queries(False) 106 | qs = models.Term.objects.get_pending_terms(setup.request.user.id) 107 | initial_data = qs.values() 108 | term_ids = qs.values_list("id") 109 | assert len(term_ids) == 3 110 | data = { 111 | "form-TOTAL_FORMS": "3", 112 | "form-INITIAL_FORMS": "3", 113 | "form-MAX_NUM_FORMS": "", 114 | "form-0-agree": True, 115 | "form-1-agree": True, 116 | "form-2-agree": True, 117 | } 118 | # Provide the data for a valid formset 119 | for count, item in enumerate(initial_data): 120 | for key, value in item.items(): 121 | key_name = "form-{0}-{1}".format(count, key) 122 | data[key_name] = value 123 | 124 | # # Test formset is valid and formset does not save to db 125 | # formset = forms.PendingAgreementFormSet(data=data) 126 | # assert formset.is_valid() 127 | # assert formset.save() == [None, None, None] 128 | 129 | # Create the post request 130 | factory = RequestFactory() 131 | request = factory.post("{0}?next=/".format(reverse("letsagree:pending")), data) 132 | request.user = setup.request.user 133 | # User has not agreed to any terms yet 134 | assert request.user.agreed_terms.count() == 0 135 | the_view = views.PendingView.as_view() 136 | response = the_view(request) 137 | assert response.status_code == 302 138 | # Success url is the next url provided 139 | # Edge case: Next url not set during post, will raise 500 error. Is it 140 | # possible? 141 | assert response.url == "/" 142 | assert request.user.agreed_terms.count() == 3 143 | assert set(request.user.agreed_terms.values_list("term_key_id")) == set(term_ids) 144 | -------------------------------------------------------------------------------- /letsagree/urls.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from django.urls import path 5 | from letsagree import views 6 | 7 | app_name = "letsagree" 8 | 9 | urlpatterns = [path("", views.PendingView.as_view(), name="pending")] 10 | -------------------------------------------------------------------------------- /letsagree/views.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from django.db import transaction 5 | from django.conf import settings 6 | from django.core.cache import cache 7 | from django.forms import modelformset_factory 8 | from django.http import Http404 9 | from django.utils.translation import gettext_lazy as _ 10 | from django.views.generic import FormView 11 | from translated_fields import to_attribute 12 | from letsagree import models 13 | from letsagree.forms import PendingConsentForm 14 | from letsagree.helpers import get_logout_url 15 | 16 | 17 | class PendingView(FormView): 18 | http_method_names = ["get", "post"] 19 | template_name = "letsagree/pending.html" 20 | 21 | def dispatch(self, request, *args, **kwargs): 22 | if not request.user.is_authenticated: 23 | raise Http404() 24 | self.success_url = request.GET.get("next") 25 | return super().dispatch(request, *args, **kwargs) 26 | 27 | def get_form_class(self): 28 | """ 29 | Initialize modelformset_factory within the FormView instance instead of 30 | the form_class because get_language() has a context only within the 31 | request/response cycle. 32 | 33 | https://code.djangoproject.com/ticket/31911#ticket 34 | https://github.com/matthiask/django-translated-fields/issues/24#issuecomment-678069602 35 | """ 36 | return modelformset_factory( 37 | models.Term, 38 | form=PendingConsentForm, 39 | extra=0, 40 | fields=( 41 | "date_created", 42 | to_attribute("summary"), 43 | to_attribute("content"), 44 | "agree", 45 | ), 46 | ) 47 | 48 | def get_form_kwargs(self): 49 | """ 50 | Pass to the modelformset_factory a queryset argument to create the 51 | formset that represents the terms needing the user's consent. 52 | """ 53 | kwargs = super().get_form_kwargs() 54 | # Avoid KeyError that randomly occurs 55 | user_id = self.request.user.id 56 | kwargs["queryset"] = models.Term.objects.get_pending_terms(user_id) 57 | return kwargs 58 | 59 | def form_valid(self, form): 60 | """ 61 | The user has agreed to the terms, save each agreement in the database. 62 | 63 | bulk_create could be used, but it is only compatible with PostgreSQL 64 | which, at the moment, is the only db able to handle autoincremented pk. 65 | """ 66 | user_id = self.request.user.id 67 | for sub_form in form: 68 | with transaction.atomic(): 69 | models.NotaryPublic.objects.create( 70 | term_key_id=sub_form.instance.id, user_key_id=user_id 71 | ) 72 | cache_key = "letsagree-{0}".format(user_id) 73 | cache.set(cache_key, False, 24 * 3600) 74 | return super().form_valid(form) 75 | 76 | def get_context_data(self, **kwargs): 77 | context = super().get_context_data(**kwargs) 78 | context["browser_title"] = getattr( 79 | settings, "LETSAGREE_BROWSER_TITLE", _("Let's Agree") 80 | ) 81 | context["border_header"] = getattr(settings, "LETSAGREE_BORDER_HEADER", "") 82 | context["user"] = self.request.user 83 | context["logout_url"] = get_logout_url() 84 | 85 | if len(context["form"]) == 0: 86 | context["empty_form"] = True 87 | else: 88 | context["empty_form"] = False 89 | 90 | return context 91 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [flake8] 5 | exclude=build,docs,.tox 6 | max-complexity=10 7 | max-line-length=88 8 | 9 | [coverage:run] 10 | branch = True 11 | relative_files = True 12 | include = 13 | *letsagree* 14 | omit = 15 | *migrations* 16 | *tests* 17 | *.tox* 18 | *test_setup* 19 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | import os 6 | from setuptools import find_packages, setup 7 | 8 | with open(os.path.join(os.path.dirname(__file__), "README.md")) as readme: 9 | README = readme.read() 10 | 11 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 12 | 13 | setup( 14 | name="django-letsagree", 15 | version="1.1.9", 16 | python_requires=">=3.7", 17 | description=( 18 | "A django application that associates Groups with Terms " 19 | "requiring consent from logged in members." 20 | ), 21 | long_description=README, 22 | long_description_content_type="text/markdown", 23 | url="https://github.com/raratiru/django-letsagree", 24 | author="George Tantiras", 25 | license="BSD 3-Clause License", 26 | packages=find_packages(exclude=("tests",)), 27 | include_package_data=True, 28 | install_requires=["Django>=2.2", "django-translated-fields"], 29 | zip_safe=False, 30 | classifiers=[ 31 | "Development Status :: 5 - Production/Stable", 32 | "Environment :: Web Environment", 33 | "Framework :: Django", 34 | "Framework :: Django :: 3.2", 35 | "Framework :: Django :: 4.0", 36 | "Framework :: Django :: 4.1", 37 | "Intended Audience :: Developers", 38 | "License :: OSI Approved :: BSD License", 39 | "Operating System :: OS Independent", 40 | "Programming Language :: Python", 41 | "Programming Language :: Python :: 3 :: Only", 42 | "Programming Language :: Python :: 3.7", 43 | "Programming Language :: Python :: 3.8", 44 | "Programming Language :: Python :: 3.9", 45 | "Programming Language :: Python :: 3.10", 46 | "Programming Language :: Python :: 3.11", 47 | "Natural Language :: English", 48 | "Topic :: Internet :: WWW/HTTP", 49 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 50 | "Topic :: Internet :: WWW/HTTP :: Site Management", 51 | ], 52 | ) 53 | -------------------------------------------------------------------------------- /test_setup/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raratiru/django-letsagree/cad20deede8928921aba30b0cb9544a9642fd728/test_setup/__init__.py -------------------------------------------------------------------------------- /test_setup/i18n_settings.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | # ============================================================================== 6 | # 7 | # File Name : test_setup/i18n_settings.py 8 | # 9 | # Creation Date : Tue 09 Apr 2019 01:19:13 AM EEST (01:19) 10 | # 11 | # Last Modified : Mon 15 Apr 2019 07:57:12 PM EEST (19:57) 12 | # 13 | # ============================================================================== 14 | 15 | import os 16 | from collections import namedtuple 17 | 18 | Settings = namedtuple("Settings", ["username", "password"]) 19 | 20 | db = { 21 | "django.db.backends.postgresql": Settings( 22 | username=os.environ.get("POSTGRES_USER", os.environ.get("TOX_DB_USER")), 23 | password=os.environ.get("POSTGRES_PASSWD", os.environ.get("TOX_DB_PASSWD")), 24 | ), 25 | "django.db.backends.mysql": Settings( 26 | username=os.environ.get("MARIADB_USER", os.environ.get("TOX_DB_USER")), 27 | password=os.environ.get("MARIADB_PASSWD", os.environ.get("TOX_DB_PASSWD")), 28 | ), 29 | } 30 | 31 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 32 | 33 | DATABASES = { 34 | "default": { 35 | "ENGINE": os.environ["TOX_DB_ENGINE"], 36 | "NAME": os.environ["TOX_DB_NAME"], 37 | "USER": db[os.environ["TOX_DB_ENGINE"]].username, 38 | "PASSWORD": db[os.environ["TOX_DB_ENGINE"]].password, 39 | "HOST": "127.0.0.1", 40 | "PORT": os.environ.get("TOX_DB_PORT"), 41 | } 42 | } 43 | 44 | 45 | INSTALLED_APPS = [ 46 | "django.contrib.admin", 47 | "django.contrib.auth", 48 | "django.contrib.contenttypes", 49 | "django.contrib.sessions", 50 | "django.contrib.messages", 51 | "django.contrib.staticfiles", 52 | "letsagree", 53 | ] 54 | 55 | LANGUAGE_CODE = "fr" 56 | 57 | LANGUAGES = (("fr", "French"), ("en", "English")) 58 | 59 | MIDDLEWARE = [ 60 | "django.middleware.security.SecurityMiddleware", 61 | "django.contrib.sessions.middleware.SessionMiddleware", 62 | "django.middleware.locale.LocaleMiddleware", 63 | "django.middleware.common.CommonMiddleware", 64 | "django.middleware.csrf.CsrfViewMiddleware", 65 | "django.contrib.auth.middleware.AuthenticationMiddleware", 66 | "django.contrib.messages.middleware.MessageMiddleware", 67 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 68 | "letsagree.middleware.LetsAgreeMiddleware", 69 | ] 70 | 71 | ROOT_URLCONF = "test_setup.urls" 72 | 73 | SECRET_KEY = "fsfie434*^%Dkkvkdnf8(*^@#()Fjvhdfi3))^%$" 74 | 75 | STATIC_URL = "/static/" 76 | 77 | TEMPLATES = [ 78 | { 79 | "BACKEND": "django.template.backends.django.DjangoTemplates", 80 | "DIRS": [], 81 | "APP_DIRS": True, 82 | "OPTIONS": { 83 | "context_processors": [ 84 | "django.template.context_processors.debug", 85 | "django.template.context_processors.request", 86 | "django.contrib.auth.context_processors.auth", 87 | "django.contrib.messages.context_processors.messages", 88 | "django.template.context_processors.i18n", 89 | ] 90 | }, 91 | } 92 | ] 93 | 94 | USE_I18N = True 95 | 96 | USE_L10N = True 97 | -------------------------------------------------------------------------------- /test_setup/requirements.txt: -------------------------------------------------------------------------------- 1 | Django==5.1.3 2 | django-translated-fields==0.13.0 -------------------------------------------------------------------------------- /test_setup/settings.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | # ============================================================================== 6 | # 7 | # File Name : test_setup/settings.py 8 | # 9 | # Creation Date : Tue 09 Apr 2019 01:19:13 AM EEST (01:19) 10 | # 11 | # Last Modified : Mon 15 Apr 2019 07:57:02 PM EEST (19:57) 12 | # 13 | # ============================================================================== 14 | 15 | import os 16 | from collections import namedtuple 17 | 18 | Settings = namedtuple("Settings", ["username", "password"]) 19 | 20 | db = { 21 | "django.db.backends.postgresql": Settings( 22 | username=os.environ.get("POSTGRES_USER", os.environ.get("TOX_DB_USER")), 23 | password=os.environ.get("POSTGRES_PASSWD", os.environ.get("TOX_DB_PASSWD")), 24 | ), 25 | "django.db.backends.mysql": Settings( 26 | username=os.environ.get("MARIADB_USER", os.environ.get("TOX_DB_USER")), 27 | password=os.environ.get("MARIADB_PASSWD", os.environ.get("TOX_DB_PASSWD")), 28 | ), 29 | } 30 | 31 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 32 | 33 | DATABASES = { 34 | "default": { 35 | "ENGINE": os.environ["TOX_DB_ENGINE"], 36 | "NAME": os.environ["TOX_DB_NAME"], 37 | "USER": db[os.environ["TOX_DB_ENGINE"]].username, 38 | "PASSWORD": db[os.environ["TOX_DB_ENGINE"]].password, 39 | "HOST": "127.0.0.1", 40 | "PORT": os.environ.get("TOX_DB_PORT"), 41 | } 42 | } 43 | 44 | INSTALLED_APPS = [ 45 | "django.contrib.admin", 46 | "django.contrib.auth", 47 | "django.contrib.contenttypes", 48 | "django.contrib.sessions", 49 | "django.contrib.messages", 50 | "django.contrib.staticfiles", 51 | "letsagree", 52 | ] 53 | 54 | LANGUAGE_CODE = "en" 55 | 56 | LANGUAGES = (("en", "English"),) 57 | 58 | MIDDLEWARE = [ 59 | "django.middleware.security.SecurityMiddleware", 60 | "django.contrib.sessions.middleware.SessionMiddleware", 61 | "django.middleware.common.CommonMiddleware", 62 | "django.middleware.csrf.CsrfViewMiddleware", 63 | "django.contrib.auth.middleware.AuthenticationMiddleware", 64 | "django.contrib.messages.middleware.MessageMiddleware", 65 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 66 | "letsagree.middleware.LetsAgreeMiddleware", 67 | ] 68 | 69 | ROOT_URLCONF = "test_setup.urls" 70 | 71 | SECRET_KEY = "fsfie4j234*^%Dkkvkdnf8(*^@#()Fjvhdfi3))^%$" 72 | 73 | STATIC_URL = "/static/" 74 | 75 | TEMPLATES = [ 76 | { 77 | "BACKEND": "django.template.backends.django.DjangoTemplates", 78 | "DIRS": [], 79 | "APP_DIRS": True, 80 | "OPTIONS": { 81 | "context_processors": [ 82 | "django.template.context_processors.debug", 83 | "django.template.context_processors.request", 84 | "django.contrib.auth.context_processors.auth", 85 | "django.contrib.messages.context_processors.messages", 86 | ] 87 | }, 88 | } 89 | ] 90 | 91 | USE_I18N = False 92 | 93 | USE_L10N = False 94 | -------------------------------------------------------------------------------- /test_setup/urls.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | # ============================================================================== 6 | # 7 | # File Name : test_setup.urls.py 8 | # 9 | # Creation Date : Tue 09 Apr 2019 03:25:45 AM EEST (03:25) 10 | # 11 | # Last Modified : Tue 09 Apr 2019 03:27:15 AM EEST (03:27) 12 | # 13 | # ============================================================================== 14 | 15 | 16 | from django.contrib import admin 17 | from django.urls import path, include 18 | 19 | urlpatterns = [ 20 | path("admin/", admin.site.urls), 21 | path("letsagree/", include("letsagree.urls")), 22 | ] 23 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{38,39,310,311}-django{32,41}-{postgres,mariadb}-{single_language,multi_language}, black 3 | [testenv] 4 | passenv = 5 | TOX_DB_NAME 6 | TOX_DB_USER 7 | TOX_DB_PASSWD 8 | MARIADB_USER 9 | MARIADB_PASSWD 10 | POSTGRES_USER 11 | POSTGRES_PASSWD 12 | TOX_DB_PORT 13 | PYTHONWARNINGS=always 14 | setenv = 15 | single_language: DJANGO_SETTINGS_MODULE=test_setup.settings 16 | multi_language: DJANGO_SETTINGS_MODULE=test_setup.i18n_settings 17 | postgres: TOX_DB_ENGINE=django.db.backends.postgresql 18 | postgres: TOX_DB_PORT=5432 19 | mariadb: TOX_DB_ENGINE=django.db.backends.mysql 20 | mariadb: TOX_DB_PORT=3306 21 | PYTHONPATH={toxinidir} 22 | deps = 23 | django-translated-fields 24 | pytest-django 25 | pytest-cov 26 | pytest-factoryboy 27 | django4: Django>=4.1 28 | django32: Django>=3.2,<3.3 29 | postgres: psycopg2-binary 30 | mariadb: mysqlclient 31 | commands = 32 | pytest -rs --nomigrations --cov --cov-branch 33 | [testenv:black] 34 | deps = 35 | flake8 36 | black 37 | bandit 38 | changedir = {toxinidir} 39 | commands = 40 | black . --check 41 | flake8 . 42 | bandit letsagree/* 43 | --------------------------------------------------------------------------------