├── test_project ├── test_project │ ├── __init__.py │ ├── templates │ │ ├── 404.html │ │ ├── ab │ │ │ ├── page.html │ │ │ ├── page │ │ │ │ └── variation1.html │ │ │ ├── st │ │ │ │ └── test1 │ │ │ │ │ └── variation1.html │ │ │ └── simple_templates │ │ │ │ └── test1 │ │ │ │ └── variation1.html │ │ ├── st │ │ │ └── test1.html │ │ ├── page.html │ │ ├── simple_templates │ │ │ ├── test1.html │ │ │ └── canonical.html │ │ ├── ab_templates │ │ │ ├── page │ │ │ │ └── variation1.html │ │ │ ├── st │ │ │ │ └── test1 │ │ │ │ │ └── variation1.html │ │ │ └── simple_templates │ │ │ │ └── test1 │ │ │ │ └── variation1.html │ │ └── base.html │ ├── urls.py │ ├── wsgi.py │ └── settings.py ├── test-requirements.txt └── manage.py ├── simple_templates ├── templatetags │ ├── __init__.py │ └── simple_templates.py ├── __init__.py ├── apps.py ├── utils.py ├── middleware.py └── tests.py ├── .gitignore ├── LICENSE ├── pyproject.toml └── README.md /test_project/test_project/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /simple_templates/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test_project/test-requirements.txt: -------------------------------------------------------------------------------- 1 | django 2 | -------------------------------------------------------------------------------- /test_project/test_project/templates/404.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /simple_templates/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.0.0" 2 | -------------------------------------------------------------------------------- /test_project/test_project/templates/ab/page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ab-page 5 | 6 | -------------------------------------------------------------------------------- /test_project/test_project/templates/st/test1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | st-test1 5 | 6 | -------------------------------------------------------------------------------- /test_project/test_project/templates/page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | page-original 5 | 6 | -------------------------------------------------------------------------------- /test_project/test_project/templates/ab/page/variation1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ab-page-variation1 5 | 6 | -------------------------------------------------------------------------------- /test_project/test_project/templates/ab/st/test1/variation1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ab-st-variation1 5 | 6 | -------------------------------------------------------------------------------- /test_project/test_project/templates/simple_templates/test1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | simple_templates-test1 5 | 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .Python 3 | .DS_Store 4 | .idea 5 | build 6 | lib 7 | include 8 | src 9 | bin 10 | share 11 | dist 12 | MANIFEST 13 | *.egg-info 14 | -------------------------------------------------------------------------------- /test_project/test_project/templates/ab_templates/page/variation1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ab_templates-page-variation1 5 | 6 | -------------------------------------------------------------------------------- /test_project/test_project/templates/ab_templates/st/test1/variation1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ab_templates-st-variation1 5 | 6 | -------------------------------------------------------------------------------- /test_project/test_project/templates/ab/simple_templates/test1/variation1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ab-simple_templates-variation1 5 | 6 | -------------------------------------------------------------------------------- /test_project/test_project/templates/simple_templates/canonical.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block body %} 4 | page-canonical-variation1 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /test_project/test_project/templates/ab_templates/simple_templates/test1/variation1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ab_templates-simple_templates-variation1 5 | 6 | -------------------------------------------------------------------------------- /simple_templates/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SimpleTemplatesConfig(AppConfig): 5 | name = "simple_templates" 6 | label = "simple_templates" 7 | verbose_name = "Simple Templates" 8 | -------------------------------------------------------------------------------- /test_project/test_project/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load simple_templates %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | {% block body %} 9 | page-original 10 | {% endblock %} 11 | 12 | -------------------------------------------------------------------------------- /test_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /test_project/test_project/urls.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | from django.urls.conf import path 3 | 4 | from simple_templates.utils import get_ab_template 5 | 6 | 7 | def my_view(request): 8 | return render(request, get_ab_template(request, 'page.html')) 9 | 10 | 11 | urlpatterns = [ 12 | path('page/', my_view, name='my-page'), 13 | ] 14 | -------------------------------------------------------------------------------- /simple_templates/templatetags/simple_templates.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlsplit, parse_qs, urlencode, urlunsplit 2 | 3 | from django import template 4 | 5 | register = template.Library() 6 | 7 | 8 | @register.filter 9 | def remove_query_param(url, param): 10 | url_parts = list(urlsplit(url)) 11 | query = parse_qs(url_parts[3]) 12 | 13 | query.pop(param, None) 14 | 15 | url_parts[3] = urlencode(query, doseq=True) 16 | return urlunsplit(url_parts) 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 James Addison 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /test_project/test_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for test_project project. 3 | 4 | This module contains the WSGI application used by Django's development server 5 | and any production WSGI deployments. It should expose a module-level variable 6 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover 7 | this application via the ``WSGI_APPLICATION`` setting. 8 | 9 | Usually you will have the standard Django WSGI application here, but it also 10 | might make sense to replace the whole Django WSGI application with a custom one 11 | that later delegates to the Django one. For example, you could introduce WSGI 12 | middleware here, or combine a Django application with an application of another 13 | framework. 14 | 15 | """ 16 | import os 17 | 18 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 19 | 20 | # This application object is used by any WSGI server configured to use this 21 | # file. This includes Django's development server, if the WSGI_APPLICATION 22 | # setting points here. 23 | from django.core.wsgi import get_wsgi_application 24 | application = get_wsgi_application() 25 | 26 | # Apply WSGI middleware here. 27 | # from helloworld.wsgi import HelloWorldApplication 28 | # application = HelloWorldApplication(application) 29 | -------------------------------------------------------------------------------- /simple_templates/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.template import loader, TemplateDoesNotExist 4 | from django.template.loader import get_template as django_find_template 5 | 6 | from django.conf import settings 7 | 8 | 9 | SIMPLE_TEMPLATES_AB_PARAM = getattr(settings, 'SIMPLE_TEMPLATES_AB_PARAM', 'ab') 10 | SIMPLE_TEMPLATES_AB_DIR = getattr(settings, 'SIMPLE_TEMPLATES_AB_DIR', 'ab_templates') 11 | 12 | 13 | def find_template(template): 14 | try: 15 | django_find_template(template) 16 | return template 17 | except TemplateDoesNotExist: 18 | return None 19 | 20 | 21 | def get_ab_template(request, default=None): 22 | """ 23 | This function simply returns the a/b template path if the ab GET parameter exists, otherwise the contents of `default`. 24 | 25 | The path for the ab template is the concatenation of: 26 | - the SIMPLE_TEMPLATES_AB_DIR setting, 27 | - the path to the `default` template and 28 | - the SIMPLE_TEMPLATES_AB_PARAM value from request.GET 29 | """ 30 | template_name = request.GET.get(SIMPLE_TEMPLATES_AB_PARAM) 31 | if template_name: 32 | if default: 33 | (filepath, extension) = os.path.splitext(default) 34 | template_name = os.path.join(SIMPLE_TEMPLATES_AB_DIR, filepath, template_name + extension or '') 35 | return find_template(template_name) or default 36 | return default 37 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.setuptools.dynamic] 6 | version = {attr = "simple_templates.__version__"} 7 | 8 | [tool.setuptools.packages.find] 9 | include = ["simple_templates*"] 10 | exclude = ["simple_templates.tests*"] 11 | 12 | [project] 13 | dynamic = ["version"] 14 | name = "django-simple-templates" 15 | readme = "README.md" 16 | authors = [ 17 | { name = "James Addison", email = "addi00@gmail.com" }, 18 | ] 19 | maintainers = [ 20 | { name = "James Addison", email = "addi00@gmail.com" }, 21 | ] 22 | description = "Easy, designer-friendly templates and A/B testing friendly tools for Django." 23 | keywords = ['a/b testing', 'split testing', 'a/b', 'split'] 24 | license = { "text" = "MIT" } 25 | classifiers = [ 26 | "Development Status :: 5 - Production/Stable", 27 | "Intended Audience :: Developers", 28 | "Environment :: Web Environment", 29 | "Operating System :: OS Independent", 30 | "Programming Language :: Python", 31 | "Framework :: Django", 32 | "Topic :: Internet :: WWW/HTTP :: WSGI", 33 | ] 34 | requires-python = ">=3.8" 35 | dependencies = [ 36 | "django>=4.2", 37 | ] 38 | 39 | [project.urls] 40 | Homepage = "https://github.com/jaddison/django-simple-templates" 41 | Documentation = "https://github.com/jaddison/django-simple-templates" 42 | Issues = "https://github.com/jaddison/django-simple-templates/issues" 43 | -------------------------------------------------------------------------------- /simple_templates/middleware.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from django.conf import settings 4 | from django.views.decorators.csrf import requires_csrf_token 5 | from django.shortcuts import render, redirect 6 | 7 | from .utils import get_ab_template, find_template 8 | 9 | 10 | SIMPLE_TEMPLATES_DIR = getattr(settings, 'SIMPLE_TEMPLATES_DIR', 'simple_templates') 11 | 12 | class SimplePageFallbackMiddleware: 13 | def __init__(self, get_response): 14 | self.get_response = get_response 15 | self.is_async = asyncio.iscoroutinefunction(self.get_response) 16 | 17 | def __call__(self, request): 18 | if self.is_async: 19 | return self.__acall__(request) 20 | 21 | response = self.get_response(request) 22 | 23 | return self.process_response(request, response) 24 | 25 | async def __acall__(self, request): 26 | response = await self.get_response(request) 27 | 28 | return self.process_response(request, response) 29 | 30 | def process_response(self, request, response): 31 | # No need to check for a simple template for non-404 responses. 32 | if response.status_code != 404: 33 | return response 34 | 35 | # set up the location where this template should reside 36 | template = "{0}/{1}.html".format(SIMPLE_TEMPLATES_DIR, request.path.strip('/') or '_homepage_') 37 | 38 | # if it doesn't exist, continue with the 404 response 39 | if not find_template(template): 40 | return response 41 | 42 | # if the template exists, ensure the trailing slash is present and redirect if necessary 43 | if not request.path.endswith('/') and settings.APPEND_SLASH: 44 | url = request.path + "/" 45 | qs = request.META.get('QUERY_STRING') 46 | if qs: 47 | url += '?' + qs 48 | return redirect(url, permanent=True) 49 | 50 | @requires_csrf_token 51 | def protect_render(request): 52 | # check for the presence of an a/b test template page 53 | return render(request, get_ab_template(request, template)) 54 | 55 | return protect_render(request) 56 | -------------------------------------------------------------------------------- /test_project/test_project/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | # grab the base path so that we can test the simple_templates app 5 | PROJECT_PATH = os.path.dirname(os.path.realpath(__file__)) 6 | if PROJECT_PATH not in sys.path: 7 | sys.path.insert(0, os.path.dirname(os.path.dirname(PROJECT_PATH))) 8 | 9 | DEBUG = True 10 | TEMPLATE_DEBUG = DEBUG 11 | 12 | DATABASES = { 13 | 'default': { 14 | 'ENGINE': 'django.db.backends.sqlite3', 15 | 'NAME': '', 16 | } 17 | } 18 | 19 | TIME_ZONE = 'UTC' 20 | LANGUAGE_CODE = 'en-us' 21 | SECRET_KEY = 'secret' 22 | 23 | MIDDLEWARE = ( 24 | "django.middleware.security.SecurityMiddleware", 25 | "django.contrib.sessions.middleware.SessionMiddleware", 26 | "django.middleware.common.CommonMiddleware", 27 | "django.middleware.csrf.CsrfViewMiddleware", 28 | "django.contrib.auth.middleware.AuthenticationMiddleware", 29 | "django.contrib.messages.middleware.MessageMiddleware", 30 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 31 | "simple_templates.middleware.SimplePageFallbackMiddleware", 32 | ) 33 | 34 | ROOT_URLCONF = 'test_project.urls' 35 | 36 | # Python dotted path to the WSGI application used by Django's runserver. 37 | WSGI_APPLICATION = 'test_project.wsgi.application' 38 | 39 | TEMPLATES = [ 40 | { 41 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 42 | 'APP_DIRS': True, 43 | 'DIRS': ( 44 | # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". 45 | # Always use forward slashes, even on Windows. 46 | # Don't forget to use absolute paths, not relative paths. 47 | os.path.join(PROJECT_PATH, 'templates'), 48 | ), 49 | "OPTIONS": { 50 | "context_processors": [ 51 | "django.template.context_processors.request", 52 | ], 53 | } 54 | }, 55 | ] 56 | 57 | INSTALLED_APPS = ( 58 | 'django.contrib.auth', 59 | 'django.contrib.contenttypes', 60 | 'django.contrib.sessions', 61 | 'django.contrib.sites', 62 | 'django.contrib.messages', 63 | 'django.contrib.staticfiles', 64 | 65 | 'simple_templates', 66 | ) 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Overview 2 | ---- 3 | **django-simple-templates** provides easy, designer-friendly templates and A/B testing (split testing) friendly tools for Django. If you have used Django's ``flatpages`` app, you'll be able to appreciate what **django-simple-templates** gives you. 4 | 5 | Objectives 6 | ---- 7 | **django-simple-templates** is intended to: 8 | 9 | - provide the means to **isolate template designer effort**; reduce web developer involvement 10 | - provide an easy way to **launch flat or simple pages quickly**; no URL pattern or view needed 11 | - provide a quick and simple method to **test page variations with Django templates** 12 | 13 | 14 | Use Cases 15 | ---- 16 | If you need to quickly launch landing pages for marketing campaigns, then **django-simple-templates** is for you. If you have a great web designer who knows next to nothing about Django, then **django-simple-templates** is a good fit. It helps to reduce the need for: 17 | 18 | - training web designers on Django URL patterns, views, etc. - you can restrict the necessary knowledge to Django templates and template tags (custom and/or builtin) 19 | - involving web developers to create stub page templates or to convert designer-created static HTML pages to Django templates 20 | 21 | If you want to be able to **A/B test any Django template**, then **django-simple-templates** will absolutely help you. I've always found A/B testing with Django (and frameworks in general) to be somewhat painful - hopefully this app alleviates that pain for others too. 22 | 23 | 24 | Installation 25 | ---- 26 | It's a standard PyPi install: 27 | 28 | pip install django-simple-templates 29 | 30 | To use the simple page template functionality, add the ``SimplePageFallbackMiddleware`` to your ``MIDDLEWARE_CLASSES`` in your ``settings.py``: 31 | 32 | MIDDLEWARE_CLASSES = ( 33 | ... # other middleware here 34 | 'simple_templates.middleware.SimplePageFallbackMiddleware' 35 | ) 36 | 37 | Note that this middleware is not necessary if you only want to use the ``get_ab_template`` functionality (see below). 38 | 39 | 40 | Configuration Options 41 | ---- 42 | **django-simple-templates** has a few options to help cater to your project's needs. You can override these by setting them in your settings.py. Each has an acceptable default value, so you do not *need* to set them: 43 | 44 | - `SIMPLE_TEMPLATES_AB_PARAM`: optional; defaults to `"ab"`. This is the query string (`request.GET`) parameter that contains the name of the A/B testing template name. 45 | - `SIMPLE_TEMPLATES_AB_DIR`: optional; defaults to `"ab_templates`". This is the subdirectory inside your template directory where you should place your A/B testing page templates. 46 | - `SIMPLE_TEMPLATES_DIR`: optional; defaults to `"simple_templates`". This is the subdirectory inside your template directory where you should place your simple page templates. 47 | 48 | 49 | Usage 50 | ---- 51 | To create a "simple template" page, all you need to do is create a template file under ``SIMPLE_TEMPLATES_DIR``. This is your standard Django template format, inheritance, etc. The directory structure you place it in determines the URL structure. For example, creating a template here: 52 | 53 | /simple_templates/en/contact.html 54 | 55 | would result in the a URL structure like: 56 | 57 | http://www.example.com/en/contact/ 58 | 59 | The ``SimplePageFallbackMiddleware`` middleware kicks in and looks for possible template file matches when an ``Http404`` is the response to a web request, so if you had a URL pattern and view that handled the ``/en/contact/`` URL, this middleware would not do anything at all. 60 | 61 | To create an A/B testing template (the variation template) for the example simple page template above, you'd create the variation template under the appropriate directory structure under ``SIMPLE_TEMPLATES_AB_DIR``: 62 | 63 | /ab_templates/simple_templates/en/contact/variation1.html 64 | 65 | and the resulting URL would be: 66 | 67 | http://www.example.com/en/contact/?ab=variation1 68 | 69 | So you can see that the A/B testing variation template needs to exist in a directory structure mimicking the original template's directory structure plus its filename without extension. 70 | 71 | **Special case:** If you want to create simple page template for the root 'home' page of your website, you given the simple template a special name of ``_homepage_.html``. URL and directory example: 72 | 73 | /simple_templates/_homepage_.html 74 | 75 | would be accessible at: 76 | 77 | http://www.example.com/ 78 | 79 | If you wanted to create an A/B testing variation template on this page, the simple variation template would exist here: 80 | 81 | /ab_templates/simple_templates/_homepage_/variation2.html 82 | 83 | and you'd access it like the examples above: 84 | 85 | http://www.example.com/?ab=variation2 86 | 87 | 88 | Using A/B Testing in Django Views 89 | ---- 90 | To use the A/B testing functionality in your existing code, import ``get_ab_template`` and use it in your view: 91 | 92 | from django.shortcuts import render 93 | from simple_templates.utils import get_ab_template 94 | 95 | def user_signup(request): 96 | template = get_ab_template(request, 'profiles/user/signup.html') 97 | return render(request, template) 98 | 99 | The ``get_ab_template`` function works like this: 100 | 101 | - pass Django's `request` object and the view's normal template into `get_ab_template` 102 | - the `get_ab_template` will look in request.GET to see if there was an `ab` parameter in the query string 103 | - if `ab` is found in request.GET, `get_ab_template` will attempt to find the associated template file under ``SIMPLE_TEMPLATES_AB_DIR`` 104 | - if the `ab` template file is found, the `ab` template path is returned 105 | - if either `ab` or the template file associated with `ab` is not found, the passed-in 'default' template file is returned 106 | 107 | Here's an example to demonstrate. If you want to A/B test your signup page with the URL: 108 | 109 | http://www.example.com/user/signup/ 110 | 111 | and your current user signup template file located here: 112 | 113 | /profiles/user/signup.html 114 | 115 | with a variation called 'fewer-inputs', you would first modify your Django view for a user signing up to use ``get_ab_template`` and you would have this URL as your variation page: 116 | 117 | http://www.example.com/user/signup/?ab=fewer-inputs 118 | 119 | and your variation template file should be placed here: 120 | 121 | /ab_templates/profiles/user/signup/fewer-inputs.html 122 | 123 | 124 | Tips for Optimising your Implementation 125 | ---- 126 | 127 | ### SEO Considerations 128 | You need to ensure you don't create duplicate content for search engines. What's duplicate content? Two pages that are (almost) identical. When you're doing A/B testing, you're frequently doing minor variations on a theme - perhaps only the colour of a single button. 129 | 130 | **Canonical link elements** to the rescue. The link should point to the 'canonical' page URL (without the `'ab=variation-name'` parameter). This would be the original page URL that you want indexed by search engines. This way, any search engine that sees a variation template page will 'ignore' it because you're telling it to see it the same as the original page. 131 | 132 | But you can make this easier by using the included `remove_query_param` template filter in your base.html, like so: 133 | 134 | 135 | 136 | base.html template 137 | 138 | 139 | 140 | ... 141 | 142 | 143 | 144 | Extend **all** of your templates (normal view templates, simple templates, and A/B templates) from this `base.html`. Here, the ``remove_query_param`` template filter removes the ``ab`` parameter to create the canonical link for you on **every single page** on your site, making split testing easy, one less thing to think about. 145 | 146 | Note that in your ``settings.py`` you'll need to update the `TEMPLATES` setting to add ``"django.core.context_processors.request"`` to the [`context_processors` option](https://docs.djangoproject.com/en/4.2/topics/templates/#django.template.backends.django.DjangoTemplates). 147 | 148 | 149 | Tests 150 | ---- 151 | To run the **django-simple-templates** tests, follow these steps: 152 | 153 | - clone the **django-simple-templates** repository 154 | - change directory into the repository 155 | - initialize a 'virtualenv': ``python -m venv venv`` 156 | - activate the virtualenv: ``source venv/bin/activate`` 157 | - install the dependencies for testing **django-simple-templates**: ``pip install -r test_project/test-requirements.txt`` 158 | - run the tests: ``python test_project/manage.py test simple_templates`` 159 | 160 | Tests have been run under: 161 | - Python 2.7.3 and Django 1.4.3 162 | - (please report other results) 163 | 164 | 165 | Compatibility 166 | ---- 167 | **django-simple-templates** been used in the following version configurations: 168 | 169 | - Python 3.8+ 170 | - Django 4.2+ 171 | 172 | Questions, Comments, Concerns: 173 | ---- 174 | Feel free to open an issue here: http://github.com/jaddison/django-simple-templates/issues/ - or better yet, submit a pull request with fixes and improvements. 175 | -------------------------------------------------------------------------------- /simple_templates/tests.py: -------------------------------------------------------------------------------- 1 | from importlib import reload 2 | 3 | from django.http import HttpResponseNotFound, HttpResponse, HttpResponsePermanentRedirect 4 | from django.test import TestCase 5 | from django.test.utils import override_settings 6 | from django.urls.base import reverse 7 | 8 | # import the simple_templates middleware and utils modules so that we can 9 | # reload it for each of our testcases, as it needs to reset internal settings 10 | # based on @override_settings() changes. 11 | from simple_templates import middleware, utils 12 | 13 | 14 | class DefaultTest(TestCase): 15 | def setUp(self): 16 | reload(middleware) 17 | reload(utils) 18 | 19 | def test_nonexistent_template_notfound(self): 20 | response = self.client.get('/foo/') 21 | self.assertEqual(type(response), HttpResponseNotFound) 22 | 23 | def test_nonexistent_template_notfound_noslash(self): 24 | # with no trailing slash and APPEND_SLASH = True, we don't want to redirect 25 | # to a slash-corrected URL if the simple template doesn't exist - wasted 26 | # server hit. 27 | response = self.client.get('/foo') 28 | self.assertEqual(type(response), HttpResponseNotFound) 29 | 30 | @override_settings(APPEND_SLASH=False) 31 | def test_nonexistent_template_notfound_noslash_noappendslash(self): 32 | # we shouldn't redirect at all with APPEND_SLASH=False, no matter what 33 | response = self.client.get('/foo') 34 | self.assertEqual(type(response), HttpResponseNotFound) 35 | 36 | def test_template_redirect(self): 37 | # default settings have APPEND_SLASH = True, so if there is no slash but the 38 | # template exists, it should redirect to the correct page 39 | path = '/test1' 40 | corrected_path = '/test1/' 41 | response = self.client.get(path) 42 | redirect_location = response.get('Location', '') 43 | 44 | self.assertEqual(type(response), HttpResponsePermanentRedirect) 45 | 46 | # the redirect location most definitely should not end with the same path as 47 | # originally requested, otherwise it's redirecting incorrectly (should have a 48 | # trailing slash) 49 | self.assertTrue(redirect_location.endswith(corrected_path)) 50 | 51 | def test_template_redirect_ab(self): 52 | # comments from `test_template_redirect` above apply 53 | path = '/test1?ab=variation1' 54 | 55 | # the corrected path should keep the original query string 56 | corrected_path = '/test1/?ab=variation1' 57 | response = self.client.get(path) 58 | redirect_location = response.get('Location', '') 59 | 60 | self.assertEqual(type(response), HttpResponsePermanentRedirect) 61 | self.assertTrue(redirect_location.endswith(corrected_path)) 62 | 63 | def test_template_found(self): 64 | response = self.client.get('/test1/') 65 | self.assertEqual(type(response), HttpResponse) 66 | self.assertContains(response, 'simple_templates-test1') 67 | 68 | def test_template_found_ab(self): 69 | response = self.client.get('/test1/?ab=variation1') 70 | self.assertEqual(type(response), HttpResponse) 71 | self.assertContains(response, 'simple_templates-variation1') 72 | 73 | def test_template_found_ab_nonexistent(self): 74 | response = self.client.get('/test1/?ab=variation-bad') 75 | self.assertEqual(type(response), HttpResponse) 76 | self.assertContains(response, 'simple_templates-test1') 77 | 78 | @override_settings(APPEND_SLASH=False) 79 | def test_template_found_noappendslash(self): 80 | response = self.client.get('/test1') 81 | self.assertEqual(type(response), HttpResponse) 82 | self.assertContains(response, 'simple_templates-test1') 83 | 84 | @override_settings(APPEND_SLASH=False) 85 | def test_template_found_ab_noappendslash(self): 86 | response = self.client.get('/test1?ab=variation1') 87 | self.assertEqual(type(response), HttpResponse) 88 | self.assertContains(response, 'simple_templates-variation1') 89 | 90 | @override_settings(APPEND_SLASH=False) 91 | def test_template_found_ab_nonexistent_noappendslash(self): 92 | response = self.client.get('/test1?ab=variation-bad') 93 | self.assertEqual(type(response), HttpResponse) 94 | self.assertContains(response, 'simple_templates-test1') 95 | 96 | def test_page_found(self): 97 | response = self.client.get(reverse('my-page')) 98 | self.assertEqual(type(response), HttpResponse) 99 | self.assertContains(response, 'page-original') 100 | 101 | def test_page_found_ab_nonexistent(self): 102 | response = self.client.get(reverse('my-page') + '?ab=variation-bad') 103 | self.assertEqual(type(response), HttpResponse) 104 | self.assertContains(response, 'page-original') 105 | 106 | def test_page_found_ab(self): 107 | template = reverse('my-page') + '?ab=variation1' 108 | response = self.client.get(template) 109 | self.assertEqual(type(response), HttpResponse) 110 | self.assertContains(response, 'ab_templates-page-variation1') 111 | 112 | 113 | @override_settings(SIMPLE_TEMPLATES_DIR='st') 114 | class ChangeSimpleTemplatesDirValidTest(TestCase): 115 | def setUp(self): 116 | reload(middleware) 117 | reload(utils) 118 | 119 | def test_template_found(self): 120 | response = self.client.get('/test1/') 121 | self.assertEqual(type(response), HttpResponse) 122 | self.assertContains(response, 'st-test1') 123 | 124 | def test_template_found_notexistent_ab(self): 125 | response = self.client.get('/test1/?ab=variation-bad') 126 | self.assertEqual(type(response), HttpResponse) 127 | self.assertContains(response, 'st-test1') 128 | 129 | def test_template_found_ab(self): 130 | response = self.client.get('/test1/?ab=variation1') 131 | self.assertEqual(type(response), HttpResponse) 132 | self.assertContains(response, 'ab_templates-st-variation1') 133 | 134 | 135 | @override_settings(SIMPLE_TEMPLATES_DIR='st-notfound') 136 | class ChangeSimpleTemplatesDirInValidTest(TestCase): 137 | def setUp(self): 138 | reload(middleware) 139 | reload(utils) 140 | 141 | def test_template_found(self): 142 | response = self.client.get('/test1/') 143 | self.assertEqual(type(response), HttpResponseNotFound) 144 | 145 | 146 | @override_settings(SIMPLE_TEMPLATES_AB_DIR='ab') 147 | class ChangeABTemplatesDirValidTest(TestCase): 148 | def setUp(self): 149 | reload(middleware) 150 | reload(utils) 151 | 152 | def test_template_found_notexistent_ab(self): 153 | response = self.client.get('/test1/?ab=variation-bad') 154 | self.assertEqual(type(response), HttpResponse) 155 | self.assertContains(response, 'simple_templates-test1') 156 | 157 | def test_template_found_ab(self): 158 | response = self.client.get('/test1/?ab=variation1') 159 | self.assertEqual(type(response), HttpResponse) 160 | self.assertContains(response, 'ab-simple_templates-variation1') 161 | 162 | def test_page_found_ab_nonexistent(self): 163 | response = self.client.get(reverse('my-page') + '?ab=variation-bad') 164 | self.assertEqual(type(response), HttpResponse) 165 | self.assertContains(response, 'page-original') 166 | 167 | def test_page_found_ab(self): 168 | template = reverse('my-page') + '?ab=variation1' 169 | response = self.client.get(template) 170 | self.assertEqual(type(response), HttpResponse) 171 | self.assertContains(response, 'ab-page-variation1') 172 | 173 | 174 | @override_settings(SIMPLE_TEMPLATES_AB_DIR='ab-notfound') 175 | class ChangeABTemplatesDirInValidTest(TestCase): 176 | def setUp(self): 177 | reload(middleware) 178 | reload(utils) 179 | 180 | def test_template_ab_notfound(self): 181 | response = self.client.get('/test1/?ab=variation1') 182 | self.assertEqual(type(response), HttpResponse) 183 | self.assertContains(response, 'simple_templates-test1') 184 | 185 | 186 | @override_settings(SIMPLE_TEMPLATES_AB_PARAM='abtest') 187 | class ChangeABParamTest(TestCase): 188 | def setUp(self): 189 | reload(middleware) 190 | reload(utils) 191 | 192 | def test_template_found_ab_wrongparamname(self): 193 | response = self.client.get('/test1/?ab=variation1') 194 | self.assertEqual(type(response), HttpResponse) 195 | self.assertContains(response, 'simple_templates-test1') 196 | 197 | def test_template_found_ab(self): 198 | response = self.client.get('/test1/?abtest=variation1') 199 | self.assertEqual(type(response), HttpResponse) 200 | self.assertContains(response, 'ab_templates-simple_templates-variation1') 201 | 202 | def test_page_found_ab_nonexistent(self): 203 | response = self.client.get(reverse('my-page') + '?ab=variation1') 204 | self.assertEqual(type(response), HttpResponse) 205 | self.assertContains(response, 'page-original') 206 | 207 | def test_page_found_ab(self): 208 | template = reverse('my-page') + '?abtest=variation1' 209 | response = self.client.get(template) 210 | self.assertEqual(type(response), HttpResponse) 211 | self.assertContains(response, 'ab_templates-page-variation1') 212 | 213 | 214 | class CanonicalURLTest(TestCase): 215 | def setUp(self): 216 | reload(middleware) 217 | reload(utils) 218 | 219 | def test_canonical(self): 220 | response = self.client.get('/canonical/?ab=variation1') 221 | self.assertEqual(type(response), HttpResponse) 222 | self.assertContains(response, 'page-canonical-variation1') 223 | self.assertNotContains(response, '') 224 | self.assertContains(response, '') 225 | --------------------------------------------------------------------------------