[POS-commit] r6396 - in kiwi/trunk/kiwi: . db ui

Johan Dahlin jdahlin at async.com.br
Thu Apr 19 18:44:33 BRT 2007


Author: jdahlin
Date: Thu Apr 19 18:44:33 2007
New Revision: 6396

Added:
   kiwi/trunk/kiwi/db/
   kiwi/trunk/kiwi/db/__init__.py
   kiwi/trunk/kiwi/db/query.py
   kiwi/trunk/kiwi/db/sqlobj.py
   kiwi/trunk/kiwi/ui/search.py
Modified:
   kiwi/trunk/kiwi/enums.py
   kiwi/trunk/kiwi/interfaces.py

Log:
    * README: Mention that SQLObject is an optional dependency

    * kiwi/db/__init__.py:
    * kiwi/db/query.py:
    * kiwi/db/sqlobj.py:
    * kiwi/enums.py:
    * kiwi/interfaces.py:
    * kiwi/ui/search.py:

    Add a SearchContainer plus database independent infrastructure to
    construct queries. Include a SQLObject plugin



Added: kiwi/trunk/kiwi/db/__init__.py
==============================================================================
--- (empty file)
+++ kiwi/trunk/kiwi/db/__init__.py	Thu Apr 19 18:44:33 2007
@@ -0,0 +1,22 @@
+#
+# Kiwi: a Framework and Enhanced Widgets for Python
+#
+# Copyright (C) 2007 Async Open Source
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307
+# USA
+#
+
+"""Database integration"""

Added: kiwi/trunk/kiwi/db/query.py
==============================================================================
--- (empty file)
+++ kiwi/trunk/kiwi/db/query.py	Thu Apr 19 18:44:33 2007
@@ -0,0 +1,98 @@
+#
+# Kiwi: a Framework and Enhanced Widgets for Python
+#
+# Copyright (C) 2007 Async Open Source
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307
+# USA
+#
+# Author(s): Johan Dahlin <jdahlin at async.com.br>
+#
+
+from kiwi.interfaces import ISearchFilter
+
+#
+# Query building
+#
+
+class QueryState(object):
+    def __init__(self, search_filter):
+        """
+        @param search_filter: search filter this query state is associated with
+        @type search_filter: L{SearchFilter}
+        """
+        self.filter = search_filter
+
+class NumberQueryState(QueryState):
+    """
+    @cvar value: number
+    """
+    def __init__(self, filter, value):
+        QueryState.__init__(self, filter)
+        self.value = value
+
+class StringQueryState(QueryState):
+    """
+    @cvar text: string
+    """
+    def __init__(self, filter, text):
+        QueryState.__init__(self, filter)
+        self.text = text
+
+class DateQueryState(QueryState):
+    """
+    @cvar date: date
+    """
+    def __init__(self, filter, date):
+        QueryState.__init__(self, filter)
+        self.date = date
+
+class DateIntervalQueryState(QueryState):
+    """
+    @cvar start: start of interval
+    @cvar end: end of interval
+    """
+    def __init__(self, filter, start, end):
+        QueryState.__init__(self, filter)
+        self.start = start
+        self.end = end
+
+class QueryExecuter(object):
+    def __init__(self):
+        self._columns = {}
+
+    #
+    # Public API
+    #
+
+    def set_filter_columns(self, search_filter, columns):
+        if not ISearchFilter.providedBy(search_filter):
+            raise TypeError("search_filter must implement ISearchFilter")
+
+        assert not search_filter in self._columns
+        self._columns[search_filter] = columns
+
+    #
+    # Overridable
+    #
+
+    def search(self, states):
+        """
+        @param states:
+        @type states: list of L{QueryStates}
+        @returns: list of objects matching query
+        """
+        raise NotImplementedError
+

Added: kiwi/trunk/kiwi/db/sqlobj.py
==============================================================================
--- (empty file)
+++ kiwi/trunk/kiwi/db/sqlobj.py	Thu Apr 19 18:44:33 2007
@@ -0,0 +1,170 @@
+# -*- coding: utf-8 -*-
+# vi:si:et:sw=4:sts=4:ts=4
+
+##
+## Copyright (C) 2007 Async Open Source
+##
+## This program is free software; you can redistribute it and/or
+## modify it under the terms of the GNU Lesser General Public License
+## as published by the Free Software Foundation; either version 2
+## of the License, or (at your option) any later version.
+##
+## This program is distributed in the hope that it will be useful,
+## but WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+## GNU Lesser General Public License for more details.
+##
+## You should have received a copy of the GNU Lesser General Public License
+## along with this program; if not, write to the Free Software
+## Foundation, Inc., or visit: http://www.gnu.org/.
+##
+##
+## Author(s):    Johan Dahlin            <jdahlin at async.com.br>
+##
+
+"""
+SQLObject integration for Kiwi
+"""
+
+from sqlobject.sqlbuilder import func, AND, OR, LIKE
+
+from kiwi.db.query import (NumberQueryState, StringQueryState,
+                           DateQueryState, DateIntervalQueryState,
+                           QueryExecuter)
+from kiwi.interfaces import ISearchFilter
+
+class SQLObjectQueryExecuter(QueryExecuter):
+    def __init__(self, conn):
+        QueryExecuter.__init__(self)
+        self.conn = conn
+        self.table = None
+        self._filter_query_callbacks = {}
+        self._query_callbacks = []
+        self._query = self._default_query
+
+    #
+    # Public API
+    #
+
+    def set_table(self, table):
+        self.table = table
+
+    def add_filter_query_callback(self, search_filter, callback):
+        """
+        @param search_filter:
+        @param callback:
+        """
+        if not ISearchFilter.providedBy(search_filter):
+            raise TypeError
+        if not callable(callback):
+            raise TypeError
+        l = self._filter_query_callbacks.setdefault(search_filter, [])
+        l.append(callback)
+
+    def add_query_callback(self, callback):
+        """
+        @param callback:
+        """
+        if not callable(callback):
+            raise TypeError
+        self._query_callbacks.append(callback)
+
+    def set_query(self, callback):
+        if callback is None:
+            callback = self._default_query
+        elif not callable(callback):
+            raise TypeError
+
+        self._query = callback
+
+    #
+    # QueryBuilder
+    #
+
+    def search(self, states):
+        """
+        @param states:
+        """
+        if self.table is None:
+            raise ValueError("table cannot be None")
+        table = self.table
+        queries = []
+        for state in states:
+            search_filter = state.filter
+            assert state.filter
+
+            # Column query
+            if search_filter in self._columns:
+                query = self._construct_state_query(
+                    table, state, self._columns[search_filter])
+                if query:
+                    queries.append(query)
+            # Custom per filter/state query.
+            elif search_filter in self._filter_query_callbacks:
+                for callback in self._filter_query_callbacks[search_filter]:
+                    query = callback(state)
+                    if query:
+                        queries.append(query)
+            elif self._query != self._default_query:
+                continue
+            else:
+                raise ValueError(
+                    "You need to add a search column or a query callback "
+                    "for filter %s" % (search_filter))
+
+        # Custom query
+        for callback in self._query_callbacks:
+            query = callback()
+            if query:
+                queries.append(query)
+
+        return self._query(AND(*queries), self.conn)
+
+    def _default_query(self, query, conn):
+        return self.table.select(query, connection=conn)
+
+    #
+    # Private
+    #
+
+    def _construct_state_query(self, table, state, columns):
+        queries = []
+        for column in columns:
+            query = None
+            table_field = getattr(table.q, column)
+            if isinstance(state, NumberQueryState):
+                query = self._parse_number_state(state, table_field)
+            elif isinstance(state, StringQueryState):
+                query = self._parse_string_state(state, table_field)
+            elif isinstance(state, DateQueryState):
+                query = self._parse_date_state(state, table_field)
+            elif isinstance(state, DateIntervalQueryState):
+                query = self._parse_date_interval_state(state, table_field)
+            else:
+                raise NotImplementedError(state.__class__.__name__)
+
+            if query:
+                queries.append(query)
+
+        return OR(*queries)
+
+    def _parse_number_state(self, state, table_field):
+        if state.value is not None:
+            return table_field == state.value
+
+    def _parse_string_state(self, state, table_field):
+        if state.text:
+            text = '%%%s%%' % state.text.lower()
+            return LIKE(func.LOWER(table_field), text)
+
+    def _parse_date_state(self, state, table_field):
+        if state.date:
+            return func.DATE(table_field) == func.DATE(state.date)
+
+    def _parse_date_interval_state(self, state, table_field):
+        queries = []
+        if state.start:
+            queries.append(table_field >= func.DATE(state.start))
+        if state.end:
+            queries.append(table_field <= func.DATE(state.end))
+        return AND(*queries)

Modified: kiwi/trunk/kiwi/enums.py
==============================================================================
--- kiwi/trunk/kiwi/enums.py	(original)
+++ kiwi/trunk/kiwi/enums.py	Thu Apr 19 18:44:33 2007
@@ -50,3 +50,15 @@
      READONLY,
      UNREMOVABLE,
      UNEDITABLE) = range(4)
+
+class SearchFilterPosition(enum):
+    """
+    An enum used to indicate where a search filter should be added to
+    a SearchContainer.
+
+    - TOP: top left corner
+    - BOTTOM: bottom
+    """
+    (TOP,
+     BOTTOM) = range(2)
+

Modified: kiwi/trunk/kiwi/interfaces.py
==============================================================================
--- kiwi/trunk/kiwi/interfaces.py	(original)
+++ kiwi/trunk/kiwi/interfaces.py	Thu Apr 19 18:44:33 2007
@@ -167,3 +167,22 @@
         """Connect the signals in the keys of dict with the objects in the
         values of dic
         """
+
+class ISearchFilter(Interface):
+
+    def get_widget():
+        """
+        @rtype: L{gtk.Widget} subclass
+        """
+
+    def get_state():
+        """
+        @rtype: L{QueryState}
+        """
+
+    def set_label(label):
+        """
+        @param label:
+        @type label: string
+        """
+

Added: kiwi/trunk/kiwi/ui/search.py
==============================================================================
--- (empty file)
+++ kiwi/trunk/kiwi/ui/search.py	Thu Apr 19 18:44:33 2007
@@ -0,0 +1,463 @@
+#
+# Kiwi: a Framework and Enhanced Widgets for Python
+#
+# Copyright (C) 2007 Async Open Source
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307
+# USA
+#
+# Author(s): Johan Dahlin <jdahlin at async.com.br>
+#
+
+"""
+Search related widgets
+"""
+
+import datetime
+import gettext
+
+import gtk
+
+from kiwi.component import implements
+from kiwi.db.query import (NumberQueryState, StringQueryState,
+                           DateQueryState, DateIntervalQueryState,
+                           QueryExecuter)
+from kiwi.enums import SearchFilterPosition
+from kiwi.interfaces import ISearchFilter
+from kiwi.python import enum
+from kiwi.ui.dateentry import DateEntry
+from kiwi.ui.delegates import SlaveDelegate
+from kiwi.ui.objectlist import ObjectList
+from kiwi.ui.widgets.combo import ProxyComboBox
+
+_ = lambda m: gettext.dgettext('kiwi', m)
+
+class DateSearchOption(object):
+    """
+    Base class for Date search options
+    A date search option is an interval of dates
+    @cvar name: name of the search option
+    """
+    name = None
+
+    def get_interval(self):
+        """
+        @returns: start date, end date
+        @rtype: datetime.date tuple
+        """
+
+class Any(DateSearchOption):
+    name = _('Any')
+
+    def get_interval(self):
+        return None, None
+
+
+class Today(DateSearchOption):
+    name = _('Today')
+
+    def get_interval(self):
+        today = datetime.date.today()
+        return today, today
+
+
+class Yesterday(DateSearchOption):
+    name = _('Yesterday')
+
+    def get_interval(self):
+        yesterday = datetime.date.today() - datetime.timedelta(days=1)
+        return yesterday, yesterday
+
+
+class LastWeek(DateSearchOption):
+    name = _('Last week')
+
+    def get_interval(self):
+        today = datetime.date.today()
+        return (today - datetime.timedelta(days=7), today)
+
+
+class LastMonth(DateSearchOption):
+    name = _('Last month')
+
+    def get_interval(self):
+        today = datetime.date.today()
+        year = today.year
+        month = today.month - 1
+        if not month:
+            month = 12
+            year -= 1
+        # Try 31 first then remove one until date() does not complain.
+        day = today.day
+        while True:
+            try:
+                start_date = datetime.date(year, month, day)
+                break
+            except ValueError:
+                day -= 1
+        return start_date, datetime.date.today()
+
+
+class DateSearchFilter(object):
+    """
+    A filter which helps you to search by a date interval.
+    Can be customized through add_option.
+    """
+    implements(ISearchFilter)
+    class Type(enum):
+        (CUSTOM_DAY,
+         CUSTOM_INTERVAL) = range(100, 102)
+
+    def __init__(self, name):
+        self._options = {}
+        hbox = gtk.HBox()
+        hbox.set_border_width(6)
+        label = gtk.Label(name)
+        hbox.pack_start(label, False, False)
+        label.show()
+
+        self.mode = ProxyComboBox()
+        self.mode.connect('content-changed',
+                          self._on_mode__content_changed)
+        hbox.pack_start(self.mode, False, False, 6)
+        self.mode.show()
+
+        self.from_label = gtk.Label(_("From:"))
+        hbox.pack_start(self.from_label, False, False)
+        self.from_label.show()
+
+        self.start_date = DateEntry()
+        self.start_date.connect('changed',
+                                self._on_start_date__changed)
+        hbox.pack_start(self.start_date, False, False, 6)
+        self.start_date.show()
+
+        self.to_label = gtk.Label(_("To:"))
+        hbox.pack_start(self.to_label, False, False)
+        self.to_label.show()
+
+        self.end_date = DateEntry()
+        hbox.pack_start(self.end_date, False, False, 6)
+        self.end_date.show()
+
+        self.hbox = hbox
+
+        self.mode.prefill([
+            (_('Custom day'), DateSearchFilter.Type.CUSTOM_DAY),
+            (_('Custom interval'), DateSearchFilter.Type.CUSTOM_INTERVAL),
+            ])
+
+        for option in (Any, Today, Yesterday, LastWeek, LastMonth):
+            self.add_option(option)
+
+        self.mode.select_item_by_position(0)
+
+    def add_option(self, option_type):
+        """
+        Adds a date option
+        @param option_type: option to add
+        @type option_type: a L{DateSearchOption} subclass
+        """
+        option = option_type()
+        num = len(self.mode)-2
+        self.mode.insert_item(num, option.name, num)
+        self._options[num] = option
+
+    def get_widget(self):
+        return self.hbox
+
+    def get_state(self):
+        start = self.start_date.get_date()
+        end = self.end_date.get_date()
+        if start == end:
+            return DateQueryState(filter=self, date=start)
+        return DateIntervalQueryState(filter=self, start=start, end=end)
+
+    #
+    # Private
+    #
+
+    def _update_dates(self):
+        date_type = self.mode.get_selected_data()
+        if date_type not in [DateSearchFilter.Type.CUSTOM_DAY,
+                             DateSearchFilter.Type.CUSTOM_INTERVAL]:
+            option = self._options.get(date_type)
+            start_date, end_date = option.get_interval()
+            self.start_date.set_date(start_date)
+            self.end_date.set_date(end_date)
+
+    def _update_sensitivity(self):
+        date_type = self.mode.get_selected_data()
+        enabled = date_type == DateSearchFilter.Type.CUSTOM_INTERVAL
+        self.to_label.set_sensitive(enabled)
+        self.end_date.set_sensitive(enabled)
+
+        enabled = (date_type == DateSearchFilter.Type.CUSTOM_INTERVAL or
+                   date_type == DateSearchFilter.Type.CUSTOM_DAY)
+        self.from_label.set_sensitive(enabled)
+        self.start_date.set_sensitive(enabled)
+
+    #
+    # Callbacks
+    #
+
+    def _on_mode__content_changed(self, mode):
+        self._update_dates()
+        self._update_sensitivity()
+
+    def _on_start_date__changed(self, start_date):
+        date_type = self.mode.get_selected_data()
+        if date_type == DateSearchFilter.Type.CUSTOM_DAY:
+            self.end_date.set_date(start_date.get_date())
+
+
+class ComboSearchFilter(object):
+    implements(ISearchFilter)
+    def __init__(self, name, values):
+        hbox = gtk.HBox()
+        label = gtk.Label(name)
+        hbox.pack_start(label, False, False)
+        label.show()
+
+        self.combo = ProxyComboBox()
+        self.combo.prefill(values)
+        hbox.pack_start(self.combo, False, False, 6)
+        self.combo.show()
+
+        self.hbox = hbox
+
+    def get_widget(self):
+        return self.hbox
+
+    def get_state(self):
+        return NumberQueryState(filter=self,
+                                value=self.combo.get_selected_data())
+
+
+class StringSearchFilter(object):
+    implements(ISearchFilter)
+    def __init__(self, name, chars=0):
+        hbox = gtk.HBox()
+        self.label = gtk.Label(name)
+        hbox.pack_start(self.label, False, False)
+        self.label.show()
+
+        self.entry = gtk.Entry()
+        if chars:
+            self.entry.set_width_chars(chars)
+        hbox.pack_start(self.entry, False, False, 6)
+        self.entry.show()
+
+        self.hbox = hbox
+
+    def get_widget(self):
+        return self.hbox
+
+    def get_state(self):
+        return StringQueryState(filter=self,
+                                text=self.entry.get_text())
+
+    def set_label(self, label):
+        self.label.set_text(label)
+
+
+#
+# Other UI pieces
+#
+
+class SearchResults(ObjectList):
+    def __init__(self, columns):
+        ObjectList.__init__(self, columns)
+
+class SearchContainer(gtk.VBox):
+    """
+    A search container is a widget which consists of:
+    - search entry
+    - search button
+    - objectlist result
+
+    Additionally you can add a number of search filters to the SearchContainer.
+    You can chose if you want to add the filter in the top-left corner
+    of bottom, see L{SearchFilterPosition}
+    """
+    def __init__(self, columns=None, chars=25):
+        """
+        @param columns: a list of L{kiwi.ui.objectlist.Column}
+        @param chars: maximum number of chars used by the search entry
+        """
+        gtk.VBox.__init__(self)
+        self._columns = columns
+        self._search_filters = []
+        self._querty_executer = None
+
+        search_filter = StringSearchFilter(_('Search:'), chars=chars)
+        self._search_filters.append(search_filter)
+        self._primary_filter = search_filter
+
+        self._create_ui()
+
+
+    #
+    # Public API
+    #
+
+    def add_filter(self, search_filter, position=SearchFilterPosition.BOTTOM):
+        """
+        Adds a search filter
+        @param search_filter: the search filter
+        @param postition: a L{SearchFilterPosition} enum
+        """
+        if not ISearchFilter.providedBy(search_filter):
+            raise TypeError("search_filter must implement ISearchFilter")
+
+        widget = search_filter.get_widget()
+        assert not widget.parent
+        if position == SearchFilterPosition.TOP:
+            self.hbox.pack_start(widget, False, False)
+            self.hbox.reorder_child(widget, 0)
+        elif position == SearchFilterPosition.BOTTOM:
+            self.pack_start(widget, False, False)
+        widget.show()
+
+        self._search_filters.append(search_filter)
+
+    def set_query_executer(self, querty_executer):
+        """
+        Ties a QueryExecuter instance to the SearchContainer class
+        @param querty_executer: a querty executer
+        @type querty_executer: a L{QueryExecuter} subclass
+        """
+        if not isinstance(querty_executer, QueryExecuter):
+            raise TypeError("querty_executer must be a QueryExecuter instance")
+
+        self._query_executer = querty_executer
+
+    def get_primary_filter(self):
+        """
+        Fetches the primary filter for the SearchContainer.
+        The primary filter is the filter attached to the standard entry
+        normally used to do free text searching
+        @returns: the primary filter
+        """
+        return self._primary_filter
+
+    def search(self):
+        """
+        Starts a search.
+        Fetches the states of all filters and send it to a query executer and
+        finally puts the result in the result class
+        """
+        if not self._query_executer:
+            raise ValueError("A query executer needs to be set at this point")
+        states = [(sf.get_state()) for sf in self._search_filters]
+        results = self._query_executer.search(states)
+        self.results.clear()
+        self.results.extend(results)
+
+    #
+    # Callbacks
+    #
+
+    def _on_search_entry_activate(self, entry):
+        self.search()
+
+    def _on_search_button__clicked(self, button):
+        self.search()
+
+    #
+    # Private
+    #
+
+    def _create_ui(self):
+        hbox = gtk.HBox()
+        self.pack_start(hbox, False, False)
+        hbox.show()
+        self.hbox = hbox
+
+        widget = self._primary_filter.get_widget()
+        self.hbox.pack_start(widget, False, False)
+        widget.show()
+
+        self.search_entry = self._primary_filter.entry
+        self.search_entry.connect('activate', self._on_search_entry_activate)
+
+        button = gtk.Button(stock=gtk.STOCK_FIND)
+        button.connect('clicked', self._on_search_button__clicked)
+        hbox.pack_start(button, False, False)
+        button.show()
+
+        self.results = SearchResults(self._columns)
+        self.pack_end(self.results)
+        self.results.show()
+
+class SearchSlaveDelegate(SlaveDelegate):
+    def __init__(self, columns):
+        self.search = SearchContainer(columns)
+        SlaveDelegate.__init__(self, toplevel=self.search)
+
+    #
+    # Public API
+    #
+
+    def add_filter(self, search_filter, position=SearchFilterPosition.BOTTOM):
+        """
+        Adds a search filter
+        @param search_filter: the search filter
+        @param postition: a L{SearchFilterPosition} enum
+        """
+        self.search.add_filter(search_filter, position)
+
+    def set_query_executer(self, querty_executer):
+        """
+        Ties a QueryExecuter instance to the SearchSlaveDelegate class
+        @param querty_executer: a querty executer
+        @type querty_executer: a L{QueryExecuter} subclass
+        """
+        if not isinstance(querty_executer, QueryExecuter):
+            raise TypeError("querty_executer must be a QueryExecuter instance")
+
+        self.search.set_query_executer(querty_executer)
+
+
+    def get_primary_filter(self):
+        """
+        Fetches the primary filter of the SearchSlaveDelegate
+        @returns: primary filter
+        """
+        return self.search.get_primary_filter()
+
+    def focus_search_entry(self):
+        """
+        Grabs the focus of the search entry
+        """
+        self.search.search_entry.grab_focus()
+
+    def search(self):
+        """
+        Trigger a search again with the currently selected inputs
+        """
+        self.search.search()
+
+    #
+    # Overridable
+    #
+
+    def get_columns(self):
+        """
+        This needs to be implemented in a subclass
+        @returns: columns
+        @rtype: list of L{kiwi.ui.objectlist.Column}
+        """
+        raise NotImplementedError


More information about the POS-commit mailing list