├── 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 |