├── .gitignore ├── .travis.yml ├── AUTHORS.txt ├── CHANGES.rst ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── django_mobile ├── __init__.py ├── cache │ ├── __init__.py │ └── middleware.py ├── compat.py ├── conf.py ├── context_processors.py ├── loader.py ├── middleware.py └── models.py ├── django_mobile_tests ├── __init__.py ├── cache_settings.py ├── manage.py ├── models.py ├── settings.py ├── templates │ ├── index.html │ └── mobile │ │ └── index.html ├── test_base.py └── urls.py ├── examples └── middleware.py ├── requirements └── tests.txt ├── runtests.py ├── setup.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | .tox/ 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | env: 3 | - TOX_ENV=py26-15 4 | - TOX_ENV=py26-16 5 | - TOX_ENV=py27-15 6 | - TOX_ENV=py27-16 7 | - TOX_ENV=py27-17 8 | - TOX_ENV=py27-18 9 | - TOX_ENV=py27-19 10 | - TOX_ENV=py33-17 11 | - TOX_ENV=py33-18 12 | - TOX_ENV=py34-17 13 | - TOX_ENV=py34-18 14 | - TOX_ENV=py34-19 15 | - TOX_ENV=pypy-15 16 | - TOX_ENV=pypy-16 17 | - TOX_ENV=pypy-17 18 | - TOX_ENV=pypy-18 19 | - TOX_ENV=pypy-19 20 | before_install: 21 | - sudo pip install tox 22 | script: 23 | - tox -e $TOX_ENV 24 | deploy: 25 | provider: pypi 26 | user: gremu 27 | password: 28 | secure: mT6Gzp14P5GuWUi0MlXiBZuH6pb6M6daPe350BLo+66DQ/s1RMqqBU3lu7KTaNIKui1ZoitfNyiMiQ0QYP3+4RvR2/9HK9BEqVY7lK5mDxRWxDr1lVUnV3Bg37Vqj8792xdstDbRwP7/EkZEuviTTWAMEbYE8YJu4M/XLWpgnYY= 29 | on: 30 | tags: true 31 | repo: gregmuellegger/django-mobile 32 | condition: "$TOX_ENV = py34-18" 33 | -------------------------------------------------------------------------------- /AUTHORS.txt: -------------------------------------------------------------------------------- 1 | Gregor Müllegger https://github.com/gregmuellegger 2 | Ryan Showalter 3 | Saverio https://github.com/mucca 4 | Mike Shultz https://github.com/mikeshultz 5 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 0.8.0 (in development) 5 | ---------------------- 6 | 7 | * `#68`_: Support recursive template inheritance in django mobile template 8 | loader, so that we are compatible with Django's 1.9 admin. Thanks to 9 | @wolfg1969 for the patch. 10 | 11 | .. _#68: https://github.com/gregmuellegger/django-mobile/issues/68 12 | 13 | 0.7.0 14 | ----- 15 | 16 | * `#64`_: Fixing ``cache_page`` decorator and splitting the 17 | ``CacheFlavourMiddleware`` into two middlewares. This follows the same 18 | strategy as Django did since quite a while. Please see `#64`_ for more 19 | details about why this is necessary. 20 | 21 | If you are using ``CacheFlavourMiddleware``, you need to replace it now with 22 | ``FetchFromCacheFlavourMiddleware`` and ``UpdateCacheMiddleware``. Please 23 | consolidate the README for more information. 24 | 25 | Thanks to Yury Paykov for the patch. 26 | 27 | .. _#64: https://github.com/gregmuellegger/django-mobile/pull/64 28 | 29 | 0.6.0 30 | ----- 31 | 32 | * `#63`_: Django 1.9 support. Thanks to Alexandre Vicenzi for the patch. 33 | 34 | .. _#63: https://github.com/gregmuellegger/django-mobile/pull/63 35 | 36 | 0.5.1 37 | ----- 38 | 39 | * `#58`_: Fix Python 3 install issues related to unicode strings. Thanks to 40 | Zowie for inspiring the patch. 41 | 42 | .. _#58: https://github.com/gregmuellegger/django-mobile/pull/58 43 | 44 | 0.5.0 45 | ----- 46 | 47 | * Support for Django 1.7 and Django 1.8. Thanks to Jose Ignacio Galarza and to 48 | Anton Shurashov for the patches. 49 | 50 | 0.4.0 51 | ----- 52 | 53 | * Python 3.3 compatibility, thanks Mirko Rossini for the patch. 54 | * Dropping Django 1.3 and 1.4 support. 55 | 56 | 0.3.0 57 | ----- 58 | 59 | * Dropping support for python 2.5 (it might still work but we won't test 60 | against it anymore). 61 | * Fixing threading problems because of wrong usage of ``threading.local``. 62 | Thanks to Mike Shultz for the patch. 63 | * Adding a cached template loader. Thanks to Saverio for the patch. 64 | 65 | 0.2.4 66 | ----- 67 | 68 | * FIX: Cookie backend actually never really worked. Thanks to demidov91 for 69 | the report. 70 | 71 | 0.2.3 72 | ----- 73 | 74 | * FIX: set *flavour* in all cases, not only if a mobile browser is detected. 75 | Thanks to John P. Kiffmeyer for the report. 76 | 77 | 0.2.2 78 | ----- 79 | 80 | * FIX: Opera Mobile on Android was categorized as mobile browser. Thanks to 81 | dgerzo for the report. 82 | * Sniffing for iPad so that it doesn't get recognized as small mobile device. 83 | Thanks to Ryan Showalter for the patch. 84 | 85 | 0.2.1 86 | ----- 87 | 88 | * Fixed packing issues that didn't include the django_mobile.cache package. 89 | Thanks to *Scott Turnbull* for the report. 90 | 91 | 0.2.0 92 | ----- 93 | 94 | * Restructured project layout to remove settings.py and manage.py from 95 | top-level directory. This resolves module-name conflicts when installing 96 | with pip's -e option. Thanks to *bendavis78* for the report. 97 | 98 | * Added a ``cache_page`` decorator that emulates django's ``cache_page`` but 99 | takes flavours into account. The caching system would otherwise cache the 100 | flavour that is currently active when a cache miss occurs. Thanks to 101 | *itmustbejj* for the report. 102 | 103 | * Added a ``CacheFlavourMiddleware`` that makes django's caching middlewares 104 | aware of flavours. We use interally the ``Vary`` response header and the 105 | ``X-Flavour`` request header. 106 | 107 | 0.1.4 108 | ----- 109 | 110 | * Fixed issue in template loader that only implemented 111 | ``load_template_source`` but no ``load_template``. Thanks to tylanpince, 112 | rwilcox and Frédéric Roland for the report. 113 | 114 | 0.1.3 115 | ----- 116 | 117 | * Fixed issue with ``runserver`` command that didn't handled all request 118 | independed from each other. Thanks to bclermont and Frédéric Roland for the 119 | report. 120 | 121 | 0.1.2 122 | ----- 123 | 124 | * Fixed unreferenced variable error in ``SetFlavourMiddleware``. 125 | 126 | 0.1.1 127 | ----- 128 | 129 | * Fixed ``is_usable`` attribute for ``django_mobile.loader.Loader``. Thanks Michela Ledwidge for the report. 130 | 131 | 0.1.0 132 | ----- 133 | 134 | * Initial release. 135 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010, Gregor Müllegger 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | * Neither the name of the nor the names of its contributors 13 | may be used to endorse or promote products derived from this software 14 | without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 17 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.txt 2 | include CHANGES.rst 3 | include LICENSE.txt 4 | include MANIFEST.in 5 | include README.rst 6 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | django-mobile 3 | ============= 4 | 5 | |build| |package| 6 | 7 | .. _introduction: 8 | 9 | **django-mobile** provides a simple way to detect mobile browsers and gives 10 | you tools at your hand to render some different templates to deliver a mobile 11 | version of your site to the user. 12 | 13 | The idea is to keep your views exactly the same but to transparently 14 | interchange the templates used to render a response. This is done in two 15 | steps: 16 | 17 | 1. A middleware determines the client's preference to view your site. E.g. if 18 | he wants to use the mobile flavour or the full desktop flavour. 19 | 2. The template loader takes then care of choosing the correct templates based 20 | on the flavour detected in the middleware. 21 | 22 | 23 | Installation 24 | ============ 25 | 26 | .. _installation: 27 | 28 | *Pre-Requirements:* ``django_mobile`` depends on django's session framework. So 29 | before you try to use ``django_mobile`` make sure that the sessions framework 30 | is enabled and working. 31 | 32 | 1. Install ``django_mobile`` with your favourite python tool, e.g. with 33 | ``easy_install django_mobile`` or ``pip install django_mobile``. 34 | 2. Add ``django_mobile`` to your ``INSTALLED_APPS`` setting in the 35 | ``settings.py``. 36 | 3. Add ``django_mobile.middleware.MobileDetectionMiddleware`` to your 37 | ``MIDDLEWARE_CLASSES`` setting. 38 | 4. Add ``django_mobile.middleware.SetFlavourMiddleware`` to your 39 | ``MIDDLEWARE_CLASSES`` setting. Make sure it's listed *after* 40 | ``MobileDetectionMiddleware`` and also after ``SessionMiddleware``. 41 | 5. Add ``django_mobile.loader.Loader`` as first item to your 42 | ``loaders`` list for ``TEMPLATES`` setting in ``settings.py``. 43 | 6. Add ``django_mobile.context_processors.flavour`` to your 44 | ``context_processors`` list for ``TEMPLATES`` setting. You can read more about ``loaders`` and ``context_processors`` in `Django docs`_. 45 | 46 | *Note:* If you are using Django 1.7 or older, you need to change step 5 and 6 slightly. Use the ``TEMPLATE_LOADERS`` and ``TEMPLATE_CONTEXT_PROCESSORS`` settings instead of ``TEMPLATES``. 47 | 48 | Now you should be able to use **django-mobile** in its glory. Read below of how 49 | things work and which settings can be tweaked to modify **django-mobile**'s 50 | behaviour. 51 | 52 | 53 | Usage 54 | ===== 55 | 56 | .. _flavours: 57 | 58 | The concept of **django-mobile** is build around the ideas of different 59 | *flavours* for your site. For example the *mobile* version is described as 60 | one possible *flavour*, the desktop version as another. 61 | 62 | This makes it possible to provide many possible designs instead of just 63 | differentiating between a full desktop experience and one mobile version. You 64 | can make multiple mobile flavours available e.g. one for mobile safari on the 65 | iPhone and Android as well as one for Opera and an extra one for the internet 66 | tablets like the iPad. 67 | 68 | *Note:* By default **django-mobile** only distinguishes between *full* and 69 | *mobile* flavour. 70 | 71 | After the correct flavour is somehow chosen by the middlewares, it's 72 | assigned to the ``request.flavour`` attribute. You can use this in your views 73 | to provide separate logic. 74 | 75 | This flavour is then use to transparently choose custom templates for this 76 | special flavour. The selected template will have the current flavour prefixed 77 | to the template name you actually want to render. This means when 78 | ``render_to_response('index.html', ...)`` is called with the *mobile* flavour 79 | being active will actually return a response rendered with the 80 | ``mobile/index.html`` template. However if this flavoured template is not 81 | available it will gracefully fallback to the default ``index.html`` template. 82 | 83 | In some cases its not the desired way to have a completely separate templates 84 | for each flavour. You can also use the ``{{ flavour }}`` template variable to 85 | only change small aspects of a single template. A short example: 86 | 87 | .. code-block:: html+django 88 | 89 | 90 | 91 | My site {% if flavour == "mobile" %}(mobile version){% endif %} 92 | 93 | 94 | ... 95 | 96 | 97 | 98 | This will add ``(mobile version)`` to the title of your site if viewed with 99 | the mobile flavour enabled. 100 | 101 | *Note:* The ``flavour`` template variable is only available if you have set up the 102 | ``django_mobile.context_processors.flavour`` context processor and used 103 | django's ``RequestContext`` as context instance to render the template. 104 | 105 | Changing the current flavour 106 | ---------------------------- 107 | 108 | The basic use case of **django-mobile** is obviously to serve a mobile version 109 | of your site to users. The selection of the correct flavour is usually already 110 | done in the middlewares when your own views are called. In some cases you want 111 | to change the currently used flavour in your view or somewhere else. You can 112 | do this by simply calling ``django_mobile.set_flavour(flavour[, 113 | permanent=True])``. The first argument is self explaining. But keep in mind 114 | that you only can pass in a flavour that you is also in your ``FLAVOURS`` 115 | setting. Otherwise ``set_flavour`` will raise a ``ValueError``. The optional 116 | ``permanent`` parameters defines if the change of the flavour is remember for 117 | future requests of the same client. 118 | 119 | Your users can set their desired flavour them self. They just need to specify 120 | the ``flavour`` GET parameter on a request to your site. This will permanently 121 | choose this flavour as their preference to view the site. 122 | 123 | You can use this GET parameter to let the user select from your available 124 | flavours: 125 | 126 | .. code-block:: html+django 127 | 128 | 133 | 134 | Notes on caching 135 | ---------------- 136 | 137 | .. _caching: 138 | 139 | Django is shipping with some convenience methods to easily cache your views. 140 | One of them is ``django.views.decorators.cache.cache_page``. The problem with 141 | caching a whole page in conjunction with **django-mobile** is, that django's 142 | caching system is not aware of flavours. This means that if the first request 143 | to a page is served with a mobile flavour, the second request might also 144 | get a page rendered with the mobile flavour from the cache -- even if the 145 | second one was requested by a desktop browser. 146 | 147 | **django-mobile** is shipping with it's own implementation of ``cache_page`` 148 | to resolve this issue. Please use ``django_mobile.cache.cache_page`` instead 149 | of django's own ``cache_page`` decorator. 150 | 151 | You can also use django's caching middlewares 152 | ``django.middleware.cache.UpdateCacheMiddleware`` and 153 | ``FetchFromCacheMiddleware`` like you already do. But to make them aware of 154 | flavours, you need to add 155 | ``django_mobile.cache.middleware.FetchFromCacheFlavourMiddleware`` item before standard Django ``FetchFromCacheMiddleware`` 156 | in the ``MIDDLEWARE_CLASSES`` settings and ``django_mobile.cache.middleware.UpdateCacheFlavourMiddleware`` before 157 | ``django_mobile.cache.middleware.UpdateCacheMiddleware`` correspondingly. 158 | 159 | It is necessary to split the usage of ``CacheMiddleware`` because some additional work should be done on request and response *before* standard caching behavior and that is not possible while using two complete middlewares in either order 160 | 161 | Reference 162 | ========= 163 | 164 | ``django_mobile.get_flavour([request,] [default])`` 165 | Get the currently active flavour. If no flavour can be determined it will 166 | return *default*. This can happen if ``set_flavour`` was not called before 167 | in the current request-response cycle. *default* defaults to the first 168 | item in the ``FLAVOURS`` setting. 169 | 170 | ``django_mobile.set_flavour(flavour, [request,] [permanent])`` 171 | Set the *flavour* to be used for *request*. This will raise ``ValueError`` 172 | if *flavour* is not in the ``FLAVOURS`` setting. You can try to set the 173 | flavour permanently for *request* by passing ``permanent=True``. This may 174 | fail if you are out of a request-response cycle. *request* defaults to the 175 | currently active request. 176 | 177 | ``django_mobile.context_processors.flavour`` 178 | Context processor that adds the current flavour as *flavour* to the 179 | context. 180 | 181 | ``django_mobile.context_processors.is_mobile`` 182 | This context processor will add a *is_mobile* variable to the context 183 | which is ``True`` if the current flavour equals the 184 | ``DEFAULT_MOBILE_FLAVOUR`` setting. 185 | 186 | ``django_mobile.middleware.SetFlavourMiddleware`` 187 | Takes care of loading the stored flavour from the user's session or 188 | cookies (depending on ``FLAVOURS_STORAGE_BACKEND``) if set. Also sets the 189 | current request to a thread-local variable. This is needed to provide 190 | ``get_flavour()`` functionality without having access to the request 191 | object. 192 | 193 | ``django_mobile.middleware.MobileDetectionMiddleware`` 194 | Detects if a mobile browser tries to access the site and sets the flavour 195 | to ``DEFAULT_MOBILE_FLAVOUR`` settings value in case. 196 | 197 | ``django_mobile.cache.cache_page`` 198 | Same as django's ``cache_page`` decorator, but wraps the view into 199 | additional decorators before and after that. Makes it possible to serve multiple 200 | flavours without getting into trouble with django's caching that doesn't 201 | know about flavours. 202 | 203 | ``django_mobile.cache.vary_on_flavour_fetch`` ``django_mobile.cache.vary_on_flavour_update`` 204 | Decorators created from the ``FetchFromCacheFlavourMiddleware`` and ``UpdateCacheFlavourMiddleware`` middleware. 205 | 206 | ``django_mobile.cache.middleware.FetchFromCacheFlavourMiddleware`` 207 | Adds ``X-Flavour`` header to ``request.META`` in ``process_request`` 208 | 209 | ``django_mobile.cache.middleware.UpdateCacheFlavourMiddleware`` 210 | Adds ``X-Flavour`` header to ``response['Vary']`` in ``process_response`` so that Django's ``CacheMiddleware`` know that it should take into account the content of this header when looking up the cached content on next request to this URL. 211 | 212 | 213 | Customization 214 | ============= 215 | 216 | .. _customization: 217 | 218 | There are some points available that let you customize the behaviour of 219 | **django-mobile**. Here are some possibilities listed: 220 | 221 | ``MobileDetectionMiddleware`` 222 | ----------------------------- 223 | 224 | The built-in middleware to detect if the user is using a mobile browser served 225 | well in production but is far from perfect and also implemented in a very 226 | simplistic way. You can safely remove this middleware from your settings and 227 | add your own version instead. Just make sure that it calls 228 | ``django_mobile.set_flavour`` at some point to set the correct flavour for 229 | you. 230 | 231 | If you need example how tablet detection can be implemented, you can checkout the `middleware.py`_ file in directory `examples`. Feel free to modify it as you like! 232 | 233 | Settings 234 | -------- 235 | 236 | .. _settings: 237 | 238 | Here is a list of settings that are used by **django-mobile** and can be 239 | changed in your own ``settings.py``: 240 | 241 | ``FLAVOURS`` 242 | A list of available flavours for your site. 243 | 244 | **Default:** ``('full', 'mobile')`` 245 | 246 | ``DEFAULT_MOBILE_FLAVOUR`` 247 | The flavour which is chosen if the built-in ``MobileDetectionMiddleware`` 248 | detects a mobile browser. 249 | 250 | **Default:** ``'mobile'`` 251 | 252 | ``FLAVOURS_COOKIE_HTTPONLY`` 253 | The value that get passed into ``HttpResponse.set_cookie``'s ``httponly`` 254 | argument. Set this to ``True`` if you don't want the Javascript code to be 255 | able to read the flavour cookie. 256 | 257 | **Default:** ``False`` 258 | 259 | ``FLAVOURS_COOKIE_KEY`` 260 | The cookie name that is used for storing the selected flavour in the 261 | browser. This is only used if ``FLAVOURS_STORAGE_BACKEND`` is set to 262 | ``'cookie'``. 263 | 264 | **Default:** ``'flavour'`` 265 | 266 | ``FLAVOURS_TEMPLATE_PREFIX`` 267 | This string will be prefixed to the template names when searching for 268 | flavoured templates. This is useful if you have many flavours and want to 269 | store them in a common subdirectory. Example: 270 | 271 | .. code-block:: python 272 | 273 | from django.template.loader import render_to_string 274 | from django_mobile import set_flavour 275 | 276 | set_flavour('mobile') 277 | render_to_string('index.html') # will render 'mobile/index.html' 278 | 279 | # now add this to settings.py 280 | FLAVOURS_TEMPLATE_PREFIX = 'flavours/' 281 | 282 | # and try again 283 | 284 | set_flavour('mobile') 285 | render_to_string('index.html') # will render 'flavours/mobile/index.html' 286 | 287 | **Default:** ``''`` (empty string) 288 | 289 | ``FLAVOURS_TEMPLATE_LOADERS`` 290 | **django-mobile**'s template loader can load templates prefixed with the 291 | current flavour. Specify with this setting which loaders are used to load 292 | flavoured templates. 293 | 294 | **Default:** same as ``TEMPLATE_LOADERS`` setting but without 295 | ``'django_mobile.loader.Loader'``. 296 | 297 | ``FLAVOURS_GET_PARAMETER`` 298 | Users can change the flavour they want to look at with a HTTP GET 299 | parameter. This determines the name of this parameter. Set it to 300 | ``None`` to disable. 301 | 302 | **Default:** ``'flavour'`` 303 | 304 | ``FLAVOURS_SESSION_KEY`` 305 | The user's preference set with the GET parameter is stored in the user's 306 | session. This setting determines which session key is used to hold this 307 | information. 308 | 309 | **Default:** ``'flavour'`` 310 | 311 | ``FLAVOURS_STORAGE_BACKEND`` 312 | Determines how the selected flavour is stored persistently. Available 313 | values: ``'session'`` and ``'cookie'``. 314 | 315 | **Default:** ``'cookie'`` 316 | 317 | Cache Settings 318 | -------------- 319 | 320 | Django ships with the `cached template loader`_ 321 | ``django.template.loaders.cached.Loader`` that doesn't require to fetch the 322 | template from disk every time you want to render it. However it isn't aware of 323 | django-mobile's flavours. For this purpose you can use 324 | ``'django_mobile.loader.CachedLoader'`` as a drop-in replacement that does 325 | exactly the same django's version but takes the different flavours into 326 | account. To use it, put the following bit into your ``settings.py`` file: 327 | 328 | .. code-block:: python 329 | 330 | TEMPLATES = [ 331 | { 332 | ... 333 | 'OPTIONS': { 334 | ... 335 | 'loaders': ('django_mobile.loader.CachedLoader', ( 336 | 'django_mobile.loader.Loader', 337 | 'django.template.loaders.filesystem.Loader', 338 | 'django.template.loaders.app_directories.Loader', 339 | )), 340 | } 341 | } 342 | ] 343 | 344 | .. _cached template loader: 345 | https://docs.djangoproject.com/en/dev/ref/templates/api/#django.template.loaders.cached.Loader 346 | 347 | .. _middleware.py: 348 | examples/middleware.py 349 | .. _Django docs: 350 | https://docs.djangoproject.com/en/dev/topics/templates/#module-django.template.backends.django 351 | 352 | .. |build| image:: https://travis-ci.org/gregmuellegger/django-mobile.svg?branch=master 353 | :alt: Build Status 354 | :scale: 100% 355 | :target: https://travis-ci.org/gregmuellegger/django-mobile 356 | .. |package| image:: https://badge.fury.io/py/django-mobile.svg 357 | :alt: Package Version 358 | :scale: 100% 359 | :target: http://badge.fury.io/py/django-mobile 360 | -------------------------------------------------------------------------------- /django_mobile/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __author__ = u'Gregor Müllegger' 4 | __version__ = '0.7.0.dev1' 5 | 6 | 7 | import threading 8 | from django.core.exceptions import ImproperlyConfigured 9 | from django.utils.encoding import smart_str 10 | from django_mobile.conf import settings 11 | 12 | 13 | _local = threading.local() 14 | 15 | 16 | class SessionBackend(object): 17 | def get(self, request, default=None): 18 | return request.session.get(settings.FLAVOURS_SESSION_KEY, default) 19 | 20 | def set(self, request, flavour): 21 | request.session[settings.FLAVOURS_SESSION_KEY] = flavour 22 | 23 | def save(self, request, response): 24 | pass 25 | 26 | 27 | class CookieBackend(object): 28 | def get(self, request, default=None): 29 | return request.COOKIES.get(settings.FLAVOURS_COOKIE_KEY, default) 30 | 31 | def set(self, request, flavour): 32 | request.COOKIES[settings.FLAVOURS_COOKIE_KEY] = flavour 33 | request._flavour_cookie = flavour 34 | 35 | def save(self, request, response): 36 | if hasattr(request, '_flavour_cookie'): 37 | response.set_cookie( 38 | smart_str(settings.FLAVOURS_COOKIE_KEY), 39 | smart_str(request._flavour_cookie), 40 | httponly=settings.FLAVOURS_COOKIE_HTTPONLY) 41 | 42 | 43 | # hijack this dict to add your own backend 44 | FLAVOUR_STORAGE_BACKENDS = { 45 | 'cookie': CookieBackend(), 46 | 'session': SessionBackend(), 47 | } 48 | 49 | 50 | class ProxyBackend(object): 51 | def get_backend(self): 52 | backend = settings.FLAVOURS_STORAGE_BACKEND 53 | if not settings.FLAVOURS_STORAGE_BACKEND: 54 | raise ImproperlyConfigured( 55 | u"You must specify a FLAVOURS_STORAGE_BACKEND setting to " 56 | u"save the flavour for a user.") 57 | return FLAVOUR_STORAGE_BACKENDS[backend] 58 | 59 | def get(self, *args, **kwargs): 60 | if settings.FLAVOURS_STORAGE_BACKEND is None: 61 | return None 62 | return self.get_backend().get(*args, **kwargs) 63 | 64 | def set(self, *args, **kwargs): 65 | if settings.FLAVOURS_STORAGE_BACKEND is None: 66 | return None 67 | return self.get_backend().set(*args, **kwargs) 68 | 69 | def save(self, *args, **kwargs): 70 | if settings.FLAVOURS_STORAGE_BACKEND is None: 71 | return None 72 | return self.get_backend().save(*args, **kwargs) 73 | 74 | 75 | flavour_storage = ProxyBackend() 76 | 77 | 78 | def get_flavour(request=None, default=None): 79 | flavour = None 80 | request = request or getattr(_local, 'request', None) 81 | # get flavour from storage if enabled 82 | if request: 83 | flavour = flavour_storage.get(request) 84 | # check if flavour is set on request 85 | if not flavour and hasattr(request, 'flavour'): 86 | flavour = request.flavour 87 | # if set out of a request-response cycle its stored on the thread local 88 | if not flavour: 89 | flavour = getattr(_local, 'flavour', default) 90 | # if something went wrong we return the very default flavour 91 | if flavour not in settings.FLAVOURS: 92 | flavour = settings.FLAVOURS[0] 93 | return flavour 94 | 95 | 96 | def set_flavour(flavour, request=None, permanent=False): 97 | if flavour not in settings.FLAVOURS: 98 | raise ValueError( 99 | u"'%r' is no valid flavour. Allowed flavours are: %s" % ( 100 | flavour, 101 | ', '.join(settings.FLAVOURS),)) 102 | request = request or getattr(_local, 'request', None) 103 | if request: 104 | request.flavour = flavour 105 | if permanent: 106 | flavour_storage.set(request, flavour) 107 | elif permanent: 108 | raise ValueError( 109 | u'Cannot set flavour permanently, no request available.') 110 | _local.flavour = flavour 111 | 112 | 113 | def _set_request_header(request, flavour): 114 | request.META['HTTP_X_FLAVOUR'] = flavour 115 | 116 | 117 | def _init_flavour(request): 118 | _local.request = request 119 | if hasattr(request, 'flavour'): 120 | _local.flavour = request.flavour 121 | if not hasattr(_local, 'flavour'): 122 | _local.flavour = settings.FLAVOURS[0] 123 | -------------------------------------------------------------------------------- /django_mobile/cache/__init__.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from django.views.decorators.cache import cache_page as _django_cache_page 4 | from django.utils.decorators import decorator_from_middleware 5 | from django_mobile.cache.middleware import FetchFromCacheFlavourMiddleware, UpdateCacheFlavourMiddleware 6 | 7 | __all__ = ('cache_page', 'vary_on_flavour_fetch', 'vary_on_flavour_update') 8 | 9 | 10 | vary_on_flavour_fetch = decorator_from_middleware(FetchFromCacheFlavourMiddleware) 11 | vary_on_flavour_update = decorator_from_middleware(UpdateCacheFlavourMiddleware) 12 | 13 | 14 | def cache_page(*args, **kwargs): 15 | ''' 16 | Same as django's ``cache_page`` decorator, but wraps the view into 17 | additional decorators before and after that. Makes it possible to serve multiple 18 | flavours without getting into trouble with django's caching that doesn't 19 | know about flavours. 20 | ''' 21 | decorator = _django_cache_page(*args, **kwargs) 22 | def flavoured_decorator(func): 23 | return vary_on_flavour_fetch(decorator(vary_on_flavour_update(func))) 24 | return flavoured_decorator 25 | -------------------------------------------------------------------------------- /django_mobile/cache/middleware.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from django.utils.cache import patch_vary_headers 4 | from django_mobile import get_flavour, _set_request_header 5 | 6 | 7 | class CacheFlavourMiddleware(object): 8 | def __init__(self): 9 | warnings.warn('CacheFlavourMiddleware does nothing and should be abandoned.' 10 | 'The intended behavior cannot be implemented using one middleware.' 11 | 'Use separate FetchFromCacheFlavourMiddleware and UpdateCacheFlavourMiddleware instead.' 12 | 'Refer to https://github.com/gregmuellegger/django-mobile/pull/64 for details', 13 | category=DeprecationWarning) 14 | 15 | 16 | class FetchFromCacheFlavourMiddleware(object): 17 | def process_request(self, request): 18 | _set_request_header(request, get_flavour(request)) 19 | 20 | 21 | class UpdateCacheFlavourMiddleware(object): 22 | def process_response(self, request, response): 23 | patch_vary_headers(response, ['X-Flavour']) 24 | return response 25 | -------------------------------------------------------------------------------- /django_mobile/compat.py: -------------------------------------------------------------------------------- 1 | try: 2 | from django.template.engine import Engine 3 | from django.template.loaders.base import Loader as BaseLoader 4 | except ImportError: # Django < 1.8 5 | Engine = None 6 | from django.template.loader import BaseLoader, find_template_loader, get_template_from_string 7 | 8 | 9 | def template_loader(loader_name): 10 | if Engine: 11 | return Engine.get_default().find_template_loader(loader_name) 12 | else: # Django < 1.8 13 | return find_template_loader(loader_name) 14 | 15 | 16 | def template_from_string(template_code): 17 | if Engine: 18 | return Engine().from_string(template_code) 19 | else: # Django < 1.8 20 | return get_template_from_string(template_code) 21 | 22 | 23 | def get_engine(): 24 | if Engine: 25 | return Engine.get_default() 26 | else: # Django < 1.8 27 | return None 28 | -------------------------------------------------------------------------------- /django_mobile/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.conf import settings as django_settings 3 | 4 | CACHE_LOADER_NAME = 'django_mobile.loader.CachedLoader' 5 | DJANGO_MOBILE_LOADER = 'django_mobile.loader.Loader' 6 | 7 | 8 | class SettingsProxy(object): 9 | def __init__(self, settings, defaults): 10 | self.settings = settings 11 | self.defaults = defaults 12 | 13 | def __getattr__(self, attr): 14 | try: 15 | return getattr(self.settings, attr) 16 | except AttributeError: 17 | try: 18 | return getattr(self.defaults, attr) 19 | except AttributeError: 20 | raise AttributeError(u'settings object has no attribute "%s"' % attr) 21 | 22 | 23 | class defaults(object): 24 | FLAVOURS = (u'full', u'mobile',) 25 | DEFAULT_MOBILE_FLAVOUR = u'mobile' 26 | FLAVOURS_TEMPLATE_PREFIX = u'' 27 | FLAVOURS_GET_PARAMETER = u'flavour' 28 | FLAVOURS_STORAGE_BACKEND = u'cookie' 29 | FLAVOURS_COOKIE_KEY = u'flavour' 30 | FLAVOURS_COOKIE_HTTPONLY = False 31 | FLAVOURS_SESSION_KEY = u'flavour' 32 | FLAVOURS_TEMPLATE_LOADERS = [] 33 | for loader in django_settings.TEMPLATE_LOADERS: 34 | if isinstance(loader, (tuple, list)) and loader[0] == CACHE_LOADER_NAME: 35 | for cached_loader in loader[1]: 36 | if cached_loader != DJANGO_MOBILE_LOADER: 37 | FLAVOURS_TEMPLATE_LOADERS.append(cached_loader) 38 | elif loader != DJANGO_MOBILE_LOADER: 39 | FLAVOURS_TEMPLATE_LOADERS.append(loader) 40 | FLAVOURS_TEMPLATE_LOADERS = tuple(FLAVOURS_TEMPLATE_LOADERS) 41 | 42 | settings = SettingsProxy(django_settings, defaults) 43 | -------------------------------------------------------------------------------- /django_mobile/context_processors.py: -------------------------------------------------------------------------------- 1 | from django_mobile import get_flavour 2 | from django_mobile.conf import settings 3 | 4 | 5 | def flavour(request): 6 | return { 7 | 'flavour': get_flavour(), 8 | } 9 | 10 | 11 | def is_mobile(request): 12 | return { 13 | 'is_mobile': get_flavour() == settings.DEFAULT_MOBILE_FLAVOUR, 14 | } 15 | -------------------------------------------------------------------------------- /django_mobile/loader.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | from django.template import TemplateDoesNotExist 3 | from django.template.loaders.cached import Loader as DjangoCachedLoader 4 | from django_mobile import get_flavour 5 | from django_mobile.conf import settings 6 | from django_mobile.compat import BaseLoader, template_loader, template_from_string 7 | from django.utils.encoding import force_bytes 8 | 9 | 10 | class Loader(BaseLoader): 11 | is_usable = True 12 | _template_source_loaders = None 13 | 14 | def get_contents(self, origin): 15 | return origin.loader.get_contents(origin) 16 | 17 | def get_template_sources(self, template_name, template_dirs=None): 18 | template_name = self.prepare_template_name(template_name) 19 | for loader in self.template_source_loaders: 20 | if hasattr(loader, 'get_template_sources'): 21 | try: 22 | for result in loader.get_template_sources(template_name, template_dirs): 23 | yield result 24 | except UnicodeDecodeError: 25 | # The template dir name was a bytestring that wasn't valid UTF-8. 26 | raise 27 | except ValueError: 28 | # The joined path was located outside of this particular 29 | # template_dir (it might be inside another one, so this isn't 30 | # fatal). 31 | pass 32 | 33 | def prepare_template_name(self, template_name): 34 | template_name = u'%s/%s' % (get_flavour(), template_name) 35 | if settings.FLAVOURS_TEMPLATE_PREFIX: 36 | template_name = settings.FLAVOURS_TEMPLATE_PREFIX + template_name 37 | return template_name 38 | 39 | def load_template(self, template_name, template_dirs=None): 40 | template_name = self.prepare_template_name(template_name) 41 | for loader in self.template_source_loaders: 42 | try: 43 | return loader(template_name, template_dirs) 44 | except TemplateDoesNotExist: 45 | pass 46 | raise TemplateDoesNotExist("Tried %s" % template_name) 47 | 48 | def load_template_source(self, template_name, template_dirs=None): 49 | template_name = self.prepare_template_name(template_name) 50 | for loader in self.template_source_loaders: 51 | if hasattr(loader, 'load_template_source'): 52 | try: 53 | return loader.load_template_source( 54 | template_name, 55 | template_dirs) 56 | except TemplateDoesNotExist: 57 | pass 58 | raise TemplateDoesNotExist("Tried %s" % template_name) 59 | 60 | @property 61 | def template_source_loaders(self): 62 | if not self._template_source_loaders: 63 | loaders = [] 64 | for loader_name in settings.FLAVOURS_TEMPLATE_LOADERS: 65 | loader = template_loader(loader_name) 66 | if loader is not None: 67 | loaders.append(loader) 68 | self._template_source_loaders = tuple(loaders) 69 | return self._template_source_loaders 70 | 71 | 72 | class CachedLoader(DjangoCachedLoader): 73 | is_usable = True 74 | 75 | def cache_key(self, template_name, template_dirs, *args): 76 | if len(args) > 0: # Django >= 1.9 77 | key = super(CachedLoader, self).cache_key(template_name, template_dirs, *args) 78 | else: 79 | if template_dirs: 80 | key = '-'.join([ 81 | template_name, 82 | hashlib.sha1(force_bytes('|'.join(template_dirs))).hexdigest() 83 | ]) 84 | else: 85 | key = template_name 86 | 87 | return '{0}:{1}'.format(get_flavour(), key) 88 | 89 | def load_template(self, template_name, template_dirs=None): 90 | key = self.cache_key(template_name, template_dirs) 91 | template_tuple = self.template_cache.get(key) 92 | 93 | if template_tuple is TemplateDoesNotExist: 94 | raise TemplateDoesNotExist('Template not found: %s' % template_name) 95 | elif template_tuple is None: 96 | template, origin = self.find_template(template_name, template_dirs) 97 | if not hasattr(template, 'render'): 98 | try: 99 | template = template_from_string(template) 100 | except TemplateDoesNotExist: 101 | # If compiling the template we found raises TemplateDoesNotExist, 102 | # back off to returning the source and display name for the template 103 | # we were asked to load. This allows for correct identification (later) 104 | # of the actual template that does not exist. 105 | self.template_cache[key] = (template, origin) 106 | 107 | self.template_cache[key] = (template, None) 108 | 109 | return self.template_cache[key] 110 | -------------------------------------------------------------------------------- /django_mobile/middleware.py: -------------------------------------------------------------------------------- 1 | import re 2 | from django_mobile import flavour_storage 3 | from django_mobile import set_flavour, _init_flavour 4 | from django_mobile.conf import settings 5 | 6 | 7 | class SetFlavourMiddleware(object): 8 | def process_request(self, request): 9 | _init_flavour(request) 10 | 11 | if settings.FLAVOURS_GET_PARAMETER in request.GET: 12 | flavour = request.GET[settings.FLAVOURS_GET_PARAMETER] 13 | if flavour in settings.FLAVOURS: 14 | set_flavour(flavour, request, permanent=True) 15 | 16 | def process_response(self, request, response): 17 | flavour_storage.save(request, response) 18 | return response 19 | 20 | 21 | class MobileDetectionMiddleware(object): 22 | user_agents_test_match = ( 23 | "w3c ", "acs-", "alav", "alca", "amoi", "audi", 24 | "avan", "benq", "bird", "blac", "blaz", "brew", 25 | "cell", "cldc", "cmd-", "dang", "doco", "eric", 26 | "hipt", "inno", "ipaq", "java", "jigs", "kddi", 27 | "keji", "leno", "lg-c", "lg-d", "lg-g", "lge-", 28 | "maui", "maxo", "midp", "mits", "mmef", "mobi", 29 | "mot-", "moto", "mwbp", "nec-", "newt", "noki", 30 | "xda", "palm", "pana", "pant", "phil", "play", 31 | "port", "prox", "qwap", "sage", "sams", "sany", 32 | "sch-", "sec-", "send", "seri", "sgh-", "shar", 33 | "sie-", "siem", "smal", "smar", "sony", "sph-", 34 | "symb", "t-mo", "teli", "tim-", "tosh", "tsm-", 35 | "upg1", "upsi", "vk-v", "voda", "wap-", "wapa", 36 | "wapi", "wapp", "wapr", "webc", "winw", "xda-",) 37 | user_agents_test_search = u"(?:%s)" % u'|'.join(( 38 | 'up.browser', 'up.link', 'mmp', 'symbian', 'smartphone', 'midp', 39 | 'wap', 'phone', 'windows ce', 'pda', 'mobile', 'mini', 'palm', 40 | 'netfront', 'opera mobi', 41 | )) 42 | user_agents_exception_search = u"(?:%s)" % u'|'.join(( 43 | 'ipad', 44 | )) 45 | http_accept_regex = re.compile("application/vnd\.wap\.xhtml\+xml", re.IGNORECASE) 46 | 47 | def __init__(self): 48 | user_agents_test_match = r'^(?:%s)' % '|'.join(self.user_agents_test_match) 49 | self.user_agents_test_match_regex = re.compile(user_agents_test_match, re.IGNORECASE) 50 | self.user_agents_test_search_regex = re.compile(self.user_agents_test_search, re.IGNORECASE) 51 | self.user_agents_exception_search_regex = re.compile(self.user_agents_exception_search, re.IGNORECASE) 52 | 53 | def process_request(self, request): 54 | is_mobile = False 55 | 56 | if 'HTTP_USER_AGENT' in request.META : 57 | user_agent = request.META['HTTP_USER_AGENT'] 58 | 59 | # Test common mobile values. 60 | if self.user_agents_test_search_regex.search(user_agent) and \ 61 | not self.user_agents_exception_search_regex.search(user_agent): 62 | is_mobile = True 63 | else: 64 | # Nokia like test for WAP browsers. 65 | # http://www.developershome.com/wap/xhtmlmp/xhtml_mp_tutorial.asp?page=mimeTypesFileExtension 66 | 67 | if 'HTTP_ACCEPT' in request.META : 68 | http_accept = request.META['HTTP_ACCEPT'] 69 | if self.http_accept_regex.search(http_accept): 70 | is_mobile = True 71 | 72 | if not is_mobile: 73 | # Now we test the user_agent from a big list. 74 | if self.user_agents_test_match_regex.match(user_agent): 75 | is_mobile = True 76 | 77 | if is_mobile: 78 | set_flavour(settings.DEFAULT_MOBILE_FLAVOUR, request) 79 | else: 80 | set_flavour(settings.FLAVOURS[0], request) 81 | -------------------------------------------------------------------------------- /django_mobile/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gregmuellegger/django-mobile/362fae1fc21304483f4ebefe54dd6e76df709b71/django_mobile/models.py -------------------------------------------------------------------------------- /django_mobile_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gregmuellegger/django-mobile/362fae1fc21304483f4ebefe54dd6e76df709b71/django_mobile_tests/__init__.py -------------------------------------------------------------------------------- /django_mobile_tests/cache_settings.py: -------------------------------------------------------------------------------- 1 | from settings import * 2 | 3 | 4 | MIDDLEWARE_CLASSES = ( 5 | 'django.middleware.cache.UpdateCacheMiddleware', 6 | ) + MIDDLEWARE_CLASSES + ( 7 | 'django_mobile.cache.middleware.CacheFlavourMiddleware', 8 | 'django.middleware.cache.FetchFromCacheMiddleware', 9 | ) 10 | -------------------------------------------------------------------------------- /django_mobile_tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os, sys 3 | 4 | os.environ['DJANGO_SETTINGS_MODULE'] = 'django_mobile_tests.settings' 5 | parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 6 | 7 | sys.path.insert(0, parent) 8 | 9 | 10 | if __name__ == '__main__': 11 | from django.core.management import execute_from_command_line 12 | execute_from_command_line() 13 | 14 | -------------------------------------------------------------------------------- /django_mobile_tests/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gregmuellegger/django-mobile/362fae1fc21304483f4ebefe54dd6e76df709b71/django_mobile_tests/models.py -------------------------------------------------------------------------------- /django_mobile_tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import warnings 3 | 4 | 5 | warnings.simplefilter('always') 6 | 7 | PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) 8 | 9 | DATABASES = { 10 | 'default': { 11 | 'ENGINE': 'django.db.backends.sqlite3', 12 | 'NAME': os.path.join(PROJECT_ROOT, 'db.sqlite'), 13 | } 14 | } 15 | 16 | SITE_ID = 1 17 | 18 | # Set in order to catch timezone aware vs unaware comparisons 19 | USE_TZ = True 20 | 21 | # If you set this to False, Django will make some optimizations so as not 22 | # to load the internationalization machinery. 23 | USE_I18N = True 24 | USE_L10N = True 25 | 26 | # Absolute path to the directory that holds media. 27 | # Example: "/home/media/media.lawrence.com/" 28 | MEDIA_ROOT = os.path.join(PROJECT_ROOT, 'media') 29 | 30 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 31 | # trailing slash if there is a path component (optional in other cases). 32 | # Examples: "http://media.lawrence.com", "http://example.com/media/" 33 | MEDIA_URL = '/media/' 34 | 35 | # URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a 36 | # trailing slash. 37 | # Examples: "http://foo.com/media/", "/media/". 38 | ADMIN_MEDIA_PREFIX = '/media/admin/' 39 | 40 | # Make this unique, and don't share it with anybody. 41 | SECRET_KEY = '0' 42 | 43 | ROOT_URLCONF = 'django_mobile_tests.urls' 44 | 45 | # List of callables that know how to import templates from various sources. 46 | TEMPLATE_LOADERS = ( 47 | ('django_mobile.loader.CachedLoader', ( 48 | 'django_mobile.loader.Loader', 49 | 'django.template.loaders.filesystem.Loader', 50 | 'django.template.loaders.app_directories.Loader', 51 | )), 52 | ) 53 | 54 | MIDDLEWARE_CLASSES = ( 55 | 'django.middleware.common.CommonMiddleware', 56 | 'django.contrib.sessions.middleware.SessionMiddleware', 57 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 58 | 'django_mobile.middleware.MobileDetectionMiddleware', 59 | 'django_mobile.middleware.SetFlavourMiddleware', 60 | ) 61 | 62 | TEMPLATE_DIRS = ( 63 | os.path.join(PROJECT_ROOT, 'templates'), 64 | ) 65 | 66 | INSTALLED_APPS = ( 67 | 'django.contrib.auth', 68 | 'django.contrib.contenttypes', 69 | 'django.contrib.sessions', 70 | 'django.contrib.sites', 71 | 'django.contrib.admin', 72 | 73 | 'django_mobile', 74 | 'django_mobile_tests', 75 | ) 76 | 77 | TEMPLATE_CONTEXT_PROCESSORS = ( 78 | "django.contrib.auth.context_processors.auth", 79 | "django.core.context_processors.debug", 80 | "django.core.context_processors.i18n", 81 | "django.core.context_processors.media", 82 | "django_mobile.context_processors.flavour", 83 | "django_mobile.context_processors.is_mobile", 84 | ) 85 | 86 | 87 | import django 88 | if django.VERSION < (1, 6): 89 | TEST_RUNNER = 'discover_runner.DiscoverRunner' 90 | else: 91 | TEST_RUNNER = 'django.test.runner.DiscoverRunner' 92 | -------------------------------------------------------------------------------- /django_mobile_tests/templates/index.html: -------------------------------------------------------------------------------- 1 | Hello {{ flavour }}. 2 | -------------------------------------------------------------------------------- /django_mobile_tests/templates/mobile/index.html: -------------------------------------------------------------------------------- 1 | Mobile! 2 | -------------------------------------------------------------------------------- /django_mobile_tests/test_base.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import sys 3 | from django.contrib.sessions.models import Session 4 | from django.template import RequestContext, TemplateDoesNotExist 5 | from django.test import Client, TestCase 6 | from mock import MagicMock, Mock, patch 7 | from django_mobile import get_flavour, set_flavour 8 | from django_mobile.conf import settings 9 | from django_mobile.compat import get_engine 10 | from django_mobile.middleware import MobileDetectionMiddleware, \ 11 | SetFlavourMiddleware 12 | 13 | IS_PYTHON_3 = sys.version > '3' 14 | 15 | def _reset(): 16 | ''' 17 | Reset the thread local. 18 | ''' 19 | import django_mobile 20 | del django_mobile._local 21 | django_mobile._local = threading.local() 22 | 23 | def str_p3_response( string ) : 24 | """ 25 | Since response.content is a binary string in python 3, 26 | we decode it to make it comparable to str objects 27 | ( python 2 compatibility ) 28 | """ 29 | if IS_PYTHON_3 : 30 | return string.decode( 'ASCII' ) 31 | return string 32 | 33 | class BaseTestCase(TestCase): 34 | def setUp(self): 35 | _reset() 36 | 37 | def tearDown(self): 38 | _reset() 39 | 40 | 41 | class BasicFunctionTests(BaseTestCase): 42 | def test_set_flavour(self): 43 | set_flavour('full') 44 | self.assertEqual(get_flavour(), 'full') 45 | set_flavour('mobile') 46 | self.assertEqual(get_flavour(), 'mobile') 47 | self.assertRaises(ValueError, set_flavour, 'spam') 48 | 49 | def test_set_flavour_with_cookie_backend(self): 50 | original_FLAVOURS_STORAGE_BACKEND = settings.FLAVOURS_STORAGE_BACKEND 51 | try: 52 | settings.FLAVOURS_STORAGE_BACKEND = 'cookie' 53 | response = self.client.get('/') 54 | self.assertFalse(settings.FLAVOURS_COOKIE_KEY in response.cookies) 55 | response = self.client.get('/', { 56 | settings.FLAVOURS_GET_PARAMETER: 'mobile', 57 | }) 58 | self.assertTrue(settings.FLAVOURS_COOKIE_KEY in response.cookies) 59 | self.assertTrue(response.cookies[settings.FLAVOURS_COOKIE_KEY], u'mobile') 60 | self.assertContains(response, 'Mobile!') 61 | finally: 62 | settings.FLAVOURS_STORAGE_BACKEND = original_FLAVOURS_STORAGE_BACKEND 63 | 64 | def test_set_flavour_with_session_backend(self): 65 | original_FLAVOURS_STORAGE_BACKEND = settings.FLAVOURS_STORAGE_BACKEND 66 | try: 67 | settings.FLAVOURS_STORAGE_BACKEND = 'session' 68 | request = Mock() 69 | request.session = {} 70 | set_flavour('mobile', request=request) 71 | self.assertEqual(request.session, {}) 72 | set_flavour('mobile', request=request, permanent=True) 73 | self.assertEqual(request.session, { 74 | settings.FLAVOURS_SESSION_KEY: u'mobile' 75 | }) 76 | self.assertEqual(get_flavour(request), 'mobile') 77 | 78 | response = self.client.get('/') 79 | self.assertFalse('sessionid' in response.cookies) 80 | response = self.client.get('/', { 81 | settings.FLAVOURS_GET_PARAMETER: 'mobile', 82 | }) 83 | self.assertTrue('sessionid' in response.cookies) 84 | sessionid = response.cookies['sessionid'].value 85 | session = Session.objects.get(session_key=sessionid) 86 | session_data = session.get_decoded() 87 | self.assertTrue(settings.FLAVOURS_SESSION_KEY in session_data) 88 | self.assertEqual(session_data[settings.FLAVOURS_SESSION_KEY], 'mobile') 89 | finally: 90 | settings.FLAVOURS_STORAGE_BACKEND = original_FLAVOURS_STORAGE_BACKEND 91 | 92 | 93 | class TemplateLoaderTests(BaseTestCase): 94 | def test_load_template_on_filesystem(self): 95 | from django.template.loaders import app_directories, filesystem 96 | 97 | @patch.object(app_directories.Loader, 'load_template') 98 | @patch.object(filesystem.Loader, 'load_template') 99 | def testing(filesystem_loader, app_directories_loader): 100 | filesystem_loader.side_effect = TemplateDoesNotExist('error') 101 | app_directories_loader.side_effect = TemplateDoesNotExist('error') 102 | 103 | from django_mobile.loader import Loader 104 | loader = Loader(get_engine()) 105 | 106 | set_flavour('mobile') 107 | try: 108 | loader.load_template('base.html', template_dirs=None) 109 | except TemplateDoesNotExist: 110 | pass 111 | self.assertEqual(filesystem_loader.call_args[0][0], 'mobile/base.html') 112 | self.assertEqual(app_directories_loader.call_args[0][0], 'mobile/base.html') 113 | 114 | set_flavour('full') 115 | try: 116 | loader.load_template('base.html', template_dirs=None) 117 | except TemplateDoesNotExist: 118 | pass 119 | self.assertEqual(filesystem_loader.call_args[0][0], 'full/base.html') 120 | self.assertEqual(app_directories_loader.call_args[0][0], 'full/base.html') 121 | 122 | testing() 123 | 124 | def test_load_template_source_on_filesystem(self): 125 | from django.template.loaders import app_directories, filesystem 126 | 127 | @patch.object(app_directories.Loader, 'load_template_source') 128 | @patch.object(filesystem.Loader, 'load_template_source') 129 | def testing(filesystem_loader, app_directories_loader): 130 | filesystem_loader.side_effect = TemplateDoesNotExist('error') 131 | app_directories_loader.side_effect = TemplateDoesNotExist('error') 132 | 133 | from django_mobile.loader import Loader 134 | loader = Loader(get_engine()) 135 | 136 | set_flavour('mobile') 137 | try: 138 | loader.load_template_source('base.html', template_dirs=None) 139 | except TemplateDoesNotExist: 140 | pass 141 | self.assertEqual(filesystem_loader.call_args[0][0], 'mobile/base.html') 142 | self.assertEqual(app_directories_loader.call_args[0][0], 'mobile/base.html') 143 | 144 | set_flavour('full') 145 | try: 146 | loader.load_template_source('base.html', template_dirs=None) 147 | except TemplateDoesNotExist: 148 | pass 149 | self.assertEqual(filesystem_loader.call_args[0][0], 'full/base.html') 150 | self.assertEqual(app_directories_loader.call_args[0][0], 'full/base.html') 151 | 152 | testing() 153 | 154 | def test_functional(self): 155 | from django.template.loader import render_to_string 156 | set_flavour('full') 157 | result = render_to_string('index.html') 158 | result = result.strip() 159 | self.assertEqual(result, 'Hello .') 160 | # simulate RequestContext 161 | result = render_to_string('index.html', context_instance=RequestContext(Mock())) 162 | result = result.strip() 163 | self.assertEqual(result, 'Hello full.') 164 | set_flavour('mobile') 165 | result = render_to_string('index.html') 166 | result = result.strip() 167 | self.assertEqual(result, 'Mobile!') 168 | 169 | def test_loading_unexisting_template(self): 170 | from django.template.loader import render_to_string 171 | try: 172 | render_to_string('not_existent.html') 173 | except TemplateDoesNotExist as e: 174 | self.assertEqual(e.args, ('not_existent.html',)) 175 | else: 176 | self.fail('TemplateDoesNotExist was not raised.') 177 | 178 | 179 | class MobileDetectionMiddlewareTests(BaseTestCase): 180 | @patch('django_mobile.middleware.set_flavour') 181 | def test_mobile_browser_agent(self, set_flavour): 182 | request = Mock() 183 | request.META = { 184 | 'HTTP_USER_AGENT': 'My Mobile Browser', 185 | } 186 | middleware = MobileDetectionMiddleware() 187 | middleware.process_request(request) 188 | self.assertEqual(set_flavour.call_args, (('mobile', request), {})) 189 | 190 | @patch('django_mobile.middleware.set_flavour') 191 | def test_desktop_browser_agent(self, set_flavour): 192 | request = Mock() 193 | request.META = { 194 | 'HTTP_USER_AGENT': 'My Desktop Browser', 195 | } 196 | middleware = MobileDetectionMiddleware() 197 | middleware.process_request(request) 198 | self.assertEqual(set_flavour.call_args, (('full', request), {})) 199 | 200 | 201 | class SetFlavourMiddlewareTests(BaseTestCase): 202 | def test_set_default_flavour(self): 203 | request = Mock() 204 | request.META = MagicMock() 205 | request.GET = {} 206 | middleware = SetFlavourMiddleware() 207 | middleware.process_request(request) 208 | # default flavour is set 209 | self.assertEqual(get_flavour(), 'full') 210 | 211 | @patch('django_mobile.middleware.set_flavour') 212 | def test_set_flavour_through_get_parameter(self, set_flavour): 213 | request = Mock() 214 | request.META = MagicMock() 215 | request.GET = {'flavour': 'mobile'} 216 | middleware = SetFlavourMiddleware() 217 | middleware.process_request(request) 218 | self.assertEqual(set_flavour.call_args, 219 | (('mobile', request), {'permanent': True})) 220 | 221 | 222 | class RealAgentNameTests(BaseTestCase): 223 | def assertFullFlavour(self, agent): 224 | client = Client(HTTP_USER_AGENT=agent) 225 | response = client.get('/') 226 | if str_p3_response( response.content.strip() ) != 'Hello full.': 227 | self.fail(u'Agent is matched as mobile: %s' % agent) 228 | 229 | def assertMobileFlavour(self, agent): 230 | client = Client(HTTP_USER_AGENT=agent) 231 | response = client.get('/') 232 | if str_p3_response( response.content.strip() ) != 'Mobile!': 233 | self.fail(u'Agent is not matched as mobile: %s' % agent) 234 | 235 | def test_ipad(self): 236 | self.assertFullFlavour(u'Mozilla/5.0 (iPad; U; CPU OS 3_2 like Mac OS X; en-us) AppleWebKit/531.21.10 (KHTML, like Gecko) Version/4.0.4 Mobile/7B334b Safari/531.21.10') 237 | 238 | def test_iphone(self): 239 | self.assertMobileFlavour(u'Mozilla/5.0 (iPhone; U; CPU like Mac OS X; en) AppleWebKit/420+ (KHTML, like Gecko) Version/3.0 Mobile/1A543a Safari/419.3') 240 | 241 | def test_motorola_xoom(self): 242 | self.assertFullFlavour(u'Mozilla/5.0 (Linux; U; Android 3.0; en-us; Xoom Build/HRI39) AppleWebKit/534.13 (KHTML, like Gecko) Version/4.0 Safari/534.13') 243 | 244 | def test_opera_mobile_on_android(self): 245 | ''' 246 | Regression test of issue #9 247 | ''' 248 | self.assertMobileFlavour(u'Opera/9.80 (Android 2.3.3; Linux; Opera Mobi/ADR-1111101157; U; en) Presto/2.9.201 Version/11.50') 249 | 250 | 251 | class RegressionTests(BaseTestCase): 252 | def setUp(self): 253 | self.desktop = Client() 254 | # wap triggers mobile behaviour 255 | self.mobile = Client(HTTP_USER_AGENT='wap') 256 | 257 | def test_multiple_browser_access(self): 258 | ''' 259 | Regression test of issue #2 260 | ''' 261 | response = self.desktop.get('/') 262 | self.assertEqual( str_p3_response( response.content.strip() ), 'Hello full.') 263 | 264 | response = self.mobile.get('/') 265 | self.assertEqual( str_p3_response( response.content.strip() ), 'Mobile!') 266 | 267 | response = self.desktop.get('/') 268 | self.assertEqual( str_p3_response( response.content.strip() ), 'Hello full.') 269 | 270 | response = self.mobile.get('/') 271 | self.assertEqual( str_p3_response( response.content.strip() ), 'Mobile!') 272 | 273 | def test_cache_page_decorator(self): 274 | response = self.mobile.get('/cached/') 275 | self.assertEqual( str_p3_response( response.content.strip() ), 'Mobile!') 276 | 277 | response = self.desktop.get('/cached/') 278 | self.assertEqual( str_p3_response( response.content.strip() ), 'Hello full.') 279 | 280 | response = self.mobile.get('/cached/') 281 | self.assertEqual( str_p3_response( response.content.strip() ), 'Mobile!') 282 | 283 | response = self.desktop.get('/cached/') 284 | self.assertEqual( str_p3_response( response.content.strip() ), 'Hello full.') 285 | -------------------------------------------------------------------------------- /django_mobile_tests/urls.py: -------------------------------------------------------------------------------- 1 | try: 2 | from django.conf.urls.defaults import * 3 | except ImportError: 4 | from django.conf.urls import * 5 | from django.shortcuts import render_to_response 6 | from django.template import RequestContext 7 | from django_mobile.cache import cache_page 8 | 9 | 10 | def index(request): 11 | return render_to_response('index.html', { 12 | }, context_instance=RequestContext(request)) 13 | 14 | 15 | urlpatterns = patterns('', 16 | url(r'^$', index), 17 | url(r'^cached/$', cache_page(60*10)(index)), 18 | ) 19 | -------------------------------------------------------------------------------- /examples/middleware.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django_mobile.middleware import MobileDetectionMiddleware 4 | from django_mobile import set_flavour 5 | from django.conf import settings 6 | 7 | 8 | class MobileTabletDetectionMiddleware(MobileDetectionMiddleware): 9 | # Example how default middleware could be expanded to provide possibility to detect 10 | # tablet devices. 11 | 12 | user_agents_android_search = u"(?:android)" 13 | user_agents_mobile_search = u"(?:mobile)" 14 | user_agents_tablets_search = u"(?:%s)" % u'|'.join(('ipad', 'tablet', )) 15 | 16 | def __init__(self): 17 | super(MobileTabletDetectionMiddleware, self).__init__() 18 | self.user_agents_android_search_regex = re.compile(self.user_agents_android_search, 19 | re.IGNORECASE) 20 | self.user_agents_mobile_search_regex = re.compile(self.user_agents_mobile_search, 21 | re.IGNORECASE) 22 | self.user_agents_tablets_search_regex = re.compile(self.user_agents_tablets_search, 23 | re.IGNORECASE) 24 | 25 | def process_request(self, request): 26 | is_tablet = False 27 | 28 | user_agent = request.META.get('HTTP_USER_AGENT') 29 | if user_agent: 30 | # Ipad or Blackberry 31 | if self.user_agents_tablets_search_regex.search(user_agent): 32 | is_tablet = True 33 | # Android-device. If User-Agent doesn't contain Mobile, then it's a tablet 34 | elif (self.user_agents_android_search_regex.search(user_agent) and 35 | not self.user_agents_mobile_search_regex.search(user_agent)): 36 | is_tablet = True 37 | else: 38 | # otherwise, let the superclass make decision 39 | super(MobileTabletDetectionMiddleware, self).process_request(request) 40 | 41 | # set tablet flavour. It can be `mobile`, `tablet` or anything you want 42 | if is_tablet: 43 | set_flavour(settings.FLAVOURS[2], request) 44 | -------------------------------------------------------------------------------- /requirements/tests.txt: -------------------------------------------------------------------------------- 1 | argparse 2 | coverage 3 | django-discover-runner 4 | mock == 1.0.1 5 | tox 6 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import argparse 3 | import os, sys 4 | 5 | 6 | os.environ['DJANGO_SETTINGS_MODULE'] = 'django_mobile_tests.settings' 7 | 8 | 9 | # Adding current directory to ``sys.path``. 10 | parent = os.path.dirname(os.path.abspath(__file__)) 11 | sys.path.insert(0, parent) 12 | 13 | 14 | def runtests(*argv): 15 | argv = list(argv) or [ 16 | 'django_mobile', 17 | 'django_mobile_tests', 18 | ] 19 | opts = argparser.parse_args(argv) 20 | 21 | if opts.coverage: 22 | from coverage import coverage 23 | test_coverage = coverage( 24 | branch=True, 25 | source=['django_mobile']) 26 | test_coverage.start() 27 | 28 | # Run tests. 29 | from django.core.management import execute_from_command_line 30 | execute_from_command_line([sys.argv[0], 'test'] + opts.appname) 31 | 32 | if opts.coverage: 33 | test_coverage.stop() 34 | 35 | # Report coverage to commandline. 36 | test_coverage.report(file=sys.stdout) 37 | 38 | 39 | argparser = argparse.ArgumentParser(description='Process some integers.') 40 | argparser.add_argument('appname', nargs='*') 41 | argparser.add_argument('--no-coverage', dest='coverage', action='store_const', 42 | const=False, default=True, help='Do not collect coverage data.') 43 | 44 | 45 | if __name__ == '__main__': 46 | runtests(*sys.argv[1:]) 47 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import re 4 | import os 5 | import sys 6 | from setuptools import setup 7 | 8 | 9 | README_PATH = os.path.join(os.path.dirname(__file__), 'README.rst') 10 | CHANGES_PATH = os.path.join(os.path.dirname(__file__), 'CHANGES.rst') 11 | 12 | 13 | def readfile(filename): 14 | if sys.version_info[0] >= 3: 15 | return open(filename, 'r', encoding='utf-8').read() 16 | else: 17 | return open(filename, 'r').read() 18 | 19 | 20 | def get_author(package): 21 | """ 22 | Return package version as listed in `__version__` in `init.py`. 23 | """ 24 | init_py = readfile(os.path.join(package, '__init__.py')) 25 | author = re.search("__author__ = u?['\"]([^'\"]+)['\"]", init_py).group(1) 26 | return UltraMagicString(author) 27 | 28 | 29 | def get_version(package): 30 | """ 31 | Return package version as listed in `__version__` in `init.py`. 32 | """ 33 | init_py = readfile(os.path.join(package, '__init__.py')) 34 | return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) 35 | 36 | 37 | class UltraMagicString(object): 38 | ''' 39 | Taken from 40 | http://stackoverflow.com/questions/1162338/whats-the-right-way-to-use-unicode-metadata-in-setup-py 41 | ''' 42 | def __init__(self, value): 43 | self.value = value 44 | 45 | def __str__(self): 46 | return self.value 47 | 48 | def __unicode__(self): 49 | return self.value.decode('UTF-8') 50 | 51 | def __add__(self, other): 52 | return UltraMagicString(self.value + str(other)) 53 | 54 | def split(self, *args, **kw): 55 | return self.value.split(*args, **kw) 56 | 57 | 58 | if sys.version_info[0] >= 3: 59 | long_description = u'\n\n'.join(( 60 | readfile(README_PATH), 61 | readfile(CHANGES_PATH), 62 | )) 63 | else: 64 | long_description = u'\n\n'.join(( 65 | readfile(README_PATH).decode('utf-8'), 66 | readfile(CHANGES_PATH).decode('utf-8'), 67 | )) 68 | long_description = long_description.encode('utf-8') 69 | long_description = UltraMagicString(long_description) 70 | 71 | 72 | setup( 73 | name='django-mobile', 74 | version=get_version('django_mobile'), 75 | url='https://github.com/gregmuellegger/django-mobile', 76 | license='BSD', 77 | description=u'Detect mobile browsers and serve different template flavours to them.', 78 | long_description=long_description, 79 | author=get_author('django_mobile'), 80 | author_email='gregor@muellegger.de', 81 | keywords='django,mobile', 82 | classifiers=[ 83 | 'Development Status :: 4 - Beta', 84 | 'Environment :: Web Environment', 85 | 'Framework :: Django', 86 | 'Intended Audience :: Developers', 87 | 'License :: OSI Approved :: BSD License', 88 | 'Natural Language :: English', 89 | 'Operating System :: OS Independent', 90 | "Programming Language :: Python :: 2.6", 91 | "Programming Language :: Python :: 2.7", 92 | "Programming Language :: Python :: 3.3", 93 | "Topic :: Internet :: WWW/HTTP", 94 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 95 | "Topic :: Software Development :: Libraries :: Python Modules", 96 | ], 97 | packages=[ 98 | 'django_mobile', 99 | 'django_mobile.cache', 100 | ], 101 | tests_require=['Django', 'mock'], 102 | test_suite='django_mobile_tests.runtests.runtests', 103 | ) 104 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion = 1.8 3 | envlist = 4 | py26-{15,16}, 5 | py27-{15,16,17,18,19,master}, 6 | py33-{17,18,master}, 7 | py34-{17,18,19,master}, 8 | pypy-{15,16,17,18,19,master} 9 | 10 | [testenv] 11 | commands = python runtests.py 12 | deps = 13 | 15: Django >= 1.5, < 1.6 14 | 16: Django >= 1.6, < 1.7 15 | 17: Django >= 1.7, < 1.8 16 | 18: Django >= 1.8, < 1.9 17 | 19: Django >= 1.9, < 1.10 18 | master: https://github.com/django/django/tarball/master#egg=Django 19 | -r{toxinidir}/requirements/tests.txt 20 | --------------------------------------------------------------------------------