Foreword

ModelViewPresenter is a derivative of the ModelViewController Pattern. Its aim is to provide a cleaner implementation of the Observer connection between Application Model and view. For more information about this pattern check http://c2.com/cgi/wiki?ModelViewPresenter and http://www.martinfowler.com/eaaDev/ModelViewPresenter.html. The original Taligent article is still available at http://www.oodesign.com.br/forum/index.php?act=Attach&type=post&id=74. There is also a paper titled "Presenter First" at http://atomicobject.com/media/files/PresenterFirst.pdf, which presents a pattern and process for test-driven development (TDD) of GUI apps structured with MVP. (Caveat: I've only briefly scanned the paper, and don't know whether it will warrant the link on closer inspection. -- Don Dwiggins)

Most of the following information is an adaptation of Martin Fowler's MVP page to wxPython. A lot of the text is just Copy&Paste.

Introduction

Model View Presenter separates the behavior of the presentation out into a separate presenter class. Any user events are forwarded to the presenter and the presenter manipulates the state of the view. As well as separating the responsibilities, this also allows the behavior to be tested without the UI, and for different UI controls to be used with the same basic behavior.

How It Works

The heart of Model View Presenter is to pull all the behavior of the presentation out of view and place it in a separate presenter class. The resulting view will by very dumb - little more than a holder for the gui controls themselves. In this way the separation is very much the same as the classic separation of Model View Controller.

The difference between MVC and MVP is that in MVP the presenter does not handles the GUI events directly as the controller does in MVC but through delegation via the interactor. This in my view allows easier testing of the presenter.

Example: Album Window

Here is an example:

mvp.png

The code for model is just a simple data object.

   1 class Album(object):
   2     def __init__(self, artist, title, isClassical=False, composer=None):
   3         self.artist = artist
   4         self.title = title
   5         self.isClassical = isClassical
   6         self.composer = composer

You start the application by creating a presenter.

   1 class AlbumPresenter(object):
   2     def __init__(self, albums, view, interactor):
   3         self.albums = albums
   4         self.view = view
   5         interactor.Install(self, view)
   6         self.isListening = True
   7         self.initView()
   8         view.start()

The View is built inside a wx.Frame

   1 class AlbumWindow(wx.Frame):
   2     def __init__(self):
   3         self.app = wx.App(0)
   4         wx.Frame.__init__(self, None)
   5         self.SetBackgroundColour("lightgray")
   6         ...

Before instantiating the Frame we create the wx.App object used by our application and when the view starts we start the Main Loop. In real life you could create the layout using something like wxGlade and inherit one of the generated classes.

The presenter is responsible for putting all the data into the window. It does this by pulling data out of the domain class and pushing the data into the window via the view interface.

   1 class AlbumPresenter(object):
   2     ...
   3     def loadViewFromModel(self):
   4         if self.isListening:
   5             self.isListening = False
   6             self.refreshAlbumList()
   7             self.view.setTitle(self.selectedAlbum.title)
   8             self.updateWindowTitle()
   9             self.view.setArtist(self.selectedAlbum.artist)
  10             self.view.setClassical(self.selectedAlbum.isClassical)
  11             if self.selectedAlbum.isClassical:
  12                 self.view.setComposer(self.selectedAlbum.composer)
  13             else:
  14                 self.view.setComposer("")
  15             self.view.setComposerEnabled(self.selectedAlbum.isClassical)
  16             self.enableApplyAndCancel(False)
  17             self.isListening = True
  18             
  19     def refreshAlbumList(self):
  20         currentAlbum = self.view.getSelectedAlbum()
  21         self.view.setAlbums(self.albums)
  22         self.view.setSelectedAlbum(currentAlbum)
  23         self.selectedAlbum = self.albums[currentAlbum]
  24         
  25     def updateWindowTitle(self):
  26         self.view.setWindowTitle("Album: " + self.view.getTitle())
  27         
  28     def enableApplyAndCancel(self, enabled):
  29         self.view.setApplyEnabled(enabled)
  30         self.view.setCancelEnabled(enabled)
  31     ...

The loadViewFromModel method updates all the items of view. Some of these updates cause events to fire which would cause a recursive trigerring of the update method, so I use a guard around the update method to prevent the recursion.

The methods in the view allow access to the underlying controls via the fields.

   1 class AlbumWindow(wx.Frame):
   2     ...
   3     def setClassical(self, isClassical):
   4         self.classical.SetValue(isClassical)
   5     def isClassical(self):
   6         return self.classical.GetValue()

Alternatively the view could be implemented using property objects and use simple assignment/access instead of set/get methods.

   1 class AlbumWindow(wx.Frame):
   2     ...
   3     def _setTitle(self, title):
   4         self._title.SetValue(title)
   5     def _getTitle(self):
   6         return self._title.GetValue()
   7     title = property(_getTitle, _setTitle)

then

   1 class AlbumPresenter(object):
   2     ...
   3     def loadViewFromModel(self):
   4         if self.isListening:
   5             ...
   6             self.view.title = self.selectedAlbum.title
   7     ...
   8     def updateWindowTitle(self):
   9         self.view.setWindowTitle("Album: " + self.view.title)

The interactor object is installed from the presenter and it installs all the needed event handlers delegating the event to the presenter.

   1 class AlbumInteractor(object):
   2     def Install(self, presenter, view):
   3         self.presenter = presenter
   4         self.view = view
   5         view.albums.Bind(wx.EVT_LISTBOX, self.OnReloadNeeded)
   6         view.title.Bind(wx.EVT_TEXT, self.OnDataFieldUpdated)
   7         view.artist.Bind(wx.EVT_TEXT, self.OnDataFieldUpdated)
   8         view.composer.Bind(wx.EVT_TEXT, self.OnDataFieldUpdated)
   9         view.classical.Bind(wx.EVT_CHECKBOX, self.OnDataFieldUpdated)
  10         view.apply.Bind(wx.EVT_BUTTON, self.OnApply)
  11         view.cancel.Bind(wx.EVT_BUTTON, self.OnReloadNeeded)
  12         
  13     def OnApply(self, evt):
  14         self.presenter.updateModel()
  15         
  16     def OnReloadNeeded(self, evt):
  17         self.presenter.loadViewFromModel()
  18         
  19     def OnDataFieldUpdated(self, evt):
  20         self.presenter.dataFieldUpdated()

You save data to the model when the user hits the apply button.

   1     def updateModel(self):
   2         self.selectedAlbum.title = self.view.getTitle()
   3         self.selectedAlbum.artist = self.view.getArtist()
   4         self.selectedAlbum.isClassical = self.view.isClassical()
   5         if self.view.isClassical:
   6             self.selectedAlbum.composer = self.view.getComposer()
   7         else:
   8             self.selectedAlbum.composer = None
   9         self.enableApplyAndCancel(False)
  10         self.loadViewFromModel()

To test if apply works you can use the unittest module and mock objects for view and interactor

   1 class TestAlbumPresenter(unittest.TestCase):
   2     ...
   3     def testApplySavesDataToModel(self):
   4         view = mock_objects.MockAlbumWindow();
   5         model = [models.Album(*data) for data in self.someAlbums]
   6         interactor = mock_objects.MockAlbumInteractor()
   7         presenter = presenters.AlbumPresenter(model, view, interactor);
   8         newTitle = "Some Other Title"
   9         view.title = newTitle
  10         presenter.updateModel()
  11         assert view.title == newTitle

Complete implementation together with mock objects and tests: mvp.zip To start the application use albums.pyw

Expanding applications with MVP architecture

Let's say that the next step would be to add the capability to add new albums and to provide a way to sort the albums either ascending or descending. The application will look like this:

mvp2.png

The best order to expand the example is to start in the test module and first update the tests, next the presenter, the mock views, then when the functionality is done move to the real view, add the necessary bits and then alter the interactor to connect the view with the presenter. For expediency reasons will do it first the View next the Presenter and last the Interactor.

First let's add the visual bits: In the AlbumWindow we add 2 buttons, place them beneath the albums list and provide access to the order button label.

   1 class AlbumWindow(wx.Frame):
   2     def __init__(self):
   3         ...
   4         self.add = wx.Button(self, label="New Album")
   5         self.order = wx.Button(self, label="A->Z")
   6 
   7         leftSizer = wx.GridBagSizer(5,5)
   8         leftSizer.Add(self.albums, (0,0), (1,2),flag=wx.EXPAND)
   9         leftSizer.Add(self.add, (1,0), (1,1),flag=wx.EXPAND)
  10         leftSizer.Add(self.order, (1,1), (1,1),flag=wx.EXPAND)
  11         ...
  12         mainSizer.Add(leftSizer, 0, wx.EXPAND|wx.ALL, 5)
  13     ...
  14     def setOrderLabel(self, label):
  15         self.order.SetLabel(label)

Next we add the functionality in the presenter. First we add a flag to hold the order:

   1 class AlbumPresenter(object):
   2     def __init__(self, albums, view, interactor):
   3         ...
   4         self.reverse = False
   5         self.albums.sort(key=lambda x: x.title)

Next we fix the refreshAlbumList method to take into account that the list must be sorted according to our selected order.

   1 class AlbumPresenter(object):
   2     def refreshAlbumList(self):
   3         currentAlbum = self.view.getSelectedAlbum()
   4         self.selectedAlbum = self.albums[currentAlbum]
   5         self.albums.sort(key=lambda x: x.title, reverse=self.reverse)
   6         self.view.setAlbums(self.albums)
   7         self.view.setSelectedAlbum(self.albums.index(self.selectedAlbum))        

Provide a way to toggle the order and a way to add a new album

   1 class AlbumPresenter(object):
   2     def toggleOrder(self):
   3         self.reverse = not self.reverse
   4         self.loadViewFromModel()
   5         
   6     def addNewAlbum(self):
   7         newAlbum = models.Album("Unknown Artist", "New Album Title")
   8         self.albums.append(newAlbum)
   9         self.view.setAlbums(self.albums)
  10         self.view.setSelectedAlbum(self.albums.index(newAlbum))
  11         self.loadViewFromModel()

Now all that remains to be done to update the AlbumInteractor to provide the connection between button presses and our new functionality:

   1 class AlbumInteractor(object):
   2     def Install(self, presenter, view):
   3         ...
   4         view.add.Bind(wx.EVT_BUTTON, self.OnAddNewAlbum)
   5         view.order.Bind(wx.EVT_BUTTON, self.OnToggleOrder)
   6         
   7     def OnAddNewAlbum(self, evt):
   8         self.presenter.addNewAlbum()
   9         
  10     def OnToggleOrder(self, evt):
  11         self.presenter.toggleOrder()

All Done! New version of the source code: mvp2.zip

Remember! When writing production code start in the unittests, this way you'll be able to catch logic related errors early on, before they are altered by the view part.

ModelViewPresenter (last edited 2009-11-25 07:00:09 by vpn-8061f451)