├── .coveragerc ├── .github └── workflows │ └── django.yml ├── .gitignore ├── AUTHORS.txt ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── __init__.py ├── requirements.txt ├── runtests.py ├── setup.py ├── tos ├── __init__.py ├── admin.py ├── apps.py ├── middleware.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── signal_handlers.py ├── templates │ └── tos │ │ ├── tos.html │ │ └── tos_check.html ├── tests │ ├── __init__.py │ ├── templates │ │ ├── index.html │ │ └── registration │ │ │ └── login.html │ ├── test_middleware.py │ ├── test_models.py │ ├── test_urls.py │ └── test_views.py ├── urls.py └── views.py └── tos_i18n ├── __init__.py ├── admin.py ├── models.py ├── tests.py ├── translation.py └── views.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | # Regexes for lines to exclude from consideration 3 | exclude_lines = 4 | # Have to re-enable the standard pragma 5 | pragma: no cover 6 | 7 | # Don't complain about missing debug-only code: 8 | def __repr__ 9 | if self\.debug 10 | def __unicode__ 11 | def __repr__ 12 | if settings.DEBUG 13 | raise NotImplementedError 14 | from django\. 15 | 16 | # Don't complain if tests don't hit defensive assertion code: 17 | raise AssertionError 18 | raise NotImplementedError 19 | 20 | # Don't complain if non-runnable code isn't run: 21 | if 0: 22 | if __name__ == .__main__.: 23 | 24 | [run] 25 | omit = 26 | *tests* 27 | *migrations* 28 | *management* 29 | *urls* 30 | *site-packages* 31 | *src* 32 | *manage* 33 | *settings* 34 | -------------------------------------------------------------------------------- /.github/workflows/django.yml: -------------------------------------------------------------------------------- 1 | name: Django CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | strategy: 15 | max-parallel: 4 16 | matrix: 17 | include: 18 | - python-version: "3.9" 19 | django-version: Django==4.2 20 | 21 | - python-version: "3.10" 22 | django-version: Django==4.2 23 | 24 | - python-version: "3.10" 25 | django-version: Django==5.0 26 | 27 | - python-version: "3.11" 28 | django-version: Django==4.2 29 | 30 | - python-version: "3.11" 31 | django-version: Django==5.0 32 | 33 | - python-version: "3.12" 34 | django-version: Django==4.2 35 | 36 | - python-version: "3.12" 37 | django-version: Django==5.0 38 | 39 | - python-version: "3.13" 40 | django-version: Django==5.1 41 | steps: 42 | - uses: actions/checkout@v4 43 | - name: Set up Python ${{ matrix.python-version }} 44 | uses: actions/setup-python@v4 45 | with: 46 | python-version: ${{ matrix.python-version }} 47 | - name: Install Dependencies 48 | run: | 49 | python -m pip install --upgrade pip 50 | pip install coverage 51 | pip install ${{ matrix.django-version }} 52 | - name: Run Tests 53 | run: coverage run runtests.py 54 | 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .coverage 3 | htmlcov/ 4 | venv 5 | .python-version 6 | -------------------------------------------------------------------------------- /AUTHORS.txt: -------------------------------------------------------------------------------- 1 | django-tos is a Revolution Systems (https://www.revsys.com/) project. 2 | 3 | The following is a list of much appreciated contributors: 4 | 5 | Daniel Greenfeld 6 | Frank Wiles 7 | Krzysztof Dorosz 8 | akuryou 9 | George Dorn 10 | Nicholas Serra 11 | blag - https://github.com/blag 12 | Ali Karbassi - https://github.com/karbassi 13 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) Revolution Systems, LLC and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of django-tos nor the names of its contributors 15 | may be used to endorse or promote products derived from this software 16 | without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include tos/templates * 2 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | django-tos 3 | ========== 4 | 5 | This project gives the admin the ability to reset terms of agreement with the end users. It tracks when TOS are changed and when users agree to the new TOS. 6 | 7 | Summary 8 | ======= 9 | 10 | - Keeps track of when TOS is changed 11 | - Users need to be informed and agree/re-agree when they login (custom login is provided) 12 | - Just two models (TOS and user agreement) 13 | 14 | Terms Of Service Installation 15 | ============================= 16 | 17 | 1. ``pip install django-tos`` 18 | 19 | 2. Add ``tos`` to your ``INSTALLED_APPS`` setting. 20 | 21 | 3. Sync your database with ``python manage.py migrate`` 22 | 23 | Configuration 24 | ============= 25 | 26 | Options 27 | ``````` 28 | 29 | There are two ways to configure ``django-tos`` - either enable the TOS check when users sign in, or use middleware to enable the TOS check on every ``GET`` request. 30 | 31 | If you cannot override your login view (for instance, if you're using `django-allauth `_) you should use the second option. 32 | 33 | Option 1: TOS Check On Sign In 34 | `````````````````````````````` 35 | 36 | In your root urlconf file ``urls.py`` add: 37 | 38 | .. code-block:: python 39 | 40 | from tos.views import login 41 | 42 | # terms of service links 43 | urlpatterns += patterns('', 44 | url(r'^login/$', login, {}, 'auth_login',), 45 | url(r'^terms-of-service/', include('tos.urls')), 46 | ) 47 | 48 | Option 2: Middleware Check 49 | `````````````````````````` 50 | 51 | This option uses the ``incr`` methods for the configured Django cache. If you are using ``django-tos`` in a complex or parallel environment, be sure to use a cache backend that supports atomic increment operations. For more information, see the notes at the end of `this section of the Django documentation `_. 52 | 53 | Also, to ensure that warming the cache with users who can skip the agreement check works properly, you will need to include ``tos`` before your app (``myapp`` in the example) in your ``INSTALLED_APPS`` setting: 54 | 55 | .. code-block:: python 56 | 57 | INSTALLED_APPS = ( 58 | ... 59 | 'tos', 60 | ... 61 | 'myapp', # Example app name 62 | ... 63 | ) 64 | 65 | Advantages 66 | ---------- 67 | 68 | * Can optionally use a separate cache for TOS agreements (necessary if your default cache does not support atomic increment operations) 69 | * Allow some of your users to skip the TOS check (eg: developers, staff, admin, superusers, employees) 70 | * Uses signals to invalidate cached agreements 71 | * Skips the agreement check when the user is anonymous or not signed in 72 | * Skips the agreement check when the request is AJAX 73 | * Skips the agreement check when the request isn't a ``GET`` request (to avoid getting in the way of data mutations) 74 | 75 | Disadvantages 76 | ------------- 77 | 78 | * Requires a cache key for each user who is signed in 79 | * Requires an additional cache key for each staff user 80 | * May leave keys in the cache when the active ``TermsOfService`` changes 81 | 82 | Efficiency 83 | ---------- 84 | 85 | * Best case for staff users: 2 cache hits 86 | * Best case for non-staff users: 1 cache miss, 2 cache hits 87 | * Worst case: 1 cache hit, 2 cache misses, 1 database query, 1 cache set (this should only happen when the user signs in) 88 | 89 | Option 2 Configuration 90 | ---------------------- 91 | 92 | 1. In your root urlconf file ``urls.py`` only add the terms-of-service URLs: 93 | 94 | .. code-block:: python 95 | 96 | # terms of service links 97 | urlpatterns += [ 98 | path('terms-of-service/', include('tos.urls')), 99 | ] 100 | 101 | 2. Optional: Since the cache used by TOS will be overwhelmingly read-heavy, you can use a separate cache specifically for TOS. To do so, create a new cache in your project's ``settings.py``: 102 | 103 | .. code-block:: python 104 | 105 | CACHES = { 106 | ... 107 | # The cache specifically for django-tos 108 | 'tos': { # Can use any name here 109 | 'BACKEND': ..., 110 | 'LOCATION': ..., 111 | 'NAME': 'tos-cache', # Can use any name here 112 | }, 113 | } 114 | 115 | and configure ``django-tos`` to use the new cache: 116 | 117 | .. code-block:: python 118 | 119 | TOS_CACHE_NAME = 'tos' # Must match the key name in in CACHES 120 | 121 | this setting defaults to the ``default`` cache. 122 | 123 | 4. Then in your project's ``settings.py`` add the middleware to ``MIDDLEWARE_CLASSES``: 124 | 125 | .. code-block:: python 126 | 127 | MIDDLEWARE = ( 128 | ... 129 | # Terms of service checks 130 | 'tos.middleware.UserAgreementMiddleware', 131 | ) 132 | 133 | 5. Optional: To allow users to skip the TOS check, you will need to set corresponding cache keys for them in the TOS cache. The cache key for each user will need to be prefixed with ``django:tos:skip_tos_check:``, and have the user ID appended to it. 134 | 135 | Here is an example app configuration that allows staff users and superusers to skip the TOS agreement check: 136 | 137 | .. code-block:: python 138 | 139 | from django.apps import AppConfig, apps 140 | from django.conf import settings 141 | from django.contrib.auth import get_user_model 142 | from django.core.cache import caches 143 | from django.db.models import Q 144 | from django.db.models.signals import post_save, pre_save 145 | from django.dispatch import receiver 146 | 147 | class MyAppConfig(AppConfig): 148 | name = 'myapp' 149 | 150 | def ready(self): 151 | if 'tos' in settings.INSTALLED_APPS: 152 | cache = caches[getattr(settings, 'TOS_CACHE_NAME', 'default')] 153 | tos_app = apps.get_app_config('tos') 154 | TermsOfService = tos_app.get_model('TermsOfService') 155 | 156 | @receiver(post_save, sender=get_user_model(), dispatch_uid='set_staff_in_cache_for_tos') 157 | def set_staff_in_cache_for_tos(user, instance, **kwargs): 158 | if kwargs.get('raw', False): 159 | return 160 | 161 | # Get the cache prefix 162 | key_version = cache.get('django:tos:key_version') 163 | 164 | # If the user is staff allow them to skip the TOS agreement check 165 | if instance.is_staff or instance.is_superuser: 166 | cache.set('django:tos:skip_tos_check:{}'.format(instance.id), version=key_version) 167 | 168 | # But if they aren't make sure we invalidate them from the cache 169 | elif cache.get('django:tos:skip_tos_check:{}'.format(instance.id), False): 170 | cache.delete('django:tos:skip_tos_check:{}'.format(instance.id), version=key_version) 171 | 172 | @receiver(post_save, sender=TermsOfService, dispatch_uid='add_staff_users_to_tos_cache') 173 | def add_staff_users_to_tos_cache(*args, **kwargs): 174 | if kwargs.get('raw', False): 175 | return 176 | 177 | # Get the cache prefix 178 | key_version = cache.get('django:tos:key_version') 179 | 180 | # Efficiently cache all of the users who are allowed to skip the TOS 181 | # agreement check 182 | cache.set_many({ 183 | 'django:tos:skip_tos_check:{}'.format(staff_user.id): True 184 | for staff_user in get_user_model().objects.filter( 185 | Q(is_staff=True) | Q(is_superuser=True)) 186 | }, version=key_version) 187 | 188 | # Immediately add staff users to the cache 189 | add_staff_users_to_tos_cache() 190 | 191 | =============== 192 | django-tos-i18n 193 | =============== 194 | 195 | django-tos internationalization using django-modeltranslation. 196 | 197 | Terms Of Service i18n Installation 198 | ================================== 199 | 200 | Assuming you have correctly installed django-tos in your app you only need to 201 | add following apps to ``INSTALLED_APPS``: 202 | 203 | .. code-block:: python 204 | 205 | INSTALLED_APPS += ('modeltranslation', 'tos_i18n') 206 | 207 | and also you should also define your languages in Django ``LANGUAGES`` 208 | variable, e.g.: 209 | 210 | .. code-block:: python 211 | 212 | LANGUAGES = ( 213 | ('pl', 'Polski'), 214 | ('en', 'English'), 215 | ) 216 | 217 | Please note that adding those to ``INSTALLED_APPS`` **changes** Django models. 218 | Concretely it adds for every registered ``field`` that should translated, 219 | additional fields with name ``field_``, e.g. for given model: 220 | 221 | .. code-block:: python 222 | 223 | class MyModel(models.Model): 224 | name = models.CharField(max_length=10) 225 | 226 | There will be generated fields: ``name`` , ``name_en``, ``name_pl``. 227 | 228 | You should probably migrate your database, and if you're using Django < 1.7 using South is recommended. These migrations should be kept in your local project. 229 | 230 | How to migrate tos with South 231 | ````````````````````````````` 232 | 233 | Here is some step-by-step example how to convert your legacy django-tos 234 | installation synced using syncdb into a translated django-tos-i18n with South 235 | migrations. 236 | 237 | 1. Inform South that you want to store migrations in custom place by putting 238 | this in your Django settings file: 239 | 240 | .. code-block:: python 241 | 242 | SOUTH_MIGRATION_MODULES = { 243 | 'tos': 'YOUR_APP.migrations.tos', 244 | } 245 | 246 | 2. Add required directory (package): 247 | 248 | .. code-block:: bash 249 | 250 | mkdir -p YOUR_APP/migrations/tos 251 | touch YOUR_APP/migrations/tos/__init__.py 252 | 253 | 3. Create initial migration (referring to the database state as it is now): 254 | 255 | .. code-block:: bash 256 | 257 | python manage.py schemamigration --initial tos 258 | 259 | 4. Fake migration (because the changes are already in the database): 260 | 261 | .. code-block:: bash 262 | 263 | python manage.py migrate tos --fake 264 | 265 | 5. Install tos_i18n (and modeltranslation) to ``INSTALLED_APPS``: 266 | 267 | .. code-block:: python 268 | 269 | INSTALLED_APPS += ('modeltranslation', 'tos_i18n',) 270 | 271 | 6. Make sure that the Django ``LANGUAGES`` setting is properly configured. 272 | 273 | 7. Migrate what changed: 274 | 275 | .. code-block:: bash 276 | 277 | $ python manage.py schemamigration --auto tos 278 | $ python migrate tos 279 | 280 | 281 | That's it. You are now running tos in i18n mode with the languages you declared 282 | in ``LANGUAGES`` setting. This will also make all required adjustments in the 283 | Django admin. 284 | 285 | For more info on how translation works in details please refer to the 286 | `django-modeltranslation documentation 287 | `_. 288 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/revsys/django-tos/bfd169dabd14f6024c562c8668307d53ac1b5384/__init__.py -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==4.2.22 2 | coverage==5.2.1 3 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import logging 3 | import sys 4 | 5 | import django 6 | 7 | from django.conf import settings 8 | from django.core.management import execute_from_command_line 9 | 10 | 11 | if not settings.configured: 12 | django_settings = { 13 | 'DATABASES': { 14 | 'default': { 15 | 'ENGINE': 'django.db.backends.sqlite3', 16 | } 17 | }, 18 | 'DEFAULT_AUTO_FIELD': 'django.db.models.AutoField', 19 | 'INSTALLED_APPS': [ 20 | 'django.contrib.auth', 21 | 'django.contrib.contenttypes', 22 | 'django.contrib.sessions', 23 | 'django.contrib.messages', 24 | 'django.contrib.sites', 25 | 'tos', 26 | 'tos.tests' 27 | ], 28 | 'TEMPLATES': [ 29 | { 30 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 31 | 'DIRS': [], 32 | 'APP_DIRS': True, 33 | 'OPTIONS': { 34 | 'context_processors': [ 35 | 'django.template.context_processors.debug', 36 | 'django.template.context_processors.request', 37 | 'django.contrib.auth.context_processors.auth', 38 | 'django.contrib.messages.context_processors.messages', 39 | ], 40 | }, 41 | }, 42 | ], 43 | 'ROOT_URLCONF': 'tos.tests.test_urls', 44 | 'LOGIN_URL': '/login/', 45 | 'SITE_ID': 1, 46 | 'CACHES': { 47 | 'default': { 48 | 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', 49 | }, 50 | 'tos': { 51 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 52 | } 53 | }, 54 | 'SECRET_KEY': '7v%d@z##e=8z5#oc=cc-o%!cka5ibyy7#9r!#2fyiwn7ki020y', 55 | 'TOS_CACHE_NAME': 'tos' 56 | } 57 | 58 | django_settings['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 | ] 67 | 68 | settings.configure(**django_settings) 69 | 70 | 71 | logging.basicConfig( 72 | level = logging.DEBUG, 73 | format = '%(asctime)s %(levelname)s %(message)s', 74 | ) 75 | logging.disable(logging.CRITICAL) 76 | 77 | 78 | def runtests(): 79 | argv = sys.argv[:1] + ['test', 'tos'] 80 | execute_from_command_line(argv) 81 | 82 | 83 | if __name__ == '__main__': 84 | runtests() 85 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | 4 | from tos import VERSION 5 | 6 | f = open(os.path.join(os.path.dirname(__file__), 'README.rst')) 7 | readme = f.read() 8 | f.close() 9 | 10 | setup( 11 | name='django-tos', 12 | version=".".join(map(str, VERSION)), 13 | description='django-tos is a reusable Django application for setting Terms of Service.', 14 | long_description=readme, 15 | author='Frank Wiles', 16 | author_email='frank@revsys.com', 17 | url='http://github.com/revsys/django-tos/tree/master', 18 | packages=find_packages(), 19 | include_package_data=True, 20 | classifiers=[ 21 | 'Development Status :: 4 - Beta', 22 | 'Environment :: Web Environment', 23 | 'Intended Audience :: Developers', 24 | 'License :: OSI Approved :: BSD License', 25 | 'Operating System :: OS Independent', 26 | 'Programming Language :: Python', 27 | 'Framework :: Django', 28 | ], 29 | ) 30 | -------------------------------------------------------------------------------- /tos/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (1, 1, 0) 2 | -------------------------------------------------------------------------------- /tos/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from tos.models import TermsOfService, UserAgreement 4 | 5 | 6 | class TermsOfServiceAdmin(admin.ModelAdmin): 7 | model = TermsOfService 8 | 9 | admin.site.register(TermsOfService, TermsOfServiceAdmin) 10 | 11 | 12 | class UserAgreementAdmin(admin.ModelAdmin): 13 | model = UserAgreement 14 | 15 | admin.site.register(UserAgreement, UserAgreementAdmin) 16 | -------------------------------------------------------------------------------- /tos/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.conf import settings 3 | from django.db.models.signals import pre_save 4 | 5 | from .signal_handlers import invalidate_cached_agreements 6 | 7 | 8 | class TOSConfig(AppConfig): 9 | name = 'tos' 10 | verbose_name = 'Terms Of Service' 11 | 12 | def ready(self): 13 | MIDDLEWARES = getattr(settings, 'MIDDLEWARE', []) 14 | if 'tos.middleware.UserAgreementMiddleware' in MIDDLEWARES: 15 | TermsOfService = self.get_model('TermsOfService') 16 | 17 | pre_save.connect(invalidate_cached_agreements, 18 | sender=TermsOfService, 19 | dispatch_uid='invalidate_cached_agreements') 20 | 21 | # Create the TOS key version immediately 22 | invalidate_cached_agreements(TermsOfService) 23 | -------------------------------------------------------------------------------- /tos/middleware.py: -------------------------------------------------------------------------------- 1 | from django import VERSION as DJANGO_VERSION 2 | from django.conf import settings 3 | from django.contrib.auth import SESSION_KEY as session_key 4 | from django.contrib.auth import REDIRECT_FIELD_NAME 5 | from django.core.cache import caches 6 | from django.http import HttpResponseRedirect 7 | from django.urls import reverse_lazy 8 | from django.utils.cache import add_never_cache_headers 9 | from django.utils.deprecation import MiddlewareMixin 10 | 11 | from .models import UserAgreement 12 | 13 | 14 | cache = caches[getattr(settings, 'TOS_CACHE_NAME', 'default')] 15 | tos_check_url = reverse_lazy('tos_check_tos') 16 | 17 | 18 | class UserAgreementMiddleware(MiddlewareMixin): 19 | """ 20 | Some middleware to check if users have agreed to the latest TOS 21 | """ 22 | 23 | def __init__(self, get_response = None): 24 | if DJANGO_VERSION < (4,0): 25 | self.get_response = get_response 26 | else: 27 | if get_response is None: 28 | raise TypeError('get_response cannot be None in Django 4.0 and later') 29 | super().__init__(get_response) 30 | 31 | def process_request(self, request): 32 | if self.should_fast_skip(request): 33 | return None 34 | 35 | # Grab the user ID from the session so we avoid hitting the database 36 | # for the user object. 37 | # NOTE: We use the user ID because it's not user-settable and it won't 38 | # ever change (usernames and email addresses can change) 39 | user_id = request.session['_auth_user_id'] 40 | 41 | # Get the cache prefix 42 | key_version = cache.get('django:tos:key_version') 43 | 44 | # Skip if the user is allowed to skip - for instance, if the user is an 45 | # admin or a staff member 46 | if cache.get(f'django:tos:skip_tos_check:{user_id}', False, version=key_version): 47 | return None 48 | 49 | # Ping the cache for the user agreement 50 | user_agreed = cache.get(f'django:tos:agreed:{user_id}', None, version=key_version) 51 | 52 | # If the cache is missing this user 53 | if user_agreed is None: 54 | # Check the database and cache the result 55 | user_agreed = self.get_and_cache_agreement_from_db(user_id, key_version) 56 | 57 | if not user_agreed: 58 | # Confirm view uses these session keys. Non-middleware flow sets them in login view, 59 | # so we need to set them here. 60 | request.session['tos_user'] = user_id 61 | request.session['tos_backend'] = request.session['_auth_user_backend'] 62 | 63 | response = HttpResponseRedirect('{}?{}={}'.format( 64 | tos_check_url, 65 | REDIRECT_FIELD_NAME, 66 | request.path_info, 67 | )) 68 | add_never_cache_headers(response) 69 | return response 70 | 71 | return None 72 | 73 | def should_fast_skip(self, request): 74 | '''Check if we should skip TOS checks without hitting the cache or database''' 75 | # Don't get in the way of any mutating requests 76 | if request.method != 'GET': 77 | return True 78 | 79 | # Ignore ajax requests 80 | if request.META.get('HTTP_X_REQUESTED_WITH') == 'XMLHttpRequest': 81 | return True 82 | 83 | # Don't redirect users when they're trying to get to the confirm page 84 | if request.path_info == tos_check_url: 85 | return True 86 | 87 | # If the user doesn't have a user ID, ignore them - they're anonymous 88 | if not request.session.get(session_key, None): 89 | return True 90 | 91 | return False 92 | 93 | def get_and_cache_agreement_from_db(self, user_id, key_version): 94 | # Grab the data from the database 95 | user_agreed = UserAgreement.objects.filter( 96 | user__id=user_id, 97 | terms_of_service__active=True).exists() 98 | 99 | # Set the value in the cache 100 | cache.set(f'django:tos:agreed:{user_id}', user_agreed, version=key_version) 101 | 102 | return user_agreed 103 | -------------------------------------------------------------------------------- /tos/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.9.4 on 2016-04-19 14:09 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 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='TermsOfService', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('created', models.DateTimeField(auto_now_add=True)), 22 | ('modified', models.DateTimeField(auto_now=True)), 23 | ('active', models.BooleanField(default=False, help_text='Only one terms of service is allowed to be active', verbose_name='active')), 24 | ('content', models.TextField(blank=True, verbose_name='content')), 25 | ], 26 | options={ 27 | 'ordering': ('-created',), 28 | 'get_latest_by': 'created', 29 | 'verbose_name': 'Terms of Service', 30 | 'verbose_name_plural': 'Terms of Service', 31 | }, 32 | ), 33 | migrations.CreateModel( 34 | name='UserAgreement', 35 | fields=[ 36 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 37 | ('created', models.DateTimeField(auto_now_add=True)), 38 | ('modified', models.DateTimeField(auto_now=True)), 39 | ('terms_of_service', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terms', to='tos.TermsOfService')), 40 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_agreement', to=settings.AUTH_USER_MODEL)), 41 | ], 42 | options={ 43 | 'abstract': False, 44 | }, 45 | ), 46 | ] 47 | -------------------------------------------------------------------------------- /tos/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/revsys/django-tos/bfd169dabd14f6024c562c8668307d53ac1b5384/tos/migrations/__init__.py -------------------------------------------------------------------------------- /tos/models.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from django.conf import settings 4 | from django.core.exceptions import ValidationError 5 | from django.db import models 6 | from django.utils.translation import gettext_lazy as _ 7 | 8 | 9 | class NoActiveTermsOfService(ValidationError): 10 | pass 11 | 12 | 13 | class BaseModel(models.Model): 14 | created = models.DateTimeField(auto_now_add=True, editable=False) 15 | modified = models.DateTimeField(auto_now=True, editable=False) 16 | 17 | class Meta: 18 | abstract = True 19 | 20 | 21 | class TermsOfServiceManager(models.Manager): 22 | def get_current_tos(self): 23 | try: 24 | return self.get(active=True) 25 | except self.model.DoesNotExist: 26 | if settings.DEBUG: 27 | warnings.warn("There is no active Terms-of-Service") 28 | else: 29 | raise NoActiveTermsOfService( 30 | 'Please create an active Terms-of-Service' 31 | ) 32 | 33 | 34 | class TermsOfService(BaseModel): 35 | active = models.BooleanField( 36 | default=False, 37 | verbose_name=_('active'), 38 | help_text=_( 39 | 'Only one terms of service is allowed to be active' 40 | ) 41 | ) 42 | content = models.TextField(verbose_name=_('content'), blank=True) 43 | objects = TermsOfServiceManager() 44 | 45 | class Meta: 46 | get_latest_by = 'created' 47 | ordering = ('-created',) 48 | verbose_name = _('Terms of Service') 49 | verbose_name_plural = _('Terms of Service') 50 | 51 | def __str__(self): 52 | active = 'inactive' 53 | if self.active: 54 | active = 'active' 55 | return f'{self.created}: {active}' 56 | 57 | def save(self, *args, **kwargs): 58 | """ Ensure we're being saved properly """ 59 | 60 | if self.active: 61 | TermsOfService.objects.exclude(id=self.id).update(active=False) 62 | 63 | else: 64 | if not TermsOfService.objects\ 65 | .exclude(id=self.id)\ 66 | .filter(active=True)\ 67 | .exists(): 68 | if settings.DEBUG: 69 | warnings.warn("There is no active Terms-of-Service") 70 | else: 71 | raise NoActiveTermsOfService( 72 | 'One of the terms of service must be marked active' 73 | ) 74 | 75 | super().save(*args, **kwargs) 76 | 77 | 78 | class UserAgreement(BaseModel): 79 | terms_of_service = models.ForeignKey(TermsOfService, related_name='terms', on_delete=models.CASCADE) 80 | user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='user_agreement', on_delete=models.CASCADE) 81 | 82 | def __str__(self): 83 | return f'{self.user.username} agreed to TOS: {self.terms_of_service}' 84 | 85 | 86 | def has_user_agreed_latest_tos(user): 87 | return UserAgreement.objects.filter( 88 | terms_of_service=TermsOfService.objects.get_current_tos(), 89 | user=user, 90 | ).exists() 91 | -------------------------------------------------------------------------------- /tos/signal_handlers.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.cache import caches 3 | 4 | 5 | # Force the user to create a separate cache 6 | cache = caches[getattr(settings, 'TOS_CACHE_NAME', 'default')] 7 | 8 | 9 | def invalidate_cached_agreements(sender, **kwargs): 10 | if kwargs.get('raw', False): 11 | return 12 | 13 | # Set the key version to 0 if it doesn't exist and leave it 14 | # alone if it does 15 | cache.add('django:tos:key_version', 0) 16 | 17 | # This key will be used to version the rest of the TOS keys 18 | # Incrementing it will effectively invalidate all previous keys 19 | cache.incr('django:tos:key_version') 20 | -------------------------------------------------------------------------------- /tos/templates/tos/tos.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 |

{% blocktrans %}Terms of Service as of {{ tos.created|date:"SHORT_DATE_FORMAT" }}{% endblocktrans %}

4 | 5 | {{ tos.content|safe }} 6 | -------------------------------------------------------------------------------- /tos/templates/tos/tos_check.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% if note %} 3 |

{{ note }}

4 | {% else %} 5 |

6 | {% blocktrans with tos_created=tos.created|date:"SHORT_DATE_FORMAT" %} 7 | Terms of Service as of {{ tos_created }} 8 | {% endblocktrans %} 9 |

10 | {% endif %} 11 | 12 | {{ tos.content|safe }} 13 | 14 |

{% trans "Accept Terms of Service?" %}

15 | 16 |
17 | {% csrf_token %} 18 | {% if next %} 19 | 20 | {% endif %} 21 | 22 | 23 |
24 | -------------------------------------------------------------------------------- /tos/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/revsys/django-tos/bfd169dabd14f6024c562c8668307d53ac1b5384/tos/tests/__init__.py -------------------------------------------------------------------------------- /tos/tests/templates/index.html: -------------------------------------------------------------------------------- 1 | index 2 | -------------------------------------------------------------------------------- /tos/tests/templates/registration/login.html: -------------------------------------------------------------------------------- 1 | Dummy login template. 2 | -------------------------------------------------------------------------------- /tos/tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | from django import VERSION as DJANGO_VERSION 2 | from django.conf import settings 3 | from django.contrib.auth import REDIRECT_FIELD_NAME, get_user_model 4 | from django.core.cache import caches 5 | from django.http import HttpResponse 6 | from django.test import TestCase 7 | from django.test.utils import modify_settings, skipIf 8 | from django.urls import reverse 9 | 10 | from tos.middleware import UserAgreementMiddleware 11 | from tos.models import TermsOfService, UserAgreement 12 | from tos.signal_handlers import invalidate_cached_agreements 13 | 14 | 15 | @modify_settings( 16 | MIDDLEWARE={ 17 | 'append': 'tos.middleware.UserAgreementMiddleware', 18 | }, 19 | ) 20 | class TestMiddleware(TestCase): 21 | 22 | def setUp(self): 23 | # Clear cache between tests 24 | cache = caches[getattr(settings, 'TOS_CACHE_NAME', 'default')] 25 | cache.clear() 26 | 27 | # User that has agreed to TOS 28 | self.user1 = get_user_model().objects.create_user('user1', 'user1@example.com', 'user1pass') 29 | 30 | # User that has not yet agreed to TOS 31 | self.user2 = get_user_model().objects.create_user('user2', 'user2@example.com', 'user2pass') 32 | self.user3 = get_user_model().objects.create_user('user3', 'user3@example.com', 'user3pass') 33 | 34 | self.tos1 = TermsOfService.objects.create( 35 | content="first edition of the terms of service", 36 | active=True 37 | ) 38 | self.tos2 = TermsOfService.objects.create( 39 | content="second edition of the terms of service", 40 | active=False 41 | ) 42 | self.login_url = getattr(settings, 'LOGIN_URL', '/login/') 43 | 44 | UserAgreement.objects.create( 45 | terms_of_service=self.tos1, 46 | user=self.user1 47 | ) 48 | 49 | self.redirect_page = '{}?{}={}'.format( 50 | reverse('tos_check_tos'), 51 | REDIRECT_FIELD_NAME, 52 | reverse('index'), 53 | ) 54 | 55 | def test_middleware_redirects(self): 56 | """ 57 | User that hasn't accepted TOS should be redirected to confirm. Also make sure 58 | confirm works. 59 | """ 60 | self.client.login(username='user2', password='user2pass') 61 | response = self.client.get(reverse('index')) 62 | self.assertRedirects(response, self.redirect_page) 63 | 64 | # Make sure confirm works after middleware redirect. 65 | response = self.client.post(reverse('tos_check_tos'), {'accept': 'accept'}) 66 | 67 | # Confirm redirects. 68 | self.assertEqual(response.status_code, 302) 69 | 70 | def test_invalidate_cache_on_accept_fix_redirect_loop(self): 71 | """ 72 | Make sure accepting doesn't send you right back to tos page. 73 | """ 74 | self.assertFalse(UserAgreement.objects.filter(terms_of_service=self.tos1, user=self.user2).exists()) 75 | 76 | self.client.login(username='user2', password='user2pass') 77 | response = self.client.get(reverse('index')) 78 | self.assertRedirects(response, self.redirect_page) 79 | 80 | # Make sure confirm works after middleware redirect. 81 | response = self.client.post(reverse('tos_check_tos'), {'accept': 'accept'}) 82 | 83 | self.assertTrue(UserAgreement.objects.filter(terms_of_service=self.tos1, user=self.user2).exists()) 84 | 85 | response = self.client.get(reverse('index')) 86 | self.assertEqual(response.status_code, 200) 87 | 88 | self.assertIn('index', str(response.content)) 89 | 90 | def test_middleware_doesnt_redirect(self): 91 | """User that has accepted TOS should get 200.""" 92 | self.client.login(username='user1', password='user1pass') 93 | response = self.client.get(reverse('index')) 94 | self.assertEqual(response.status_code, 200) 95 | 96 | def test_anonymous_user_200(self): 97 | response = self.client.get(reverse('index')) 98 | self.assertEqual(response.status_code, 200) 99 | 100 | def test_accept_after_middleware_redirects_properly(self): 101 | self.client.login(username='user3', password='user3pass') 102 | 103 | response = self.client.get(reverse('index'), follow=True) 104 | 105 | self.assertRedirects(response, self.redirect_page) 106 | 107 | # Agree 108 | response = self.client.post(self.redirect_page, {'accept': 'accept'}) 109 | 110 | # Confirm redirects back to the index page 111 | self.assertEqual(response.status_code, 302) 112 | self.assertEqual(response.url.replace('http://testserver', ''), str(reverse('index'))) 113 | 114 | 115 | @modify_settings( 116 | MIDDLEWARE={ 117 | 'append': 'tos.middleware.UserAgreementMiddleware', 118 | }, 119 | ) 120 | class BumpCoverage(TestCase): 121 | 122 | def setUp(self): 123 | # User that has agreed to TOS 124 | self.user1 = get_user_model().objects.create_user('user1', 'user1@example.com', 'user1pass') 125 | 126 | self.tos1 = TermsOfService.objects.create( 127 | content="first edition of the terms of service", 128 | active=True 129 | ) 130 | self.tos2 = TermsOfService.objects.create( 131 | content="second edition of the terms of service", 132 | active=False 133 | ) 134 | self.login_url = getattr(settings, 'LOGIN_URL', '/login/') 135 | 136 | UserAgreement.objects.create( 137 | terms_of_service=self.tos1, 138 | user=self.user1 139 | ) 140 | 141 | # Test the backward compatibility of the middleware 142 | @skipIf(DJANGO_VERSION >= (4,0), 'Django < 4.0 only') 143 | def test_ajax_request_pre_40(self): 144 | class Request: 145 | method = 'GET' 146 | META = { 147 | 'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest' 148 | } 149 | 150 | def is_ajax(self): 151 | return True 152 | 153 | mw = UserAgreementMiddleware() 154 | 155 | response = mw.process_request(Request()) 156 | 157 | self.assertIsNone(response) 158 | 159 | def test_ajax_request(self): 160 | class Request: 161 | method = 'GET' 162 | META = { 163 | 'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest' 164 | } 165 | 166 | def is_ajax(self): 167 | return True 168 | 169 | mw = UserAgreementMiddleware(HttpResponse()) 170 | 171 | response = mw.process_request(Request()) 172 | 173 | self.assertIsNone(response) 174 | 175 | def test_skip_for_user(self): 176 | cache = caches[getattr(settings, 'TOS_CACHE_NAME', 'default')] 177 | 178 | key_version = cache.get('django:tos:key_version') 179 | 180 | cache.set(f'django:tos:skip_tos_check:{self.user1.id}', True, version=key_version) 181 | 182 | self.client.login(username='user1', password='user1pass') 183 | response = self.client.get(reverse('index')) 184 | 185 | self.assertEqual(response.status_code, 200) 186 | 187 | def test_invalidate_cached_agreements(self): 188 | cache = caches[getattr(settings, 'TOS_CACHE_NAME', 'default')] 189 | 190 | invalidate_cached_agreements(TermsOfService) 191 | 192 | key_version = cache.get('django:tos:key_version') 193 | 194 | invalidate_cached_agreements(TermsOfService) 195 | 196 | self.assertEqual(cache.get('django:tos:key_version'), key_version+1) 197 | 198 | invalidate_cached_agreements(TermsOfService, raw=True) 199 | 200 | self.assertEqual(cache.get('django:tos:key_version'), key_version+1) 201 | 202 | # Test that as of Django 4.0, get_response is required 203 | @skipIf(DJANGO_VERSION < (4,0), 'Django 4.0+ only') 204 | def test_creation_of_middleware(self): 205 | with self.assertRaises(TypeError): 206 | UserAgreementMiddleware() 207 | -------------------------------------------------------------------------------- /tos/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.core.exceptions import ValidationError 3 | from django.test import TestCase, override_settings 4 | 5 | from tos.models import ( 6 | NoActiveTermsOfService, 7 | TermsOfService, 8 | UserAgreement, 9 | has_user_agreed_latest_tos, 10 | ) 11 | 12 | 13 | class TestModels(TestCase): 14 | 15 | def setUp(self): 16 | self.user1 = get_user_model().objects.create_user('user1', 17 | 'user1@example.com', 18 | 'user1pass') 19 | self.user2 = get_user_model().objects.create_user('user2', 20 | 'user2@example.com', 21 | 'user2pass') 22 | self.user3 = get_user_model().objects.create_user('user3', 23 | 'user3@example.com', 24 | 'user3pass') 25 | 26 | self.tos1 = TermsOfService.objects.create( 27 | content="first edition of the terms of service", 28 | active=True 29 | ) 30 | self.tos2 = TermsOfService.objects.create( 31 | content="second edition of the terms of service", 32 | active=False 33 | ) 34 | 35 | def test_terms_of_service(self): 36 | 37 | self.assertEqual(TermsOfService.objects.count(), 2) 38 | 39 | # order is by -created 40 | latest = TermsOfService.objects.latest() 41 | self.assertFalse(latest.active) 42 | 43 | # setting a tos to True changes all others to False 44 | latest.active = True 45 | latest.save() 46 | first = TermsOfService.objects.get(id=self.tos1.id) 47 | self.assertFalse(first.active) 48 | 49 | # latest is active though 50 | self.assertTrue(latest.active) 51 | 52 | def test_validation_error_all_set_false(self): 53 | """ 54 | If you try and set all to false the model will throw a ValidationError 55 | """ 56 | 57 | self.tos1.active = False 58 | self.assertRaises(ValidationError, self.tos1.save) 59 | 60 | def test_user_agreement(self): 61 | 62 | # simple agreement 63 | UserAgreement.objects.create( 64 | terms_of_service=self.tos1, 65 | user=self.user1 66 | ) 67 | 68 | self.assertTrue(has_user_agreed_latest_tos(self.user1)) 69 | self.assertFalse(has_user_agreed_latest_tos(self.user2)) 70 | self.assertFalse(has_user_agreed_latest_tos(self.user3)) 71 | 72 | # Now set self.tos2.active to True and see what happens 73 | self.tos2.active = True 74 | self.tos2.save() 75 | self.assertFalse(has_user_agreed_latest_tos(self.user1)) 76 | self.assertFalse(has_user_agreed_latest_tos(self.user2)) 77 | self.assertFalse(has_user_agreed_latest_tos(self.user3)) 78 | 79 | # add in a couple agreements and try again 80 | UserAgreement.objects.create( 81 | terms_of_service=self.tos2, 82 | user=self.user1 83 | ) 84 | UserAgreement.objects.create( 85 | terms_of_service=self.tos2, 86 | user=self.user3 87 | ) 88 | 89 | self.assertTrue(has_user_agreed_latest_tos(self.user1)) 90 | self.assertFalse(has_user_agreed_latest_tos(self.user2)) 91 | self.assertTrue(has_user_agreed_latest_tos(self.user3)) 92 | 93 | 94 | class TestManager(TestCase): 95 | def test_terms_of_service_manager(self): 96 | 97 | tos1 = TermsOfService.objects.create( 98 | content="first edition of the terms of service", 99 | active=True 100 | ) 101 | 102 | self.assertEqual(TermsOfService.objects.get_current_tos(), tos1) 103 | 104 | def test_terms_of_service_manager_raises_error(self): 105 | 106 | self.assertRaises(NoActiveTermsOfService, TermsOfService.objects.get_current_tos) 107 | 108 | 109 | class TestNoActiveTOS(TestCase): 110 | @classmethod 111 | def setUpClass(cls): 112 | # Use bulk_create to avoid calling the model's save() method 113 | TermsOfService.objects.bulk_create([ 114 | TermsOfService( 115 | content="The only TOS", 116 | active=False, 117 | ) 118 | ]) 119 | 120 | @classmethod 121 | def tearDownClass(cls): 122 | TermsOfService.objects.all().delete() 123 | 124 | @override_settings(DEBUG=True) 125 | def test_model_save_raises_warning(self): 126 | with self.assertWarns(Warning): 127 | TermsOfService.objects.first().save() 128 | 129 | @override_settings(DEBUG=True) 130 | def test_get_current_tos_raises_warning(self): 131 | with self.assertWarns(Warning): 132 | TermsOfService.objects.get_current_tos() 133 | 134 | @override_settings(DEBUG=False) 135 | def test_model_save_raises_exception(self): 136 | with self.assertRaises(NoActiveTermsOfService): 137 | TermsOfService.objects.first().save() 138 | 139 | @override_settings(DEBUG=False) 140 | def test_get_current_tos_raises_exception(self): 141 | with self.assertRaises(NoActiveTermsOfService): 142 | TermsOfService.objects.get_current_tos() 143 | -------------------------------------------------------------------------------- /tos/tests/test_urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, re_path 2 | from django.views.generic import TemplateView 3 | 4 | from tos import views 5 | 6 | 7 | urlpatterns = [ 8 | re_path(r'^$', TemplateView.as_view(template_name='index.html'), name='index'), 9 | 10 | re_path(r'^login/$', views.login, {}, 'login'), 11 | re_path(r'^tos/', include('tos.urls')), 12 | ] 13 | -------------------------------------------------------------------------------- /tos/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth import get_user_model 3 | from django.test import TestCase 4 | from django.urls import reverse 5 | 6 | from tos.models import TermsOfService, UserAgreement, has_user_agreed_latest_tos 7 | 8 | 9 | class TestViews(TestCase): 10 | 11 | def setUp(self): 12 | # User that has agreed to TOS 13 | self.user1 = get_user_model().objects.create_user('user1', 'user1@example.com', 'user1pass') 14 | 15 | # User that has not yet agreed to TOS 16 | self.user2 = get_user_model().objects.create_user('user2', 'user2@example.com', 'user2pass') 17 | 18 | self.tos1 = TermsOfService.objects.create( 19 | content="first edition of the terms of service", 20 | active=True 21 | ) 22 | self.tos2 = TermsOfService.objects.create( 23 | content="second edition of the terms of service", 24 | active=False 25 | ) 26 | 27 | self.login_url = getattr(settings, 'LOGIN_URL', '/login/') 28 | 29 | UserAgreement.objects.create( 30 | terms_of_service=self.tos1, 31 | user=self.user1 32 | ) 33 | 34 | def test_login(self): 35 | """ Make sure we didn't break the authentication system 36 | This assumes that login urls are named 'login' 37 | """ 38 | 39 | self.assertTrue(has_user_agreed_latest_tos(self.user1)) 40 | login = self.client.login(username='user1', password='user1pass') 41 | self.assertTrue(login, 'Could not log in') 42 | self.assertTrue(has_user_agreed_latest_tos(self.user1)) 43 | 44 | def test_user_agrees_multiple_times(self): 45 | login_response = self.client.post(reverse('login'), { 46 | 'username': 'user2', 47 | 'password': 'user2pass', 48 | }) 49 | 50 | self.assertTrue(login_response) 51 | 52 | response = self.client.post(reverse('tos_check_tos'), {'accept': 'accept'}) 53 | 54 | self.assertEqual(response.status_code, 302) 55 | self.assertEqual(UserAgreement.objects.filter(user=self.user2).count(), 1) 56 | 57 | response = self.client.post(reverse('tos_check_tos'), {'accept': 'accept'}) 58 | 59 | self.assertEqual(response.status_code, 302) 60 | self.assertEqual(UserAgreement.objects.filter(user=self.user2).count(), 1) 61 | 62 | response = self.client.post(reverse('tos_check_tos'), {'accept': 'accept'}) 63 | 64 | self.assertEqual(response.status_code, 302) 65 | self.assertEqual(UserAgreement.objects.filter(user=self.user2).count(), 1) 66 | 67 | def test_need_agreement(self): 68 | """ user2 tries to login and then has to go and agree to terms""" 69 | 70 | self.assertFalse(has_user_agreed_latest_tos(self.user2)) 71 | 72 | response = self.client.post(self.login_url, {'username': 'user2', 'password': 'user2pass'}) 73 | self.assertContains(response, "first edition of the terms of service") 74 | 75 | self.assertFalse(has_user_agreed_latest_tos(self.user2)) 76 | 77 | def test_do_not_need_agreement(self): 78 | """ user2 tries to login and has already agreed""" 79 | 80 | self.assertTrue(has_user_agreed_latest_tos(self.user1)) 81 | 82 | response = self.client.post(self.login_url, {'username': 'user1', 83 | 'password': 'user1pass'}) 84 | self.assertEqual(302, response.status_code) 85 | 86 | def test_redirect_security(self): 87 | """ redirect to outside url not allowed, should redirect to login url""" 88 | 89 | response = self.client.post(self.login_url, {'username': 'user1', 90 | 'password': 'user1pass', 'next': 'http://example.com'}) 91 | self.assertEqual(302, response.status_code) 92 | self.assertIn(settings.LOGIN_REDIRECT_URL, response.url) 93 | 94 | def test_need_to_log_in(self): 95 | """ GET to login url shows login template.""" 96 | 97 | response = self.client.get(self.login_url) 98 | self.assertContains(response, "Dummy login template.") 99 | 100 | def test_root_tos_view(self): 101 | 102 | response = self.client.get('/tos/') 103 | self.assertIn(b'first edition of the terms of service', response.content) 104 | 105 | def test_reject_agreement(self): 106 | 107 | self.assertFalse(has_user_agreed_latest_tos(self.user2)) 108 | 109 | response = self.client.post(self.login_url, {'username': 'user2', 'password': 'user2pass'}) 110 | self.assertContains(response, "first edition of the terms of service") 111 | url = reverse('tos_check_tos') 112 | response = self.client.post(url, {'accept': 'reject'}) 113 | 114 | self.assertFalse(has_user_agreed_latest_tos(self.user2)) 115 | 116 | def test_accept_agreement(self): 117 | 118 | self.assertFalse(has_user_agreed_latest_tos(self.user2)) 119 | 120 | response = self.client.post(self.login_url, {'username': 'user2', 'password': 'user2pass'}) 121 | self.assertContains(response, "first edition of the terms of service") 122 | self.assertFalse(has_user_agreed_latest_tos(self.user2)) 123 | url = reverse('tos_check_tos') 124 | response = self.client.post(url, {'accept': 'accept'}) 125 | 126 | self.assertTrue(has_user_agreed_latest_tos(self.user2)) 127 | 128 | def test_bump_new_agreement(self): 129 | 130 | # Change the tos 131 | self.tos2.active = True 132 | self.tos2.save() 133 | 134 | # is user1 agreed now? 135 | self.assertFalse(has_user_agreed_latest_tos(self.user1)) 136 | 137 | # user1 agrees again 138 | response = self.client.post(self.login_url, {'username': 'user1', 'password': 'user1pass'}) 139 | self.assertContains(response, "second edition of the terms of service") 140 | self.assertFalse(has_user_agreed_latest_tos(self.user2)) 141 | url = reverse('tos_check_tos') 142 | response = self.client.post(url, {'accept': 'accept'}) 143 | 144 | self.assertTrue(has_user_agreed_latest_tos(self.user1)) 145 | -------------------------------------------------------------------------------- /tos/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | 3 | from tos.views import check_tos, TosView 4 | 5 | 6 | urlpatterns = [ 7 | # Terms of Service conform 8 | re_path(r'^confirm/$', check_tos, name='tos_check_tos'), 9 | 10 | # Terms of service simple display 11 | re_path(r'^$', TosView.as_view(), name='tos'), 12 | ] 13 | -------------------------------------------------------------------------------- /tos/views.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.conf import settings 4 | from django.contrib import messages 5 | from django.contrib.auth import login as auth_login 6 | from django.contrib.auth import REDIRECT_FIELD_NAME 7 | from django.contrib.auth import get_user_model 8 | from django.contrib.auth.forms import AuthenticationForm 9 | from django.contrib.sites.shortcuts import get_current_site 10 | from django.core.cache import caches 11 | from django.http import HttpResponseRedirect 12 | from django.shortcuts import render 13 | from django.utils.translation import gettext_lazy as _ 14 | from django.views.decorators.cache import never_cache 15 | from django.views.decorators.csrf import csrf_protect 16 | from django.views.generic import TemplateView 17 | 18 | from tos.models import has_user_agreed_latest_tos, TermsOfService, UserAgreement 19 | 20 | 21 | cache = caches[getattr(settings, 'TOS_CACHE_NAME', 'default')] 22 | 23 | 24 | class TosView(TemplateView): 25 | template_name = "tos/tos.html" 26 | 27 | def get_context_data(self, **kwargs): 28 | context = super().get_context_data(**kwargs) 29 | context['tos'] = TermsOfService.objects.get_current_tos() 30 | return context 31 | 32 | 33 | def _redirect_to(redirect_to): 34 | """ Moved redirect_to logic here to avoid duplication in views""" 35 | 36 | # Light security check -- make sure redirect_to isn't garbage. 37 | if not redirect_to or ' ' in redirect_to: 38 | redirect_to = settings.LOGIN_REDIRECT_URL 39 | 40 | # Heavier security check -- redirects to http://example.com should 41 | # not be allowed, but things like /view/?param=http://example.com 42 | # should be allowed. This regex checks if there is a '//' *before* a 43 | # question mark. 44 | elif '//' in redirect_to and re.match(r'[^\?]*//', redirect_to): 45 | redirect_to = settings.LOGIN_REDIRECT_URL 46 | return redirect_to 47 | 48 | 49 | @csrf_protect 50 | @never_cache 51 | def check_tos(request, template_name='tos/tos_check.html', 52 | redirect_field_name=REDIRECT_FIELD_NAME,): 53 | 54 | redirect_to = _redirect_to(request.POST.get(redirect_field_name, request.GET.get(redirect_field_name, ''))) 55 | tos = TermsOfService.objects.get_current_tos() 56 | if request.method == "POST": 57 | if request.POST.get("accept", "") == "accept": 58 | user = get_user_model().objects.get(pk=request.session['tos_user']) 59 | user.backend = request.session['tos_backend'] 60 | 61 | # Save the user agreement to the new TOS 62 | UserAgreement.objects.get_or_create(terms_of_service=tos, user=user) 63 | 64 | key_version = cache.get('django:tos:key_version') 65 | cache.delete(f'django:tos:agreed:{user.pk}', version=key_version) 66 | 67 | # Log the user in 68 | auth_login(request, user) 69 | 70 | if request.session.test_cookie_worked(): 71 | request.session.delete_test_cookie() 72 | 73 | return HttpResponseRedirect(redirect_to) 74 | else: 75 | messages.error( 76 | request, 77 | _("You cannot login without agreeing to the terms of this site.") 78 | ) 79 | context = { 80 | 'tos': tos, 81 | 'redirect_field_name': redirect_field_name, 82 | 'next': redirect_to, 83 | } 84 | return render(request, template_name, context) 85 | 86 | 87 | @csrf_protect 88 | @never_cache 89 | def login(request, template_name='registration/login.html', 90 | redirect_field_name=REDIRECT_FIELD_NAME, 91 | authentication_form=AuthenticationForm): 92 | """Displays the login form and handles the login action.""" 93 | 94 | redirect_to = request.POST.get(redirect_field_name, request.GET.get(redirect_field_name, '')) 95 | 96 | if request.method == "POST": 97 | form = authentication_form(data=request.POST) 98 | if form.is_valid(): 99 | 100 | redirect_to = _redirect_to(redirect_to) 101 | 102 | # Okay, security checks complete. Check to see if user agrees 103 | # to terms 104 | user = form.get_user() 105 | if has_user_agreed_latest_tos(user): 106 | 107 | # Log the user in. 108 | auth_login(request, user) 109 | 110 | if request.session.test_cookie_worked(): 111 | request.session.delete_test_cookie() 112 | 113 | return HttpResponseRedirect(redirect_to) 114 | 115 | else: 116 | # user has not yet agreed to latest tos 117 | # force them to accept or refuse 118 | 119 | request.session['tos_user'] = user.pk 120 | # Pass the used backend as well since django will require it 121 | # and it can only be obtained by calling authenticate, but we 122 | # got no credentials in check_tos. 123 | # see: https://docs.djangoproject.com/en/1.6/topics/auth/default/#how-to-log-a-user-in 124 | request.session['tos_backend'] = user.backend 125 | 126 | context = { 127 | 'redirect_field_name': redirect_to, 128 | 'tos': TermsOfService.objects.get_current_tos() 129 | } 130 | 131 | return render(request, 'tos/tos_check.html', context) 132 | else: 133 | form = authentication_form(request) 134 | 135 | request.session.set_test_cookie() 136 | 137 | current_site = get_current_site(request) 138 | 139 | context = { 140 | 'form': form, 141 | 'redirect_field_name': redirect_to, 142 | 'site': current_site, 143 | 'site_name': current_site.name, 144 | } 145 | return render(request, template_name, context) 146 | -------------------------------------------------------------------------------- /tos_i18n/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/revsys/django-tos/bfd169dabd14f6024c562c8668307d53ac1b5384/tos_i18n/__init__.py -------------------------------------------------------------------------------- /tos_i18n/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from modeltranslation.admin import TranslationAdmin 3 | from tos.admin import TermsOfServiceAdmin 4 | 5 | # Admin translation for django-plans 6 | from tos.models import TermsOfService 7 | 8 | 9 | class TranslatedTermsOfServiceAdmin(TermsOfServiceAdmin, TranslationAdmin): 10 | pass 11 | 12 | admin.site.unregister(TermsOfService) 13 | admin.site.register(TermsOfService, TranslatedTermsOfServiceAdmin) 14 | -------------------------------------------------------------------------------- /tos_i18n/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /tos_i18n/tests.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/revsys/django-tos/bfd169dabd14f6024c562c8668307d53ac1b5384/tos_i18n/tests.py -------------------------------------------------------------------------------- /tos_i18n/translation.py: -------------------------------------------------------------------------------- 1 | from modeltranslation.translator import translator, TranslationOptions 2 | from tos.models import TermsOfService 3 | 4 | # Translations for django-tos 5 | 6 | class TermsOfServiceTranslationOptions(TranslationOptions): 7 | fields = ('content', ) 8 | 9 | translator.register(TermsOfService, TermsOfServiceTranslationOptions) 10 | -------------------------------------------------------------------------------- /tos_i18n/views.py: -------------------------------------------------------------------------------- 1 | # Create your views here. 2 | --------------------------------------------------------------------------------