Add an import wizard to an application

In this tutorial we will add an import wizard to the movie database application created in the Creating a Movie Database Application tutorial.

We assume Camelot is properly installed and the movie database application is working.

_static/controls/main_window.png

Introduction

Most applications need a way to import data. This data is often delivered in files generated by another application or company. To demonstrate this process we will build a wizard that allows the user to import cover images into the movie database. For each image the user selects, a new Movie will be created with the selected image as a cover image.

Create an action

All user interaction in Camelot is handled through Actions. For actions that run in the context of the application, we use the Application Actions. We first create a file importer.py in the same directory as application_admin.py.

In this file we create subclass of camelot.admin.action.Action which will be the entry point of the import wizard:

from camelot.admin.action import Action
from camelot.core.utils import ugettext_lazy as _

class ImportCovers( Action ):
    verbose_name = _('Import cover images')

    def model_run( self, model_context ):
        yield

So now we haven an ImportCovers action. Such an action has a verbose_name class attribute with the name of the action as shown to the user.

The most important method of the action is the model_run method, which will be triggered when the user clicks the action. This method should be a generator that yields an object whenever user interaction is required. Everything that happens inside the model_run method happens in a different thread than the GUI thread, so it will not block the GUI.

Add the action to the GUI

Now the user needs to be able to trigger the action. We edit the application_admin.py file and make sure the ImportCoversAction is imported.

        from camelot_example.importer import ImportCovers

Then we add an instance of the ImportCovers action to the sections defined in the get_sections method of the ApplicationAdmin:

                Section( _('Movies'),
                         self,
                         Icon('tango/22x22/mimetypes/x-office-presentation.png'),
                         items = [ Movie, 
                                   Tag, 
                                   VisitorReport, 
#                                   VisitorsPerDirector,
                                   ImportCovers() ]),

This will make sure the action pops up in the Movies section of the application.

_static/controls/navigation_pane.png

Select the files

To make the action do something useful, we will implement its model_run method. Inside the model_run method, we can yield various camelot.admin.action.base.ActionStep objects to the GUI. An ActionStep is a part of the action that requires user interaction (the user answering a question). The result of this interaction is returned by the yield statement.

To ask the user for a number of image files to import, we will pop up a file selection dialog inside the model_run method:

    def model_run( self, model_context ):
        from camelot.view.action_steps import ( SelectFile, 
                                                UpdateProgress, 
                                                Refresh,
                                                FlushSession )
        
        select_image_files = SelectFile( 'Image Files (*.png *.jpg);;All Files (*)' )
        select_image_files.single = False
        file_names = yield select_image_files
        file_count = len( file_names )

The yield statement returns a list of file names selected by the user.

_static/actionsteps/select_file.png

Create new movies

First make sure the Movie class has an camelot.types.Image field named cover which will store the image files.

    cover = Column( camelot.types.Image( upload_to = 'covers' ) )

Next we add to the model_run method the actual creation of new movies.

        import os
        from sqlalchemy import orm
        from camelot.core.orm import Session
        from camelot_example.model import Movie
              
        movie_mapper = orm.class_mapper( Movie )
        cover_property = movie_mapper.get_property( 'cover' )
        storage = cover_property.columns[0].type.storage
        session = Session()

        for i, file_name in enumerate(file_names):
            yield UpdateProgress( i, file_count )
            title = os.path.splitext( os.path.basename( file_name ) )[0]
            stored_file = storage.checkin( unicode( file_name ) )
            movie = Movie( title = unicode( title ) )
            movie.cover = stored_file
            
        yield FlushSession( session )

In this part of the code several things happen :

Store the images

In the first lines, we do some sqlalchemy magic to get access to the storage attribute of the cover field. This storage attribute is of type camelot.core.files.storage.Storage. The Storage represents the files managed by Camelot.

Create Movie objects

Then for each file, a new Movie object is created with as title the name of the file. For the cover attribute, the file is checked in into the Storage. This actually means the file is copied from its original directory to a directory managed by Camelot.

Write to the database

In the last line, the session is flushed and thus all changes are written to the database. The FlushSession action step flushes the session and propagetes the changes to the GUI.

Keep the user informed

For each movie imported, a camelot.view.action_steps.update_progress.UpdateProgress object is yield to the GUI to inform the user of the import progress. Each time such an object is yielded, the progress bar is updated.

_static/controls/progress_dialog.png

Refresh the GUI

The last step of the model_run method will be to refresh the GUI. So if the user has the Movies table open when importing, this table will show the newly created movies.

        yield Refresh()

Result

This is how the resulting importer.py file looks like :

from camelot.admin.action import Action
from camelot.core.utils import ugettext_lazy as _
from camelot.view.art import Icon

class ImportCovers( Action ):
    verbose_name = _('Import cover images')
    icon = Icon('tango/22x22/mimetypes/image-x-generic.png')
    
# begin select files
    def model_run( self, model_context ):
        from camelot.view.action_steps import ( SelectFile, 
                                                UpdateProgress, 
                                                Refresh,
                                                FlushSession )
        
        select_image_files = SelectFile( 'Image Files (*.png *.jpg);;All Files (*)' )
        select_image_files.single = False
        file_names = yield select_image_files
        file_count = len( file_names )
# end select files
# begin create movies
        import os
        from sqlalchemy import orm
        from camelot.core.orm import Session
        from camelot_example.model import Movie
              
        movie_mapper = orm.class_mapper( Movie )
        cover_property = movie_mapper.get_property( 'cover' )
        storage = cover_property.columns[0].type.storage
        session = Session()

        for i, file_name in enumerate(file_names):
            yield UpdateProgress( i, file_count )
            title = os.path.splitext( os.path.basename( file_name ) )[0]
            stored_file = storage.checkin( unicode( file_name ) )
            movie = Movie( title = unicode( title ) )
            movie.cover = stored_file
            
        yield FlushSession( session )
# end create movies
# begin refresh
        yield Refresh()
# end refresh

Unit tests

Once an action works, its important to keep it working as the development of the application continues. One of the advantages of working with generators for the user interaction, is that its easy to simulate the user interaction towards the model_run() method of the action. This is done by using the send() method of the generator that is returned when calling model_run() :

    def test_example_application_action( self ):
        from camelot_example.importer import ImportCovers
        from camelot_example.model import Movie
        # count the number of movies before the import
        movies = Movie.query.count()
        # create an import action
        action = ImportCovers()
        generator = action.model_run( None )
        select_file = generator.next()
        self.assertFalse( select_file.single )
        # pretend the user selected a file
        generator.send( [os.path.join( os.path.dirname(__file__), '..', 'camelot_example', 'media', 'covers', 'circus.png') ] )
        # continue the action till the end
        list( generator )
        # a movie should be inserted
        self.assertEqual( movies + 1, Movie.query.count() )

Conclusion

We went through the basics of the action framework Camelot :

  • Subclassing a camelot.admin.action.Action class
  • Implementing the model_run method
  • yield camelot.admin.action.base.ActionStep objects to interact with the user
  • Add the camelot.admin.action.base.Action object to a camelot.admin.section.Section in the side pane

More camelot.admin.action.base.ActionStep classes can be found in the camelot.view.action_steps module.