Step 05: Projector with Resources

We have now seen how to create fake content in a hierarchy. Let’s hook this up with the UX we finished with in Step 10: Re-usable Template Macros.

In particular, let’s make a series of screens that let you add new people, new companies, new projects, and new folders and documents inside projects and folders.

Along the way we do a lot of refactoring. Since we are accumulating a lot of code, we will dial back a bit on the tests.

Goals

  • Addable content in a generalized content and view space
  • Helper functions which deal with URLs in hierarchies

Objectives

  • View methods which can act as self-posting forms
  • Helper methods on view classes to act as “factories” to reduce repetitive code for making resources
  • Helper functions on the layouts (and layout macros) to provide consistency, as well as place-neutral lookups
  • Eliminate the dummy data

Steps

  1. $ cd ../../resources; mkdir step05; cd step05

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

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    from pyramid.config import Configurator
    
    from wsgiref.simple_server import make_server
    
    from resources import bootstrap
    
    
    def main():
        config = Configurator(root_factory=bootstrap)
        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(host='0.0.0.0', port=8080, app=app)
        server.serve_forever()
    
  3. $ mkdir static

  4. Copy the following into step05/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
    body {
        font-family: Arial, sans-serif;
        font-size: 1em;
        padding: 0;
        margin: 0;
        background-color: white;
    }
    
    #header {
        height: 3em;
        background-color: lightgray;
    }
    
    #header ul, #company_menu {
        margin-left: 1em;
        padding: 0;
    }
    
    #header li, #company_menu 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;
    }
    
    #message {
        background-color: yellow;
        text-align: center;
        width: 100px;
        margin-left: 100px;
    }
    
  5. Copy the following into step05/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 step05/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
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    from random import randint
    
    from pyramid.httpexceptions import HTTPFound
    from pyramid.view import view_config
    
    from layouts import Layouts
    from resources import Company
    from resources import Document
    from resources import Folder
    from resources import People
    from resources import Person
    from resources import Project
    from resources import Site
    
    class ProjectorViews(Layouts):
        def __init__(self, context, request):
            self.context = context
            self.request = request
    
        def _add_container(self, klass):
            title = self.request.params['title']
            name = str(randint(0, 10000))
            self.context[name] = klass(name, self.context, title)
            url = self.request.resource_url(self.context,
                                            query={'msg': 'Added'})
            return HTTPFound(location=url)
    
        def _add_document(self, klass=Document):
            title = self.request.params['title']
            name = str(randint(0, 10000))
            self.context[name] = klass(name, self.context, title,
                                       '<p>Default</p>')
            url = self.request.resource_url(self.context,
                                            query={'msg': 'Added'})
            return HTTPFound(location=url)
    
        @view_config(renderer="templates/site.pt", context=Site)
        def site_view(self):
            if 'submit' in self.request.POST:
                return self._add_container(Company)
            return {"page_title": "Home"}
    
        @view_config(renderer="templates/company.pt", context=Company)
        def company_view(self):
            if 'submit' in self.request.POST:
                return self._add_container(Project)
            return {"page_title": self.context.title}
    
        @view_config(renderer="templates/project.pt", context=Project)
        def project_view(self):
            if 'folder' in self.request.POST:
                return self._add_container(Folder)
            if 'document' in self.request.POST:
                return self._add_document()
            return {"page_title": self.context.title}
    
        @view_config(renderer="templates/folder.pt", context=Folder)
        def folder_view(self):
            if 'folder' in self.request.POST:
                return self._add_container(Folder)
            if 'document' in self.request.POST:
                return self._add_document()
            return {"page_title": self.context.title}
    
        @view_config(renderer="templates/document.pt", context=Document)
        def document_view(self):
            return {"page_title": self.context.title}
    
    
        @view_config(renderer="templates/people.pt", context=People)
        def people_view(self):
            if 'submit' in self.request.POST:
                return self._add_document(Person)
            return {"page_title": self.context.title}
    
        @view_config(renderer="templates/person.pt", context=Person)
        def person_view(self):
            return {"page_title": self.context.title}
    
    
        @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 step05/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
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    from pyramid.decorator import reify
    from pyramid.location import lineage
    from pyramid.renderers import get_renderer
    
    from resources import Company
    from resources import Site
    
    class Layouts(object):
        @reify
        def global_template(self):
            renderer = get_renderer("templates/global_layout.pt")
            return renderer.implementation().macros['layout']
    
        @reify
        def global_macros(self):
            renderer = get_renderer("templates/macros.pt")
            return renderer.implementation().macros
    
        @reify
        def site(self):
            # From somewhere deep in hierarchy, reach up and grab site
            for l in lineage(self.context):
                if isinstance(l, Site):
                    return l
            return None
    
        @reify
        def company(self):
            # From somewhere deep in hierarchy, reach up and grab company
            for l in lineage(self.context):
                if isinstance(l, Company):
                    return l
            return None
    
        @reify
        def message(self):
            return self.request.GET.get('msg', None)
    
        @reify
        def site_menu(self):
            new_menu = []
            for c in [self.site,] + self.site.values():
                url = self.request.resource_url(c)
                new_menu.append({'href': url, 'title': c.title})
            return new_menu
    
  8. Copy the following into step05/resources.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
    class Folder(dict):
        def __init__(self, name, parent, title):
            self.__name__ = name
            self.__parent__ = parent
            self.title = title
    
    
    class Document(object):
        def __init__(self, name, parent, title, body):
            self.__name__ = name
            self.__parent__ = parent
            self.title = title
            self.body = body
    
    
    class Site(Folder):
        def bootstrap(self):
    
            # Document
            body = "<p>Project is a <em>project management system.</p>"
            root['about'] = Document('about', root, 'About Projector', body)
    
            # Some people
            people = People('people', root, 'People')
            root['people'] = people
            people['sstanton'] = Person('sstanton', people, 'Susan Stanton',
                                        '<p>Hello <em>Susan bio<em></p>')
            people['bbarker'] = Person('bbarker', people, 'Bob Barker',
                                       '<p>The <em>Bob bio</em> goes here</p>')
    
            # Some companies and projects and docs
            acme = Company('acme', root, 'ACME, Inc.')
            root['acme'] = acme
            project01 = Project('project01', acme, 'Project 01')
            acme['project01'] = project01
            project02 = Project('project02', acme, 'Project 02')
            acme['project02'] = project02
            project01['doc1'] = Document('doc1', project01, 'Document 01',
                                         '<p>Some doc of <em>stuff</em></p>')
            project01['doc2'] = Document('doc2', project01, 'Document 02',
                                         '<p>More <em>stuff</em></p>')
            folder1 = Folder('folder1', project01, 'Folder 1')
            project01['folder1'] = folder1
            folder1['doc3'] = Document('doc3', folder1, 'Document 3',
                                       '<p>A <em>really</em> deep down doc')
    
    
    
    class People(Folder):
        pass
    
    
    class Person(Document):
        pass
    
    
    class Company(Folder):
        pass
    
    
    class Project(Folder):
        pass
    
    root = Site('', None, 'Home')
    root.bootstrap()
    
    def bootstrap(request):
        return root
    
  9. Copy the following into step05/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
    <!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">
                <span><a href="${menu.href}">${menu.title}</a></span>
            </li>
        </ul>
    </div>
    <div id="sidebar">
        <h2>Updates</h2>
        <ul></ul>
        <p><a href="#">reload</a></p>
    </div>
    <tal:block condition="view.message">
        <p id="message" tal:content="view.message">Message</p>
    </tal:block>
    <div id="main"><h1>${page_title}</h1>
    
        <div metal:define-slot="content">
        </div>
    </div>
    </body>
    </html>
    
  10. Copy the following into step05/templates/macros.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
    <!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">
    <head>
        <title>Macros</title>
    </head>
    <body>
    <metal:company_menu define-macro="company_menu">
        <ul tal:condition="view.company" id="company_menu">
            <li>Projects:</li>
            <li tal:repeat="child view.company.values()">
                <a href="${view.request.resource_url(child)}">${child.title}</a>
            </li>
        </ul>
    </metal:company_menu>
    <metal:children_listing define-macro="children_listing">
        <tal:block condition="view.context.values() == []">
            <em>No items.</em>
        </tal:block>
        <tal:block condition="view.context.values() != []">
            <ul>
                <li tal:repeat="child view.context.values()">
                    <a href="${view.request.resource_url(child)}">${child.title}</a>
                </li>
            </ul>
        </tal:block>
    </metal:children_listing>
    </body>
    </html>
    
  11. Copy the following into step05/templates/company.pt:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    <div metal:use-macro="view.global_template">
        <div metal:fill-slot="content">
            <p>Welcome to our company project management system.</p>
            <form action="." method="POST">
                <label for="title">Title:</label>
                <input name="title"/>
                <input name="submit" type="submit" value="Add Project"/>
            </form>
            <div metal:use-macro="view.global_macros['company_menu']"></div>
            
        </div>
    </div>
    
  12. Copy the following into step05/templates/document.pt:

    1
    2
    3
    4
    5
    6
    7
    <div metal:use-macro="view.global_template">
        <div metal:fill-slot="content">
            <div tal:replace="structure view.context.body"/>
            <div metal:use-macro="view.global_macros['company_menu']"></div>
            
        </div>
    </div>
    
  13. Copy the following into step05/templates/folder.pt:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    <div metal:use-macro="view.global_template">
        <div metal:fill-slot="content">
            <h2>Folder Contents</h2>
            <div metal:use-macro="view.global_macros['children_listing']"></div>
            <form action="." method="POST">
                <label for="title">Title:</label>
                <input name="title"/>
                <input name="folder" type="submit" value="Add Folder"/>
            </form>
            <form action="." method="POST">
                <label for="title">Title:</label>
                <input name="title"/>
                <input name="document" type="submit" value="Add Document"/>
            </form>
            <div metal:use-macro="view.global_macros['company_menu']"></div>
            
        </div>
    </div>
    
  14. Copy the following into step05/templates/people.pt:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    <div metal:use-macro="view.global_template">
        <div metal:fill-slot="content">
            <div metal:use-macro="view.global_macros['children_listing']"></div>
            <form action="." method="POST">
                <label for="title">Title:</label>
                <input name="title"/>
                <input name="submit" type="submit" value="Add Person"/>
            </form>
        </div>
    </div>
    
  15. Copy the following into step05/templates/person.pt:

    1
    2
    3
    4
    5
    <div metal:use-macro="view.global_template">
        <div metal:fill-slot="content">
            <div tal:replace="structure view.context.body"/>
        </div>
    </div>
    
  16. Copy the following into step05/templates/project.pt:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    <div metal:use-macro="view.global_template">
        <div metal:fill-slot="content">
            <h2>Project Content</h2>
            <div metal:use-macro="view.global_macros['children_listing']"></div>
            <form action="." method="POST">
                <label for="title">Title:</label>
                <input name="title"/>
                <input name="folder" type="submit" value="Add Folder"/>
            </form>
            <form action="." method="POST">
                <label for="title">Title:</label>
                <input name="title"/>
                <input name="document" type="submit" value="Add Document"/>
            </form>
            <div metal:use-macro="view.global_macros['company_menu']"></div>
            
        </div>
    </div>
    
  17. Copy the following into step05/templates/site.pt:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    <div metal:use-macro="view.global_template">
        <div metal:fill-slot="content">
            <p>Home page content goes here.</p>
            <form action="." method="POST">
                <label for="title">Title:</label>
                <input name="title"/>
                <input name="submit" type="submit" value="Add Company"/>
            </form>
        </div>
    </div>
    
  18. Copy the following into step05/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
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    import unittest
    
    from pyramid.testing import DummyRequest
    from pyramid.testing import setUp
    from pyramid.testing import tearDown
    
    class DummyContext(object):
        title = "Dummy Context"
    
    
    class ProjectorViewsUnitTests(unittest.TestCase):
        def setUp(self):
            request = DummyRequest()
            self.config = setUp(request=request)
    
        def tearDown(self):
            tearDown()
    
        def _makeOne(self, request):
            from views import ProjectorViews
    
            context = DummyContext()
            inst = ProjectorViews(context, request)
            return inst
    
        def test_site_view(self):
            request = DummyRequest()
            inst = self._makeOne(request)
            result = inst.site_view()
            self.assertEqual(result['page_title'], 'Home')
    
        def test_company_view(self):
            request = DummyRequest()
            inst = self._makeOne(request)
            result = inst.company_view()
            self.assertEqual(result["page_title"], "Dummy Context")
    
        def test_project_view(self):
            request = DummyRequest()
            inst = self._makeOne(request)
            result = inst.project_view()
            self.assertEqual(result["page_title"], "Dummy Context")
    
        def test_folder_view(self):
            request = DummyRequest()
            inst = self._makeOne(request)
            result = inst.folder_view()
            self.assertEqual(result["page_title"], "Dummy Context")
    
        def test_document_view(self):
            request = DummyRequest()
            inst = self._makeOne(request)
            result = inst.document_view()
            self.assertEqual(result["page_title"], "Dummy Context")
    
        def test_people_view(self):
            request = DummyRequest()
            inst = self._makeOne(request)
            result = inst.people_view()
            self.assertEqual(result["page_title"], "Dummy Context")
    
        def test_person_view(self):
            request = DummyRequest()
            inst = self._makeOne(request)
            result = inst.person_view()
            self.assertEqual(result["page_title"], "Dummy Context")
    
    
    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('Home page content' in res.body)
            res = self.testapp.get('/about', status=200)
            self.assertTrue('project management system' in res.body)
            res = self.testapp.get('/acme', status=200)
            self.assertTrue('our company' in res.body)
            res = self.testapp.get('/acme/project01', status=200)
            self.assertTrue('Project Content' in res.body)
            res = self.testapp.get('/acme/project01/folder1', status=200)
            self.assertTrue('Folder Contents' in res.body)
            res = self.testapp.get('/acme/project01/folder1/doc3',
                                   status=200)
            self.assertTrue('deep down' in res.body)
            res = self.testapp.get('/people', status=200)
            self.assertTrue('Add Person' in res.body)
            res = self.testapp.get('/people/bbarker', status=200)
            self.assertTrue('goes here' in res.body)
            res = self.testapp.get('/updates.json', status=200)
            self.assertTrue('888' in res.body)
    
  19. $ nosetests should report running 10 tests.

  20. $ python application.py

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

Extra Credit

  1. We no longer have a site_menu which omits the <a> from the current menu. What’s a good way to add the functionality back in?
  2. When adding something to a container, you have to pass a reference to the container, into the object’s constructor. Is there a different pattern for this? (Hint: repoze.folder has the different pattern .))
  3. Add the ability to delete something from a container.

Analysis

Our site menu is no longer hard coded. As you add Company resources to the SiteFolder, they will automatically appear in the menu.

All of the container views have templates with one or more <form> nodes in them. These let us quickly add a particular type of resource to a container. We don’t make these into a macro because the name on submit button guides us to which kind of thing to add.

We are using self-posting forms in the views. That is, the same view acts both as a GET and a POST handler. If you post data to the view, we create a resource then redirect back to the GET view, but with a message to be displayed.

We could have repeated a lot of the boilerplate on content creation in each view. That means a lot more tests to write. Instead, we made two “factory” functions. You pass in the class of the resource you want created. The factory returns the redirect information.

layouts.py gained some helper functions used to look up the hierarchy. (We put these in the layout because it is needed by the layout macros.) Walking up to find the site, or walking up to find which company you are in, are common operations.

Listing items in a container is a repetitive task, so we made a macro for it so we could use it in various templates.

Discussion

  • Getting close to having a framework
  • OTOH, this shows that you can just as well write your own framework. Remember, you only have to pay for what you eat!

Table Of Contents