Attaching Files to a Song

Enable users to upload sheet music and other documents related to a song in the set list.

Attachments

It is now time to add file attachments to the songs in a set list. To do this, we will need to use some components from the Zope3 component architecture that are not included in our default Grok installation.

We will be using the zope.app.file component, which you will find more fully documented in the How-to titled: Handling file uploads with zope.app.file and zope.file

Adding Components

To add new components to our application we will add them to our project’s setup.py file. Edit ~/grok/virtualgrok/music/setup.py and add the line ‘zope.app.file’ to the “install_requires” property, so the file contents look like this:

from setuptools import setup, find_packages

version = '0.0'

setup(name='music',
      version=version,
      description="",
      long_description="""\
""",
      # Get strings from http://www.python.org/pypi?%3Aaction=list_classifiers
      classifiers=[],
      keywords="",
      author="",
      author_email="",
      url="",
      license="",
      package_dir={'': 'src'},
      packages=find_packages('src'),
      include_package_data=True,
      zip_safe=False,
      install_requires=['setuptools',
                        'grok',
                        'grokui.admin',
                        'z3c.testsetup',
                        'grokcore.startup',
                        # Add extra requirements here
                        'zope.app.file',
                        ],
      entry_points = """
      [console_scripts]
      music-debug = grokcore.startup:interactive_debug_prompt
      music-ctl = grokcore.startup:zdaemon_controller
      [paste.app_factory]
      main = grokcore.startup:application_factory
      """,
      )

We now need to run buildout again which will download the necessary software and configure it to be available in our project.

$ cd ~/grok/virtualgrok/music
$ ./bin/buildout

Adjusting the Model

We will start by editing song.py in the src directory to look like the following:

import grok
from zope import interface
from interfaces import ISong
from zope.app.form.browser.textwidgets import TextWidget

import zope.app.file
from zope.app.container.interfaces import INameChooser
from zope.app.container.contained import NameChooser
import urllib

class Song(grok.Container):
    interface.implements(ISong)
    title=u''
    key=u''
    arrangement=u''
    order = 0

    def __init__(self):
        super(Song, self).__init__()
        self['files'] = FileContainer()

class LongTextWidget(TextWidget):
    displayWidth = 50

class Index(grok.EditForm):
    grok.context(Song)
    form_fields = grok.AutoFields(Song)
    form_fields['title'].custom_widget = LongTextWidget
    form_fields['arrangement'].custom_widget = LongTextWidget

    def update(self):
        self.label = u'Edit Song for ' + self.context.__parent__.__parent__.__name__

    @grok.action('Save')
    def edit(self, **data):
        self.applyData(self.context, **data)
        self.redirect(self.url(self.context.__parent__.__parent__))

    def null_validator(self, action, data):
        return u''

    @grok.action('Cancel', validator=null_validator)
    def cancel(self, **data):
        self.redirect(self.url(self.context.__parent__.__parent__))

    @grok.action('Delete', validator=null_validator)
    def delete(self, **data):
        for file in list(self.context['files'].keys()):
            del self.context['files'][file]
        self.redirect(self.url(self.context.__parent__.__parent__))
        del self.context.__parent__[self.context.__name__]

class SetList(grok.Container):
    pass

class DeleteFile(grok.View):
    grok.context(Song)
    def render(self):
        filename = urllib.unquote(self.request.get('QUERY_STRING'))
        if filename and self.context['files'].has_key(filename):
            del self.context['files'][filename]
            self.redirect(self.url(self.context))

class FileContainer(grok.Container):
    pass

class AddFile(grok.AddForm):
    grok.context(Song)
    form_fields = grok.AutoFields(zope.app.file.interfaces.IFile).select('data')

    @grok.action('Upload')
    def add(self, data):
        if len(data) > 0:
            self.upload(data)
        self.redirect(self.url(self.context))

    def upload(self, data):
        fileupload = self.request['form.data']
        if fileupload and fileupload.filename:
            contenttype = fileupload.headers.get('Content-Type')
            file_ = zope.app.file.file.File(data, contenttype)
            # use the INameChooser registered for your file upload container
            filename = INameChooser(self.context['files']).chooseName(fileupload.filename, None)
            self.context['files'][filename] = file_

class PrimitiveFilenameChangingNameChooser(grok.Adapter, NameChooser):
    grok.context(Song)
    grok.implements(INameChooser)
    grok.adapts(FileContainer)

    def chooseName(self, name):
        if name.startswith('+'):
            name = 'plus-' + name[1:]
        if name.startswith('@'):
            name = 'at-' + name[1:]

As you can see we imported several new items for use in our code. The zope.app.file will provide our storage and NameChooser will assure that our file names do not collide with one another.

Similar to the Performance object, we have added a sub-container to the Song object. Instead of being a new grok.container, it will be a new instance of FileContainer. The delete action for a song has also been modified to remove any songs. (Apparently, the contents of the FileContainer are not cleaned up as automatically as a grok.container.) Failure to remove the files before deleting the songs will result in a error like:

TypeError: There isn't enough context to get URL information.
This is probably due to a bug in setting up location information.

Notice that we have also added a new “DeleteFile” view. This time, we will not be providing the name of the file to be deleted via a form submission, but rather through a query string. This requires us to properly encode the name of the file for use in an URL and to decode it for use in our application. For this we use the Python “urllib” library.

We will generate a grok.AddForm based on the schema provided by the IFile interface. This will include a text box with a button used to browse for a file, and a second button to upload the selected file.

The upload method will use the NameChooser to fix potentially problematic file names and also to modify the filename by adding a numeric suffix when a particular filename already exists for this song.

Updating the Edit Form

We can no longer use the standard, generated edit form if we want to be able to display a list of file attachments for a song. In the src/music directory, create a new directory called “song_templates” and add the following file named “index.pt” to the directory:

<html>
<head>
  <style type="text/css">
    table { empty-cells:show; }
  </style>
</head>
<body>
<form action="." tal:attributes="action request/URL" method="post"
      class="edit-form" enctype="multipart/form-data">

  <h1 i18n:translate=""
    tal:condition="view/label"
    tal:content="view/label">Label</h1>

  <div class="form-status"
    tal:define="status view/status"
    tal:condition="status">

    <div i18n:translate="" tal:content="view/status">
      Form status summary
    </div>

    <ul class="errors" tal:condition="view/errors">
      <li tal:repeat="error view/error_views">
         <span tal:replace="structure error">Error Type</span>
      </li>
    </ul>
  </div>

  <table class="form-fields">
    <tbody>
      <tal:block repeat="widget view/widgets">
        <tr>
          <td class="label" tal:define="hint widget/hint">
            <label tal:condition="python:hint"
                   tal:attributes="for widget/name">
              <span class="required" tal:condition="widget/required"
              >*</span><span i18n:translate=""
                             tal:content="widget/label">label</span>
            </label>
            <label tal:condition="python:not hint"
                   tal:attributes="for widget/name">
              <span class="required" tal:condition="widget/required"
              >*</span><span i18n:translate=""
                             tal:content="widget/label">label</span>
            </label>
          </td>
          <td class="field">
            <div class="widget" tal:content="structure widget">
              <input type="text" />
            </div>
            <div class="error" tal:condition="widget/error">
              <span tal:replace="structure widget/error">error</span>
            </div>
          </td>
        </tr>
      </tal:block>
    </tbody>
  </table>

  <div id="actionsView">
    <span class="actionButtons" tal:condition="view/availableActions">
      <input tal:repeat="action view/actions"
             tal:replace="structure action/render"
             />
    </span>
  </div>
 </form>
  <h2>File List</h2>
  <table class="listing" border="1" >
    <thead>
      <tr><td>File URL</td><td>File Type</td><td>Remove</td></tr>
    </thead>
    <tbody>
      <tal:block repeat="file python:context['files'].keys()">
        <tr>
          <td>
            <a tal:attributes="href python:view.url(context['files'][file])"
            tal:content="python:file">Title</a>
          </td>
          <td tal:content="python:context['files'][file].contentType">Arrangement</td>
          <td>
            <a tal:attributes="href python:view.url(context) + '/deletefile?' + file">Delete</a>
          </td>
         </tr>
      </tal:block>
    </tbody>
    <tfoot>
      <tr class="controls">
        <td colspan="4" class="align-right">
          <form action="./addfile" method="post" class="edit-form" enctype="multipart/form-data">
            <table class="form-fields">
              <tbody>
                <tr>
                  <td class="label">
                    <label for="form.data">
                      <span>New Attachment: </span>
                    </label>
                  </td>
                  <td class="field">
                    <div class="widget">
                      <input class="hiddenType" id="form.data.used" name="form.data.used" type="hidden" value="" />
                      <input class="fileType" id="form.data" name="form.data" size="20" type="file"  />
                      <input type="submit" id="form.actions.upload" name="form.actions.upload" value="Upload" class="button" /></div>
                  </td>
                </tr>
              </tbody>
            </table>
          </form>
        </td>
      </tr>
    </tfoot>
  </table>
</body>
</html>

The top portion is the standard edit form, but the bottom portion will render a table that contains the attached files. The “File URL” column will contain a link to download the stored file attachment.

The “Remove” column will construct an URL which will pass the name of a given file to the “DeleteFile” view in the query string.

Notice that in the footer of the table, instead of providing a link to the add form, we actually provide the form controls required to add a file attachment and we post the result to the AddFile auto-form view.

It is important that the control names in this template match those in the auto-form. If you have problems, you can view the generated add form directly, by appending “/addfile” to the URL of song and viewing the HTML source.

The AddFile auto-form will redirect back to the song view after it adds the file. So it will appear to the user like we never left the form. Not quite as seamless as using AJAX, but it is nicer than than bouncing between two different forms when adding several objects. (We will address the use of AJAX in a later tutorial installment.)

tutorials/musical_performance_organizer/AttachFiles.png

Updating the Performance View

To enhance the user experience further, it would be nice to allow the user to download attached files directly from the list of songs in the Performance view. Modify the set list table within the page template src/music/performance_templates/index.pt as follows:

<h2>Set List</h2>
<table class="listing" border="1" >
  <thead>
    <tr><td>#</td><td>Songs</td><td>Key</td><td>Arrangement</td><td>Files</td></tr>
  </thead>
  <tbody>
    <tal:block repeat="song python:sorted(context['setlist'].values(), key=lambda obj:obj.order)">
      <tr>
        <td tal:content="python:song.order">#</td>
        <td>
          <a tal:attributes="href python:view.url(song)"
          tal:content="python:song.title">Title</a>
        </td>
        <td tal:content="python:song.key">Key</td>
        <td tal:content="python:song.arrangement">Arrangement</td>
        <td>
          <tal:block repeat="file python:song['files'].keys()">
            <a tal:attributes="href python:view.url(song['files'][file])"
              tal:content="python:file">File</a><br />
          </tal:block>
        </td>
      </tr>
    </tal:block>
  </tbody>
  <tfoot>
    <tr class="controls">
      <td colspan="5" class="align-right">
        <a href="addsong">Add Song</a>
      </td>
    </tr>
  </tfoot>
</table>

We have added another repeat block in the right-most column of the table which will display a list of file attachment links within each table row. It will be unlikely to have more than one or two attachments per file.

tutorials/musical_performance_organizer/PerformanceWithAttachments.png