Edit me on GitHub

Step 09: AJAX With JSON Views

Modern web development means AJAX. Through its renderer support, Pyramid makes return JSON data from a view very easy and coherent with the rest of the Pyramid architecture.

In this step we add a box to each screen which fetches, formats, and re-fetches site news updates.

Note

Our templates will include jQuery from the Google CDN.

Goals

  • Show Pyramid’s support for AJAX

Objectives

  • Learn about the json renderer
  • Add a test for the JSON response
  • Include into the main template

Steps

  1. $ cd ../../creatingux; mkdir step09; cd step09

  2. Copy the following into step09/application.py:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    from wsgiref.simple_server import make_server
    
    from pyramid.config import Configurator
    
    def main():
        config = Configurator()
        config.include('pyramid_chameleon')
        config.scan("views")
        config.add_static_view('static', 'static/',
                               cache_max_age=86400)
        app = config.make_wsgi_app()
        return app
    
    if __name__ == '__main__':
        app = main()
        server = make_server('0.0.0.0', 8080, app)
        server.serve_forever()
    
  3. $ mkdir static

  4. Copy the following into step09/static/global_layout.css:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    body {
        font-family: Arial, sans-serif;
        font-size: 1em;
        padding: 0;
        margin: 0;
        background-color: white;
    }
    
    #header {
        height: 3em;
        background-color: lightgray;
    }
    
    #header ul {
        margin-left: 1em;
        padding: 0;
    }
    
    #header li {
        display: inline-block;
        margin-top: 1em;
        padding-right: 0.8em;
    }
    
    #header a {
        color: black;
    }
    #main {
        margin: 2em;
    }
    
    #sidebar {
        float:right;
        width: 300px;
        border-left: solid 2px gray;
        border-bottom:  solid 2px gray;
        padding-left: 10px;
    }
    
    #sidebar ul {
        height: 100px;
    }
    #sidebar h2 {
        text-align: center;
    }
    
    #sidebar p {
        text-align: center;
    }
    
    #sidebar {
        float:right;
        width: 300px;
        border-left: solid 2px gray;
        border-bottom:  solid 2px gray;
        padding-left: 10px;
    }
    
    #sidebar ul {
        height: 100px;
    }
    #sidebar h2 {
        text-align: center;
    }
    
    #sidebar p {
        text-align: center;
    }
    
  5. Copy the following into step09/static/global_layout.js:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    $(function() {
    
        function get_updates () {
            $.getJSON('/updates.json', function(data) {
                var target = $('#sidebar ul');
                target.empty();
                $.each(data, function (key, val) {
                    target.append('<li>Update #' + val + '</li>');
                });
            });
        }
    
        $('#sidebar a').click(function () {
            get_updates();
        });
    
        get_updates();
    });
    
  6. Copy the following into step09/views.py:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    from random import randint
    
    from pyramid.view import view_config
    
    from dummy_data import COMPANY
    from dummy_data import PEOPLE
    from dummy_data import PROJECTS
    
    from layouts import Layouts
    
    class ProjectorViews(Layouts):
    
        def __init__(self, request):
            self.request = request
            
        @view_config(renderer="templates/index.pt")
        def index_view(self):
            return {"page_title": "Home"}
    
        @view_config(renderer="templates/about.pt", name="about.html")
        def about_view(self):
            return {"page_title": "About"}
    
        @view_config(renderer="templates/company.pt",
                     name="acme")
        def company_view(self):
            return {"page_title": COMPANY + " Projects",
                    "projects": PROJECTS}
    
        @view_config(renderer="templates/people.pt", name="people")
        def people_view(self):
            return {"page_title": "People", "people": PEOPLE}
    
        @view_config(renderer="json", name="updates.json")
        def updates_view(self):
            return [
                randint(0,100),
                randint(0,100),
                randint(0,100),
                randint(0,100),
                888,
            ]
    
  7. Copy the following into step09/layouts.py:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    from pyramid.renderers import get_renderer
    from pyramid.decorator import reify
    
    from dummy_data import COMPANY
    from dummy_data import SITE_MENU
    
    class Layouts(object):
    
        @reify
        def global_template(self):
            renderer = get_renderer("templates/global_layout.pt")
            return renderer.implementation().macros['layout']
    
        @reify
        def company_name(self):
            return COMPANY
    
        @reify
        def site_menu(self):
            new_menu = SITE_MENU[:]
            url = self.request.url
            for menu in new_menu:
                if menu['title'] == 'Home':
                    menu['current'] = url.endswith('/')
                else:
                    menu['current'] = url.endswith(menu['href'])
            return new_menu
    
  8. Copy the following into step09/dummy_data.py:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    # Dummy data
    COMPANY = "ACME, Inc."
    
    PEOPLE = [
            {'name': 'sstanton', 'title': 'Susan Stanton'},
            {'name': 'bbarker', 'title': 'Bob Barker'},
    ]
    
    PROJECTS = [
            {'name': 'sillyslogans', 'title': 'Silly Slogans'},
            {'name': 'meaninglessmissions', 'title': 'Meaningless Missions'},
    ]
    
    SITE_MENU = [
            {'href': '', 'title': 'Home'},
            {'href': 'about.html', 'title': 'About Projector'},
            {'href': 'acme', 'title': COMPANY},
            {'href': 'people', 'title': 'People'},
    ]
    
  9. Copy the following “global template” into step09/templates/global_layout.pt:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
            "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
    <html xmlns="http://www.w3.org/1999/xhtml"
          xmlns:metal="http://xml.zope.org/namespaces/metal"
          xmlns:tal="http://xml.zope.org/namespaces/tal"
          metal:define-macro="layout">
    <head>
        <title>Projector - ${page_title}</title>
        <link rel="stylesheet" href="/static/global_layout.css"/>
        <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.6.4/jquery.min.js"></script>
        <script src="/static/global_layout.js"></script>
    </head>
    <body>
    <div id="header">
        <ul>
            <li tal:repeat="menu view.site_menu">
                <tal:block tal:condition="menu.current">
                    <span>${menu.title}</span>
                </tal:block>
                <tal:block tal:condition="not menu.current">
                    <span><a href="/${menu.href}">${menu.title}</a></span>
                </tal:block>
            </li>
        </ul>
    </div>
    <div id="sidebar">
        <h2>Updates</h2>
        <ul></ul>
        <p><a href="#">reload</a></p>
    </div>
    <div id="main"><h1>${page_title}</h1>
    
        <div metal:define-slot="content">
        </div>
    </div>
    </body>
    </html>
    
  10. Copy the following into step09/templates/index.pt:

    1
    2
    3
    4
    5
    <div metal:use-macro="view.global_template">
        <div metal:fill-slot="content">
            <p>Home page content goes here.</p>
        </div>
    </div>
    
  11. Copy the following into step09/templates/about.pt:

    1
    2
    3
    4
    5
    6
    7
    <div metal:use-macro="view.global_template">
        <div metal:fill-slot="content">
            <p>Projector is a simple project management tool capable of hosting
                multiple projects for multiple independent companies,
                sharing a developer pool between autonomous companies.</p>
        </div>
    </div>
    
  12. Copy the following into step09/templates/company.pt:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <div metal:use-macro="view.global_template">
        <div metal:fill-slot="content">
            <ul>
                <li tal:repeat="project projects">
                    <a href="${project.name}">${project.title}</a>
                </li>
            </ul>
        </div>
    </div>
    
  13. Copy the following into step09/templates/people.pt:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <div metal:use-macro="view.global_template">
        <div metal:fill-slot="content">
            <ul>
                <li tal:repeat="person people">
                    <a href="${person.name}">${person.title}</a>
                </li>
            </ul>
        </div>
    </div>
    
  14. Copy the following into step09/test_views.py:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    import unittest
    
    from pyramid.testing import DummyRequest
    from pyramid.testing import setUp
    from pyramid.testing import tearDown
    
    class ProjectorViewsUnitTests(unittest.TestCase):
        def setUp(self):
            request = DummyRequest()
            self.config = setUp(request=request)
            self.config.include('pyramid_chameleon')
    
        def tearDown(self):
            tearDown()
    
        def _makeOne(self, request):
            from views import ProjectorViews
    
            inst = ProjectorViews(request)
            return inst
    
        def test_index_view(self):
            request = DummyRequest()
            inst = self._makeOne(request)
            result = inst.index_view()
            self.assertEqual(result['page_title'], 'Home')
    
        def test_about_view(self):
            request = DummyRequest()
            inst = self._makeOne(request)
            result = inst.about_view()
            self.assertEqual(result['page_title'], 'About')
    
        def test_company_view(self):
            request = DummyRequest()
            inst = self._makeOne(request)
            result = inst.company_view()
            self.assertEqual(result["page_title"], "ACME, Inc. Projects")
            self.assertEqual(len(result["projects"]), 2)
    
        def test_people_view(self):
            request = DummyRequest()
            inst = self._makeOne(request)
            result = inst.people_view()
            self.assertEqual(result["page_title"], "People")
            self.assertEqual(len(result["people"]), 2)
    
        def test_updates_view(self):
            request = DummyRequest()
            inst = self._makeOne(request)
            result = inst.updates_view()
            self.assertEqual(len(result), 5)
    
    class ProjectorFunctionalTests(unittest.TestCase):
        def setUp(self):
            from application import main
            app = main()
            from webtest import TestApp
            self.testapp = TestApp(app)
    
        def test_it(self):
            res = self.testapp.get('/', status=200)
            self.assertTrue(b'Home' in res.body)
            res = self.testapp.get('/about.html', status=200)
            self.assertTrue(b'autonomous' in res.body)
            res = self.testapp.get('/people', status=200)
            self.assertTrue(b'Susan' in res.body)
            res = self.testapp.get('/acme', status=200)
            self.assertTrue(b'Silly Slogans' in res.body)
            res = self.testapp.get('/updates.json', status=200)
            self.assertTrue(b'888' in res.body)
    
  15. Copy the following into step09/test_layout.py:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    import unittest
    
    from pyramid.testing import DummyRequest
    from pyramid.testing import setUp
    from pyramid.testing import tearDown
    
    class LayoutUnitTests(unittest.TestCase):
        def setUp(self):
            request = DummyRequest()
            self.config = setUp(request=request)
            self.config.include('pyramid_chameleon')
    
        def tearDown(self):
            tearDown()
    
        def _makeOne(self):
            from layouts import Layouts
    
            inst = Layouts()
            return inst
    
        def test_global_template(self):
            from chameleon.zpt.template import Macro
    
            inst = self._makeOne()
            self.assertEqual(inst.global_template.__class__, Macro)
    
        def test_company_name(self):
            from dummy_data import COMPANY
    
            inst = self._makeOne()
            self.assertEqual(inst.company_name, COMPANY)
    
        def test_site_menu(self):
            from dummy_data import SITE_MENU
    
            inst = self._makeOne()
            inst.request = DummyRequest()
            self.assertEqual(len(inst.site_menu), len(SITE_MENU))
    
  16. $ nosetests should report running 9 tests.

  17. $ python application.py

  18. Open http://127.0.0.1:8080 in your browser.

Extra Credit

  1. Rather than a static random dictionary, make a mutable global that gets appended to on each request, showing the time. Each request adds another item to the list.
  2. Can WebTest provide any functional testing for AJAX?

Analysis

The JSON view is pretty fun. It looks very similar to our other views, which is good. In fact, the whole pattern of simply returning data from your view, and letting the machinery pass it into a renderer, provides consistency and simplicity. Plus, tests are a lot easier to write.

Discussion

  • How do other systems (Zope2, Zope3, Plone, Django, Pylons 1) approach this?

Table Of Contents

Previous topic

Step 08: CSS and JS With Static Assets

Next topic

Step 10: Re-usable Template Macros