Skip to content
Browse files

freeing django-mobility from the clutches of amo

  • Loading branch information...
0 parents commit e9fc5f7587311350326f044378d5b51b5257afa3 Jeff Balogh committed Feb 3, 2011
Showing with 406 additions and 0 deletions.
  1. +27 −0 LICENSE
  2. +2 −0 MANIFEST.in
  3. +110 −0 README.rst
  4. 0 mobility/__init__.py
  5. +51 −0 mobility/decorators.py
  6. +50 −0 mobility/middleware.py
  7. +118 −0 mobility/tests.py
  8. +17 −0 runtests.sh
  9. +31 −0 setup.py
27 LICENSE
@@ -0,0 +1,27 @@
+Copyright (c) 2011, Mozilla Foundation.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+ 1. Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+
+ 2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+
+ 3. Neither the name of django-mobility nor the names of its contributors
+ may be used to endorse or promote products derived from this software
+ without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
2 MANIFEST.in
@@ -0,0 +1,2 @@
+include LICENSE
+include README.rst
110 README.rst
@@ -0,0 +1,110 @@
+What is this?
+-------------
+
+This library is a collection of middleware and decorators that help in creating
+mobile views and directing users to the mobile version of your site. It makes
+these assumptions:
+
+ * You can use Vary: User-Agent to serve mobile and non-mobile content through
+ the same URLs.
+ * You want to use separate views and/or templates for the mobile site. If
+ you're building a mobile experience through media queries this library won't
+ be helpful.
+ * Not all views from the normal site need to be replaced with mobile views.
+
+
+Support
+-------
+
+Written and tested on Django trunk with Python 2.6.
+
+
+Setup
+-----
+
+These are the default settings::
+
+ # A regex for detecting mobile user agents.
+ MOBILE_USER_AGENTS = 'android|fennec|iemobile|iphone|opera (?:mini|mobi)'
+ # The name of the cookie to set if the user prefers the mobile site.
+ MOBILE_COOKIE = 'mobile'
+
+You need these middleware (but see the User Agent caveats below)::
+
+ MIDDLEWARE_CLASSES = (
+ 'mobile.middleware.DetectMobileMiddleware',
+ 'mobile.middleware.XMobileMiddleware',
+ )
+
+
+How the Mobile Site is Chosen
+-----------------------------
+
+1. The ``HTTP_USER_AGENT`` matches ``MOBILE_USER_AGENTS`` and the
+ ``MOBILE_COOKIE`` is not set to ``off``.
+2. *or* the ``MOBILE_COOKIE`` is set to ``on``.
+3. A mobile view exists for the current URL.
+
+The ``HTTP_USER_AGENT`` is checked against the regular expression in
+``MOBILE_USER_AGENTS``. The default is a very basic set of user agents to ease
+maintenance and because the cookie provides a fallback.
+
+If ``MOBILE_COOKIE`` is set to ``on``, through ``Set-Cookie`` or through
+javascript, the mobile site will be chosen regardless of the user agent. If
+``MOBILE_COOKIE`` is set to ``off`` the normal site will always be chosen.
+
+
+Changes to the ``request`` Object
+---------------------------------
+
+If the current request is for the mobile site, ``request.MOBILE = True``. At
+all other times ``request.MOBILE = False``.
+
+
+Decorators
+----------
+
+Some decorators are provided to assist with common idioms::
+
+ @mobile_template('app/{mobile/}detail.html')
+ def view(request, template=None):
+ ...
+
+``@mobile_template`` helps with the pattern of using the same view code and
+template context, but switching to a different template for mobile. It follows
+this logic::
+
+ template = 'app/mobile/detail.html' if request.MOBILE else 'app/detail.html'
+
+To use a completely different function for the mobile view::
+
+ def view(request):
+ ...
+
+ @mobilized(view)
+ def view(request):
+ ...
+
+In the example, the first definition of ``view`` will be used for the normal
+site and the second function will be used for the mobile site. The normal and
+mobile site point to the same view in ``urls.py`` and the decorator handles
+choosing which view to run.
+
+
+Varying on User Agent
+---------------------
+
+Since mobile users can enter the site from any normal URL, the
+``DetectMobileMiddleware`` always inspects the ``User-Agent`` to see if it
+matches something in ``MOBILE_USER_AGENTS``, and may redirect the browser to
+the mobile site. Thus, every URL on the site should be sending ``Vary:
+User-Agent`` to get proper HTTP caching. Varying on User-Agent can be
+detrimental to your frontend cache scheme, so it's recommended that you move
+mobile detection up the stack to a frontend proxy.
+
+The proxy can run the logic in ``DetectMobileMiddleware`` and set
+``HTTP_X_MOBILE`` (so we know whether to serve a mobile view) without varying
+on user agent internally. Instead, it can vary on ``X-Mobile`` while
+sending ``Vary: User-Agent`` back to the client. From the outside it looks like
+the app varies on ``User-Agent`` but the proxy will only need to cache a
+mobile and non-mobile version of the URL.
0 mobility/__init__.py
No changes.
51 mobility/decorators.py
@@ -0,0 +1,51 @@
+import functools
+
+
+def mobile_template(template):
+ """
+ Mark a function as mobile-ready and pass a mobile template if MOBILE.
+
+ @mobile_template('a/{mobile/}/b.html')
+ def view(request, template=None):
+ ...
+
+ if request.MOBILE=True the template will be 'a/mobile/b.html'.
+ if request.MOBILE=False the template will be 'a/b.html'.
+
+ This function is useful if the mobile view uses the same context but a
+ different template.
+ """
+ def decorator(f):
+ @functools.wraps(f)
+ def wrapper(request, *args, **kw):
+ fmt = {'mobile/': 'mobile/' if request.MOBILE else ''}
+ kw['template'] = template.format(**fmt)
+ return f(request, *args, **kw)
+ return wrapper
+ return decorator
+
+
+def mobilized(normal_fn):
+ """
+ Replace a view function with a normal and mobile view.
+
+ def view(request):
+ ...
+
+ @mobilized(view)
+ def view(request):
+ ...
+
+ The second function is the mobile version of view. The original
+ function is overwritten, and the decorator will choose the correct
+ function based on request.MOBILE (set in middleware).
+ """
+ def decorator(mobile_fn):
+ @functools.wraps(mobile_fn)
+ def wrapper(request, *args, **kw):
+ if request.MOBILE:
+ return mobile_fn(request, *args, **kw)
+ else:
+ return normal_fn(request, *args, **kw)
+ return wrapper
+ return decorator
50 mobility/middleware.py
@@ -0,0 +1,50 @@
+import re
+
+from django.conf import settings
+from django.http import HttpResponsePermanentRedirect
+from django.utils.cache import patch_vary_headers
+
+
+# Mobile user agents.
+USER_AGENTS = 'android|fennec|iemobile|iphone|opera (?:mini|mobi)'
+USER_AGENTS = re.compile(getattr(settings, 'MOBILE_USER_AGENTS', USER_AGENTS))
+
+# We set a cookie if you explicitly select mobile/no mobile.
+COOKIE = getattr(settings, 'MOBILE_COOKIE', 'mobile')
+
+
+# We do this in zeus for performance, so this exists for the devserver and
+# to work out the logic.
+class DetectMobileMiddleware(object):
+
+ def process_request(self, request):
+ ua = request.META.get('HTTP_USER_AGENT', '').lower()
+ mc = request.COOKIES.get(COOKIE)
+ if (USER_AGENTS.search(ua) and mc != 'off') or mc == 'on':
+ request.META['HTTP_X_MOBILE'] = '1'
+
+ def process_response(self, request, response):
+ patch_vary_headers(response, ['User-Agent'])
+ return response
+
+
+class XMobileMiddleware(object):
+
+ def redirect(self, request, base):
+ path = base.rstrip('/') + request.path
+ if request.GET:
+ path += '?' + request.GET.urlencode()
+ response = HttpResponsePermanentRedirect(path)
+ response['Vary'] = 'X-Mobile'
+ return response
+
+ def process_request(self, request):
+ try:
+ want_mobile = int(request.META.get('HTTP_X_MOBILE', 0))
+ except Exception:
+ want_mobile = False
+ request.MOBILE = want_mobile
+
+ def process_response(self, request, response):
+ patch_vary_headers(response, ['X-Mobile'])
+ return response
118 mobility/tests.py
@@ -0,0 +1,118 @@
+# -*- coding: utf-8 -*-
+import unittest
+
+from django import http, test
+from django.conf import settings
+from django.utils import http as urllib
+
+from mobility import decorators, middleware
+
+
+FENNEC = ('Mozilla/5.0 (Android; Linux armv7l; rv:2.0b8) '
+ 'Gecko/20101221 Firefox/4.0b8 Fennec/4.0b3')
+FIREFOX = 'Mozilla/5.0 (Windows NT 5.1; rv:2.0b9) Gecko/20100101 Firefox/4.0b9'
+
+
+class TestDetectMobile(unittest.TestCase):
+
+ def check(self, mobile, ua=None, cookie=None):
+ d = {}
+ if cookie:
+ d['HTTP_COOKIE'] = 'mobile=%s' % cookie
+ if ua:
+ d['HTTP_USER_AGENT'] = ua
+ request = test.RequestFactory().get('/', **d)
+ response = middleware.DetectMobileMiddleware().process_request(request)
+ assert response is None
+ if mobile:
+ self.assertEqual(request.META['HTTP_X_MOBILE'], '1')
+ else:
+ assert 'HTTP_X_MOBILE' not in request.META
+
+ def test_mobile_ua(self):
+ self.check(mobile=True, ua=FENNEC)
+
+ def test_mobile_ua_and_cookie_on(self):
+ self.check(mobile=True, ua=FENNEC, cookie='on')
+
+ def test_mobile_ua_and_cookie_off(self):
+ self.check(mobile=False, ua=FENNEC, cookie='off')
+
+ def test_nonmobile_ua(self):
+ self.check(mobile=False, ua=FIREFOX)
+
+ def test_nonmobile_ua_and_cookie_on(self):
+ self.check(mobile=True, ua=FIREFOX, cookie='on')
+
+ def test_nonmobile_ua_and_cookie_off(self):
+ self.check(mobile=False, ua=FIREFOX, cookie='off')
+
+ def test_no_ua(self):
+ self.check(mobile=False)
+
+
+class TestXMobile(unittest.TestCase):
+
+ def check(self, xmobile, mobile):
+ request = test.RequestFactory().get('/')
+ if xmobile:
+ request.META['HTTP_X_MOBILE'] = xmobile
+ middleware.XMobileMiddleware().process_request(request)
+ self.assertEqual(request.MOBILE, mobile)
+
+ def test_bad_xmobile(self):
+ self.check(xmobile='xxx', mobile=False)
+
+ def test_no_xmobile(self):
+ self.check(xmobile=None, mobile=False)
+
+ def test_xmobile_1(self):
+ self.check(xmobile='1', mobile=True)
+
+ def test_xmobile_0(self):
+ self.check(xmobile='0', mobile=False)
+
+ def test_vary(self):
+ request = test.RequestFactory().get('/')
+ response = http.HttpResponse()
+ r = middleware.XMobileMiddleware().process_response(request, response)
+ assert r is response
+ self.assertEqual(response['Vary'], 'X-Mobile')
+
+ response['Vary'] = 'User-Agent'
+ middleware.XMobileMiddleware().process_response(request, response)
+ self.assertEqual(response['Vary'], 'User-Agent, X-Mobile')
+
+
+class TestMobilized(unittest.TestCase):
+
+ def setUp(self):
+ normal = lambda r: 'normal'
+ mobile = lambda r: 'mobile'
+ self.view = decorators.mobilized(normal)(mobile)
+ self.request = test.RequestFactory().get('/')
+
+ def test_call_normal(self):
+ self.request.MOBILE = False
+ self.assertEqual(self.view(self.request), 'normal')
+
+ def test_call_mobile(self):
+ self.request.MOBILE = True
+ self.assertEqual(self.view(self.request), 'mobile')
+
+
+class TestMobileTemplate(unittest.TestCase):
+
+ def setUp(self):
+ template = 'a/{mobile/}b.html'
+ func = lambda request, template: template
+ self.view = decorators.mobile_template(template)(func)
+ self.request = test.RequestFactory().get('/')
+
+ def test_normal_template(self):
+ self.request.MOBILE = False
+ self.assertEqual(self.view(self.request), 'a/b.html')
+
+ def test_mobile_template(self):
+ self.request.MOBILE = True
+ self.assertEqual(self.view(self.request), 'a/mobile/b.html')
17 runtests.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+
+SETTINGS='settings.py'
+
+cat > $SETTINGS <<EOF
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.sqlite3'
+ },
+}
+EOF
+
+export DJANGO_SETTINGS_MODULE=settings
+
+python -m unittest discover
+
+rm -f $SETTINGS
31 setup.py
@@ -0,0 +1,31 @@
+import os
+from setuptools import setup, find_packages
+
+ROOT = os.path.abspath(os.path.dirname(__file__))
+
+
+setup(
+ name='django-mobility',
+ version='0.1',
+ description='Middleware and decorators for directing users to your mobile site.',
+ long_description=open(os.path.join(ROOT, 'README.rst')).read(),
+ author='Jeff Balogh',
+ author_email='jbalogh@mozilla.com',
+ url='http://github.com/jbalogh/django-mobility',
+ license='BSD',
+ packages=find_packages(),
+ include_package_data=True,
+ install_requires=['django'],
+ zip_safe=False,
+ classifiers=[
+ 'Development Status :: 4 - Beta',
+ 'Environment :: Web Environment',
+ 'Environment :: Web Environment :: Mozilla',
+ 'Framework :: Django',
+ 'Intended Audience :: Developers',
+ 'License :: OSI Approved :: BSD License',
+ 'Operating System :: OS Independent',
+ 'Programming Language :: Python',
+ 'Topic :: Software Development :: Libraries :: Python Modules',
+ ]
+)

0 comments on commit e9fc5f7

Please sign in to comment.
Something went wrong with that request. Please try again.