root/trunk/camelot/view/controls/tableview.py @ 1164

Revision 1164, 26.2 KB (checked in by erikj, 6 months ago)

start implementation of sortable collection proxy

Line 
1#  ============================================================================
2#
3#  Copyright (C) 2007-2008 Conceptive Engineering bvba. All rights reserved.
4#  www.conceptive.be / project-camelot@conceptive.be
5#
6#  This file is part of the Camelot Library.
7#
8#  This file may be used under the terms of the GNU General Public
9#  License version 2.0 as published by the Free Software Foundation
10#  and appearing in the file LICENSE.GPL included in the packaging of
11#  this file.  Please review the following information to ensure GNU
12#  General Public Licensing requirements will be met:
13#  http://www.trolltech.com/products/qt/opensource.html
14#
15#  If you are unsure which license is appropriate for your use, please
16#  review the following information:
17#  http://www.trolltech.com/products/qt/licensing.html or contact
18#  project-camelot@conceptive.be.
19#
20#  This file is provided AS IS with NO WARRANTY OF ANY KIND, INCLUDING THE
21#  WARRANTY OF DESIGN, MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.
22#
23#  For use of this library in commercial applications, please contact
24#  project-camelot@conceptive.be
25#
26#  ============================================================================
27
28"""Tableview"""
29
30import logging
31logger = logging.getLogger( 'camelot.view.controls.tableview' )
32
33from PyQt4 import QtCore, QtGui
34from PyQt4.QtGui import QSizePolicy
35from PyQt4.QtCore import SIGNAL
36from PyQt4.QtCore import Qt
37
38from camelot.view.proxy.queryproxy import QueryTableProxy
39from camelot.view.controls.view import AbstractView
40from camelot.view.controls.user_translatable_label import UserTranslatableLabel
41from camelot.view.model_thread import model_function, gui_function, post
42from camelot.core.utils import ugettext as _
43
44from search import SimpleSearchControl
45
46class TableWidget( QtGui.QTableView):
47    """A widget displaying a table, to be used within a TableView"""
48 
49    def __init__( self, parent = None ):
50        QtGui.QTableView.__init__( self, parent )
51        logger.debug( 'create TableWidget' )
52        self.setSelectionBehavior( QtGui.QAbstractItemView.SelectRows )
53        self.setEditTriggers( QtGui.QAbstractItemView.SelectedClicked | QtGui.QAbstractItemView.DoubleClicked )
54        self.setSizePolicy( QSizePolicy.Expanding, QSizePolicy.Expanding )
55        # set to false while sorting is not implemented in CollectionProxy
56        self.horizontalHeader().setClickable( True )
57        self._header_font_required = QtGui.QApplication.font()
58        self._header_font_required.setBold( True )
59        self._minimal_row_height = QtGui.QFontMetrics(QtGui.QApplication.font()).lineSpacing() + 10
60        self.verticalHeader().setDefaultSectionSize( self._minimal_row_height )
61        self.connect( self.horizontalHeader(), QtCore.SIGNAL('sectionClicked(int)'), self.horizontal_section_clicked )
62   
63    def horizontal_section_clicked( self, logical_index ):
64        """Update the sorting of the model and the header"""
65        header = self.horizontalHeader()
66        order = Qt.AscendingOrder
67        if not header.isSortIndicatorShown():
68            header.setSortIndicatorShown( True )
69        elif header.sortIndicatorSection()==logical_index:
70            # apparently, the sort order on the header is allready switched when the section
71            # was clicked, so there is no need to reverse it
72            order = header.sortIndicatorOrder()
73        header.setSortIndicator( logical_index, order )
74        self.model().sort( logical_index, order )
75       
76    def setModel( self, model ):
77        QtGui.QTableView.setModel( self, model )
78        self.connect( self.selectionModel(), SIGNAL( 'currentChanged(const QModelIndex&,const QModelIndex&)' ), self.activated )
79   
80    def activated( self, selectedIndex, previousSelectedIndex ):
81        option = QtGui.QStyleOptionViewItem()
82        newSize = self.itemDelegate( selectedIndex ).sizeHint( option, selectedIndex )
83        row = selectedIndex.row()
84        if previousSelectedIndex.row() >= 0:
85            oldSize = self.itemDelegate( previousSelectedIndex ).sizeHint( option, selectedIndex )
86            previousRow = previousSelectedIndex.row()
87            self.setRowHeight( previousRow, oldSize.height() )
88        self.setRowHeight( row, newSize.height() )
89   
90class RowsWidget( QtGui.QLabel ):
91    """Widget that is part of the header widget, displaying the number of rows
92    in the table view"""
93 
94    _number_of_rows_font = QtGui.QApplication.font()
95 
96    def __init__( self, parent ):
97        QtGui.QLabel.__init__( self, parent )
98        self.setFont( self._number_of_rows_font )
99   
100    def setNumberOfRows( self, rows ):
101        self.setText( _('(%i rows)')%rows )
102   
103class HeaderWidget( QtGui.QWidget ):
104    """HeaderWidget for a tableview, containing the title, the search widget,
105    and the number of rows in the table"""
106 
107    search_widget = SimpleSearchControl
108    rows_widget = RowsWidget
109 
110    _title_font = QtGui.QApplication.font()
111    _title_font.setBold( True )
112 
113    def __init__( self, parent, admin ):
114        QtGui.QWidget.__init__( self, parent )
115        widget_layout = QtGui.QHBoxLayout()
116        self.search = self.search_widget( self )
117        title = UserTranslatableLabel( admin.get_verbose_name_plural(), self )
118        title.setFont( self._title_font )
119        widget_layout.addWidget( title )
120        widget_layout.addWidget( self.search )
121        if self.rows_widget:
122            self.number_of_rows = self.rows_widget( self )
123            widget_layout.addWidget( self.number_of_rows )
124     
125        else:
126            self.number_of_rows = None
127        self.setLayout( widget_layout )
128        self.setSizePolicy( QSizePolicy.Minimum, QSizePolicy.Fixed )
129        self.setNumberOfRows( 0 )
130   
131    @gui_function
132    def setNumberOfRows( self, rows ):
133        if self.number_of_rows:
134            self.number_of_rows.setNumberOfRows( rows )
135     
136class TableView( AbstractView  ):
137    """A generic tableview widget that puts together some other widgets.  The behaviour of this class and
138  the resulting interface can be tuned by specifying specific class attributes which define the underlying
139  widgets used ::
140 
141    class MovieRentalTableView(TableView):
142      title_format = 'Grand overview of recent movie rentals'
143 
144  The attributes that can be specified are :
145 
146  .. attribute:: header_widget
147 
148  The widget class to be used as a header in the table view::
149   
150    header_widget = HeaderWidget
151   
152  .. attribute:: table_widget
153 
154  The widget class used to display a table within the table view ::
155 
156  table_widget = TableWidget
157 
158  .. attribute:: title_format
159 
160  A string used to format the title of the view ::
161 
162  title_format = '%(verbose_name_plural)s'
163 
164  .. attribute:: table_model
165 
166  A class implementing QAbstractTableModel that will be used as a model for the table view ::
167 
168    table_model = QueryTableProxy
169 
170  - emits the row_selected signal when a row has been selected
171  """
172 
173    header_widget = HeaderWidget
174    table_widget = TableWidget
175 
176    #
177    # The proxy class to use
178    #
179    table_model = QueryTableProxy
180    #
181    # Format to use as the window title
182    #
183    title_format = '%(verbose_name_plural)s'
184 
185    def __init__( self, admin, search_text = None, parent = None ):
186        AbstractView.__init__( self, parent )
187        self.admin = admin
188        post( self.get_title, self.change_title )
189        widget_layout = QtGui.QVBoxLayout()
190        if self.header_widget:
191            self.header = self.header_widget( self, admin )
192            widget_layout.addWidget( self.header )
193            self.connect( self.header.search, SIGNAL( 'search' ), self.startSearch )
194            self.connect( self.header.search, SIGNAL( 'cancel' ), self.cancelSearch )
195            if search_text:
196                self.header.search.search( search_text )
197        else:
198            self.header = None
199        widget_layout.setSpacing( 0 )
200        widget_layout.setMargin( 0 )
201        self.splitter = QtGui.QSplitter( self )
202        widget_layout.addWidget( self.splitter )
203        table_widget = QtGui.QWidget( self )
204        filters_widget = QtGui.QWidget( self )
205        self.table_layout = QtGui.QVBoxLayout()
206        self.table_layout.setSpacing( 0 )
207        self.table_layout.setMargin( 0 )
208        self.table = None
209        self.filters_layout = QtGui.QVBoxLayout()
210        self.filters_layout.setSpacing( 0 )
211        self.filters_layout.setMargin( 0 )     
212        self.filters = None
213        self.actions = None
214        self._table_model = None
215        table_widget.setLayout( self.table_layout )
216        filters_widget.setLayout( self.filters_layout )
217        #filters_widget.hide()
218        self.set_admin( admin )
219        self.splitter.addWidget( table_widget )
220        self.splitter.addWidget( filters_widget )
221        self.setLayout( widget_layout )
222        self.closeAfterValidation = QtCore.SIGNAL( 'closeAfterValidation()' )
223        self.search_filter = lambda q: q
224        shortcut = QtGui.QShortcut(QtGui.QKeySequence(QtGui.QKeySequence.Find), self)
225        self.connect( shortcut, QtCore.SIGNAL( 'activated()' ), self.activate_search )
226        # give the table widget focus to prevent the header and its search control to
227        # receive default focus, as this would prevent the displaying of 'Search...' in the
228        # search control, but this conflicts with the MDI, resulting in the window not
229        # being active and the menus not to work properly
230        #table_widget.setFocus( QtCore.Qt.OtherFocusReason )
231        #self.setFocusProxy(table_widget)
232        #self.setFocus( QtCore.Qt.OtherFocusReason )
233        post( self.admin.get_subclass_tree, self.setSubclassTree )
234   
235    def activate_search(self):
236        self.header.search.setFocus(QtCore.Qt.ShortcutFocusReason)
237       
238    @model_function
239    def get_title( self ):
240        return self.title_format % {'verbose_name_plural':self.admin.get_verbose_name_plural()}
241   
242    @gui_function
243    def setSubclassTree( self, subclasses ):
244        if len( subclasses ) > 0:
245            from inheritance import SubclassTree
246            class_tree = SubclassTree( self.admin, self.splitter )
247            self.splitter.insertWidget( 0, class_tree )
248            self.connect( class_tree, SIGNAL( 'subclassClicked' ), self.set_admin )
249     
250    def sectionClicked( self, section ):
251        """emits a row_selected signal"""
252        self.emit( SIGNAL( 'row_selected' ), section )
253   
254    def copy_selected_rows( self ):
255        """Copy the selected rows in this tableview"""
256        logger.debug( 'delete selected rows called' )
257        if self.table and self._table_model:
258            for row in set( map( lambda x: x.row(), self.table.selectedIndexes() ) ):
259                self._table_model.copy_row( row )
260           
261    def create_table_model( self, admin ):
262        """Create a table model for the given admin interface"""
263        return self.table_model( admin,
264                                 admin.get_query,
265                                 admin.get_columns )
266   
267    @gui_function
268    def set_admin( self, admin ):
269        """Switch to a different subclass, where admin is the admin object of the
270        subclass"""
271        logger.debug('set_admin called')
272        self.admin = admin
273        if self.table:
274            self.disconnect(self._table_model, QtCore.SIGNAL( 'layoutChanged()' ), self.tableLayoutChanged )
275            self.table_layout.removeWidget(self.table)
276            self.table.deleteLater()
277            self._table_model.deleteLater()
278        self.table = self.table_widget( self.splitter )
279        self._table_model = self.create_table_model( admin )
280        self.table.setModel( self._table_model )
281        self.connect( self.table.verticalHeader(),
282                      SIGNAL( 'sectionClicked(int)' ),
283                      self.sectionClicked )
284        self.connect( self._table_model, QtCore.SIGNAL( 'layoutChanged()' ), self.tableLayoutChanged )
285        self.tableLayoutChanged()
286        self.table_layout.insertWidget( 1, self.table )
287   
288        def get_filters_and_actions():
289            return ( admin.get_filters(), admin.get_list_actions() )
290     
291        post( get_filters_and_actions,  self.set_filters_and_actions )
292        post( admin.get_list_charts, self.setCharts )
293   
294    @gui_function
295    def tableLayoutChanged( self ):
296        logger.debug('tableLayoutChanged')
297        if self.header:
298            self.header.setNumberOfRows( self._table_model.rowCount() )
299        item_delegate = self._table_model.getItemDelegate()
300        if item_delegate:
301            self.table.setItemDelegate( item_delegate )
302        for i in range( self._table_model.columnCount() ):
303            self.table.setColumnWidth( i, max( self._table_model.headerData( i, Qt.Horizontal, Qt.SizeHintRole ).toSize().width(),
304                                               self.table.columnWidth( i ) ) )
305     
306    @gui_function
307    def setCharts( self, charts ):
308        """creates and display charts"""
309        pass
310#    if charts:
311#
312#      from matplotlib.figure import Figure
313#      from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg as \
314#                                                     FigureCanvas
315#
316#      chart = charts[0]
317#
318#      def getData():
319#        """fetches data for chart"""
320#        from sqlalchemy.sql import select, func
321#        from elixir import session
322#        xcol = getattr( self.admin.entity, chart['x'] )
323#        ycol = getattr( self.admin.entity, chart['y'] )
324#        session.bind = self.admin.entity.table.metadata.bind
325#        result = session.execute( select( [xcol, func.sum( ycol )] ).group_by( xcol ) )
326#        summary = result.fetchall()
327#        return [s[0] for s in summary], [s[1] for s in summary]
328#
329#      class MyMplCanvas( FigureCanvas ):
330#        """Ultimately, this is a QWidget (as well as a FigureCanvasAgg)"""
331#
332#        def __init__( self, parent = None, width = 5, height = 4, dpi = 100 ):
333#          fig = Figure( figsize = ( width, height ), dpi = dpi, facecolor = 'w' )
334#          self.axes = fig.add_subplot( 111, axisbg = 'w' )
335#          # We want the axes cleared every time plot() is called
336#          self.axes.hold( False )
337#          self.compute_initial_figure()
338#          FigureCanvas.__init__( self, fig )
339#          self.setParent( parent )
340#          FigureCanvas.setSizePolicy( self,
341#                                     QSizePolicy.Expanding,
342#                                     QSizePolicy.Expanding )
343#          FigureCanvas.updateGeometry( self )
344#
345#
346#        def compute_initial_figure( self ):
347#          pass
348#
349#      def setData( data ):
350#        """set chart data"""
351#
352#        class MyStaticMplCanvas( MyMplCanvas ):
353#          """simple canvas with a sine plot"""
354#
355#          def compute_initial_figure( self ):
356#            """computes initial figure"""
357#            x, y = data
358#            bar_positions = [i - 0.25 for i in range( 1, len( x ) + 1 )]
359#            width = 0.5
360#            self.axes.bar( bar_positions, y, width, color = 'b' )
361#            self.axes.set_xlabel( 'Year' )
362#            self.axes.set_ylabel( 'Sales' )
363#            self.axes.set_xticks( range( len( x ) + 1 ) )
364#            self.axes.set_xticklabels( [''] + [str( d ) for d in x] )
365#
366#        sc = MyStaticMplCanvas( self, width = 5, height = 4, dpi = 100 )
367#        self.table_layout.addWidget( sc )
368#
369#      self.admin.mt.post( getData, setData )
370
371    def deleteSelectedRows( self ):
372        """delete the selected rows in this tableview"""
373        logger.debug( 'delete selected rows called' )
374        for row in set( map( lambda x: x.row(), self.table.selectedIndexes() ) ):
375            self._table_model.removeRow( row )
376     
377    @gui_function
378    def newRow( self ):
379        """Create a new row in the tableview"""
380        from camelot.view.workspace import get_workspace
381        workspace = get_workspace()
382        form = self.admin.create_new_view( workspace,
383                                           oncreate = lambda o:self._table_model.insertEntityInstance( 0, o ),
384                                           onexpunge = lambda o:self._table_model.removeEntityInstance( o ) )
385        workspace.addSubWindow( form )
386        form.show()
387   
388
389   
390#    @gui_function
391#    def set_filters_and_actions( self, filters_and_actions ):
392#        """sets filters for the tableview"""
393#        filters, actions = filters_and_actions
394#        from filterlist import FilterList
395#        from actionsbox import ActionsBox
396#        logger.debug( 'setting filters for tableview' )
397#        if self.filters:
398#          self.disconnect( self.filters, SIGNAL( 'filters_changed' ), self.rebuildQuery )
399#          self.filters.deleteLater()
400#          self.filters = None
401#        if self.actions:
402#          self.actions.deleteLater()
403#          self.actions = None         
404#        if filters:
405#          self.filters = FilterList( filters, parent=self )
406#          self.splitter.insertWidget( 2, self.filters )
407#          self.connect( self.filters, SIGNAL( 'filters_changed' ), self.rebuildQuery )
408#        if actions:
409#          self.actions = ActionsBox( self, self._table_model.collection_getter, lambda:[] )
410#          self.actions.setActions( actions )
411#          self.splitter.insertWidget( 2, self.filters )
412
413    def toHtml( self ):
414        """generates html of the table"""
415        table = [[getattr( row, col[0] ) for col in self.admin.getColumns()]
416                 for row in self.admin.entity.query.all()]
417        context = {
418          'title': self.admin.get_verbose_name_plural(),
419          'table': table,
420          'columns': [c[0] for c in self.admin.getColumns()],
421        }
422        from camelot.view.templates import loader
423        from jinja import Environment, FileSystemLoader
424        env = Environment( loader = loader )
425        tp = env.get_template( 'table_view.html' )
426        return tp.render( context )
427
428    def closeEvent( self, event ):
429        """reimplements close event"""
430        logger.debug( 'tableview closed' )
431        # remove all references we hold, to enable proper garbage collection
432        del self.table_layout
433        del self.table
434        del self.filters
435        del self._table_model
436        event.accept()
437   
438    def importWizard(self, attributes):
439        from camelot.view.wizard.import_data import ImportWizard
440        #object_attributes = ['title', 'releasedate', 'name', 'description' ]
441        object_attributes = self.admin.entity().Admin.form_display.get_fields()
442        importWizard = ImportWizard( self, object_attributes )
443        importWizard.start()
444        data = importWizard.getImportedData()   
445   
446    def selectTableRow( self, row ):
447        """selects the specified row"""
448        self.table.selectRow( row )
449   
450    def makeImport():
451        pass
452#        for row in data:
453#            o = self.admin.entity()
454#            #For example, setattr(x, 'foobar', 123) is equivalent to x.foobar = 123
455#            # if you want to import all attributes, you must link them to other objects
456#            #for example: a movie has a director, this isn't a primitive like a string
457#            # but a object fetched from the db
458#            setattr(o, object_attributes[0], row[0])
459#            name = row[2].split( ' ' ) #director
460#            o.short_description = "korte beschrijving"
461#            o.genre = ""
462#            from sqlalchemy.orm.session import Session
463#            Session.object_session(o).flush([o])
464   
465    post( makeImport )
466   
467    def selectedTableIndexes( self ):
468        """returns a list of selected rows indexes"""
469        return self.table.selectedIndexes()
470   
471    def getColumns( self ):
472        """return the columns to be displayed in the table view"""
473        return self.admin.get_columns()
474   
475    def getData( self ):
476        """generator for data queried by table model"""
477        for d in self._table_model.getData():
478            yield d
479     
480    def getTitle( self ):
481        """return the name of the entity managed by the admin attribute"""
482        return self.admin.get_verbose_name()
483   
484    def viewFirst( self ):
485        """selects first row"""
486        self.selectTableRow( 0 )
487   
488    def viewLast( self ):
489        """selects last row"""
490        self.selectTableRow( self._table_model.rowCount() - 1 )
491   
492    def viewNext( self ):
493        """selects next row"""
494        first = self.selectedTableIndexes()[0]
495        next = ( first.row() + 1 ) % self._table_model.rowCount()
496        self.selectTableRow( next )
497   
498    def viewPrevious( self ):
499        """selects previous row"""
500        first = self.selectedTableIndexes()[0]
501        prev = ( first.row() - 1 ) % self._table_model.rowCount()
502        self.selectTableRow( prev )
503   
504    def rebuildQuery( self ):
505        """resets the table model query"""
506   
507        def rebuild_query():
508            query = self.admin.entity.query
509            if self.filters:
510                query = self.filters.decorate_query( query )
511            if self.search_filter:
512                query = self.search_filter( query )
513            query_getter = lambda:query
514            return query_getter
515     
516        post( rebuild_query, self._table_model.setQuery )
517   
518    def startSearch( self, text ):
519        """rebuilds query based on filtering text"""
520        from camelot.view.search import create_entity_search_query_decorator
521        logger.debug( 'search %s' % text )
522        self.search_filter = create_entity_search_query_decorator( self.admin, text )
523        self.rebuildQuery()
524   
525    def cancelSearch( self ):
526        """resets search filtering to default"""
527        logger.debug( 'cancel search' )
528        self.search_filter = lambda q: q
529        self.rebuildQuery()
530       
531    @gui_function
532    def set_filters_and_actions( self, filters_and_actions ):
533        """sets filters for the tableview"""
534        filters, actions = filters_and_actions
535        from filterlist import FilterList
536        from actionsbox import ActionsBox
537        logger.debug( 'setting filters for tableview' )
538       
539        if self.filters:
540            self.disconnect( self.filters, SIGNAL( 'filters_changed' ), self.rebuildQuery )
541            self.filters_layout.removeWidget(self.filters)
542            self.filters.deleteLater()
543            self.filters = None
544        if self.actions:
545            self.filters_layout.removeWidget(self.actions)
546            self.actions.deleteLater()
547            self.actions = None           
548        if filters:
549            self.filters = FilterList( filters, parent=self.splitter )
550            self.filters_layout.addWidget( self.filters )
551            self.connect( self.filters, SIGNAL( 'filters_changed' ), self.rebuildQuery )
552        if actions:
553           
554            def selection_getter():
555                selection = []
556                for row in set( map( lambda x: x.row(), self.table.selectedIndexes() ) ):
557                    selection.append( self._table_model._get_object(row) )
558                return selection
559           
560            self.actions = ActionsBox( self,
561                                       self._table_model.get_collection_getter(),
562                                       selection_getter )
563           
564            self.actions.setActions( actions )
565            self.filters_layout.addWidget( self.actions )
566     
567    def toHtml( self ):
568        """generates html of the table"""
569        table = [[getattr( row, col[0] ) for col in self.admin.get_columns()]
570                 for row in self.admin.entity.query.all()]
571        context = {
572          'title': self.admin.get_verbose_name_plural(),
573          'table': table,
574          'columns': [field_attributes['name'] for _field, field_attributes in self.admin.get_columns()],
575        }
576        from camelot.view.templates import loader
577        from jinja import Environment
578        env = Environment( loader = loader )
579        tp = env.get_template( 'table_view.html' )
580        return tp.render( context )
581   
582    def closeEvent( self, event ):
583        """reimplements close event"""
584        logger.debug( 'tableview closed' )
585        # remove all references we hold, to enable proper garbage collection
586        del self.table_layout
587        del self.table
588        del self.filters
589        del self._table_model
590        event.accept()
591   
592    def importWizard(self, attributes):
593        from camelot.view.wizard.import_data import ImportWizard
594        object_attributes = ['title', 'releasedate', 'name', 'description' ]
595        importWizard = ImportWizard( self, object_attributes )
596        importWizard.start()
597        data = importWizard.getImportedData()   
598        def makeImport():
599            for row in data:
600                # get all possible fields (=attributes) from this object
601                #attributes = o.Admin.form_display.get_fields()
602                #object_attributes = ['title', 'releasedate', 'name', 'description' ]           
603                #print attributes
604                # set title
605                o = self.admin.entity()
606                #For example, setattr(x, 'foobar', 123) is equivalent to x.foobar = 123
607                setattr(o, object_attributes[0], row[0])
608                #movie.title = row[0]
609                name = row[2].split( ' ' ) #director
610                #director = Person()
611                #director.first_name = name[0] 
612                #director.last_name = name[1]
613                #movie.director = director
614                o.short_description = "korte beschrijving"
615                #date = row[1].split('/') # date 12/03/2009
616                #o.releasedate = datetime.date(year=int(date[2]), month=int(date[1]), day=int(date[0]))
617                o.genre = ""
618                #print o[attributes[0]]
619                from sqlalchemy.orm.session import Session
620                Session.object_session(o).flush([o])
621   
622        post( makeImport )
623           
624    def importFromFile( self ):
625        """"import data : the data will be imported in the activeMdiChild """
626        logger.info( 'call import method' )
627        from camelot.view.wizard.importwizard import ImportWizard
628        wizard = ImportWizard(self, self.admin)
629        wizard.exec_()
Note: See TracBrowser for help on using the browser.