class QueryEntryGadget(object): """This gadget modifies a ProxyEntry to behave like a ProxyComboEntry. When instanciated, the gadget will remove the entry from the editor, add a Gtk.HBox on its place, and re-attach the entry to the newly created hbox. This hbox will also have a button to add/edit a new object. There are a few advantages in using this instead of a combo: - There is no need to prefill the combo with all the options, which can be very slow depending on the number of objects. - This allows the user to use a better search mechanism, allowing him to filter using multiple keywords and even candidade keys (like a client document) """ MIN_KEY_LENGTH = 1 LOADING_ITEMS_TEXT = _("Loading items...") NEW_ITEM_TEXT = _("Create a new item with that name") NEW_ITEM_TOOLTIP = _("Create a new item") EDIT_ITEM_TOOLTIP = _("Edit the selected item") INFO_ITEM_TOOLTIP = _("See info about the selected item") NO_ITEMS_FOUND_TEXT = _("No items found") advanced_search = True selection_only = False item_editor = None item_info_dialog = ClientEditor search_class = None search_spec = None search_columns = None order_by = None def __init__(self, entry, store, initial_value=None, parent=None, run_editor=None, edit_button=None, info_button=None, search_clause=None): """ :param entry: The entry that we should modify :param store: The store that will be used for database queries :param initial_value: Initial value for the entry :param parent: The parent that should be respected when running other dialogs """ super(QueryEntryGadget, self).__init__() self._parent = parent self._on_run_editor = run_editor self._can_edit = False self._search_clause = search_clause self.entry = entry self.entry.set_mode(ENTRY_MODE_DATA) self.edit_button = edit_button self.info_button = info_button self.store = store # The filter that will be used. This is not really in the interface. # We will just use it to perform the search. self._filter = StringSearchFilter('') self._executer = QueryExecuter(self.store) self._executer.set_search_spec(self.search_spec) self._executer.set_filter_columns(self._filter, self.search_columns) self._executer.set_order_by(self.order_by) self._last_operation = None self._source_id = None self._setup() self.set_value(initial_value, force=True) # # Public API # def set_value(self, obj, force=False): if not force and obj == self._current_obj: return obj = self.store.fetch(obj) if obj is not None: if hasattr(obj, 'description'): value = obj.description else: value = obj.get_description() self.entry.prefill([(value, obj)]) self.update_edit_button(Gtk.STOCK_EDIT, self.EDIT_ITEM_TOOLTIP) else: value = '' self.entry.prefill([]) self.update_edit_button(Gtk.STOCK_NEW, self.NEW_ITEM_TOOLTIP) self._current_obj = obj self.entry.update(obj) self.entry.set_text(value) self._update_widgets() def set_editable(self, can_edit): self.entry.set_property('editable', can_edit) self._update_widgets() def update_edit_button(self, stock, tooltip=None): image = Gtk.Image.new_from_stock(stock, Gtk.IconSize.MENU) self.edit_button.set_image(image) if tooltip is not None: self.edit_button.set_tooltip_text(tooltip) def get_object_from_item(self, item): return item def describe_item(self, item): raise NotImplementedError # # Private # def _setup(self): if not self.selection_only: if self.edit_button is None or self.info_button is None: self._replace_widget() if self.edit_button is None: self.edit_button = self._add_button(Gtk.STOCK_NEW) self.edit_button.connect('clicked', self._on_edit_button__clicked) if self.info_button is None: self.info_button = self._add_button(Gtk.STOCK_INFO) self.info_button.connect('clicked', self._on_info_button__clicked) self.entry.connect('activate', self._on_entry__activate) self.entry.connect('changed', self._on_entry__changed) self.entry.connect('notify::sensitive', self._on_entry_sensitive) self.entry.connect('key-press-event', self._on_entry__key_press_event) self._popup = _QueryEntryPopup(self, has_new_item=not self.selection_only) self._popup.connect('item-selected', self._on_popup__item_selected) self._popup.connect('create-item', self._on_popup__create_item) def _update_widgets(self): self._can_edit = self.entry.get_editable( ) and self.entry.get_sensitive() if self.edit_button is not None: self.edit_button.set_sensitive( bool(self.item_editor and (self._can_edit or self._current_obj))) if self.info_button is not None: self.info_button.set_sensitive( bool(self.item_info_dialog and self._current_obj)) def _add_button(self, stock): image = Gtk.Image.new_from_stock(stock, Gtk.IconSize.MENU) button = Gtk.Button() button.set_relief(Gtk.ReliefStyle.NONE) button.set_image(image) button.show() self.box.pack_start(button, False, False, 0) return button def _replace_widget(self): # This will remove the entry, add a hbox in the entry old position, and # reattach the entry to this box. The box will then be used to add two # new buttons (one for searching, other for editing/adding new objects container = self.entry.props.parent # stolen from gazpacho code (widgets/base/base.py): props = {} for pspec in container.__class__.list_child_properties(): props[pspec.name] = container.child_get_property( self.entry, pspec.name) self.box = Gtk.HBox() self.box.show() self.entry.reparent(self.box) container.add(self.box) for name, value in props.items(): container.child_set_property(self.box, name, value) def _find_items(self, text): self._filter.set_state(text) state = self._filter.get_state() resultset = self._executer.search([state]) if self._search_clause: resultset = resultset.find(self._search_clause) return self._executer.search_async(resultset=resultset, limit=10) def _dispatch(self, value): self._source_id = None if self._last_operation is not None: self._last_operation.cancel() self._last_operation = self._find_items(value) self._last_operation.connect( 'finish', lambda o: self._popup.add_items(o.get_result())) def _run_search(self): if not self.search_class: return if not self.advanced_search: return text = self.entry.get_text() item = run_dialog(self.search_class, self._parent, self.store, double_click_confirm=True, initial_string=text) if item: self.set_value(self.get_object_from_item(item)) def _run_editor(self, model=None, description=None): with api.new_store() as store: model = store.fetch(model) if self._on_run_editor is not None: retval = self._on_run_editor(store, model, description=description, visual_mode=not self._can_edit) else: if issubclass(self.item_editor, BasePersonRoleEditor): rd = run_person_role_dialog else: rd = run_dialog retval = rd(self.item_editor, self._parent, store, model, description=description, visual_mode=not self._can_edit) if store.committed: return self.store.fetch(retval) # # Callbacks # def _on_entry__key_press_event(self, window, event): keyval = event.keyval if keyval == Gdk.KEY_Up or keyval == Gdk.KEY_KP_Up: self._popup.scroll(relative=-1) return True elif keyval == Gdk.KEY_Down or keyval == Gdk.KEY_KP_Down: self._popup.scroll(relative=+1) return True elif keyval == Gdk.KEY_Page_Up: self._popup.scroll(relative=-14) return True elif keyval == Gdk.KEY_Page_Down: self._popup.scroll(relative=+14) return True elif keyval == Gdk.KEY_Escape: self._popup.popdown() return True return False def _on_entry__changed(self, entry): value = str(entry.get_text()) self.set_value(None) if len(value) >= self.MIN_KEY_LENGTH: if self._source_id is not None: GLib.source_remove(self._source_id) self._source_id = GLib.timeout_add(150, self._dispatch, value) if not self._popup.visible: self._popup.popup() self._popup.set_loading(True) elif self._popup.visible: # In this case, the user has deleted text to less than the # min key length, so pop it down if self._source_id is not None: GLib.source_remove(self._source_id) self._source_id = None self._popup.popdown() def _on_entry__activate(self, entry): if self._popup.visible: self._popup.popdown() self._popup.confirm() else: self._run_search() def _on_entry_sensitive(self, entry, pspec): self._update_widgets() def _on_popup__item_selected(self, popup, item, fallback_to_search): self.set_value(self.get_object_from_item(item)) popup.popdown() self.entry.grab_focus() GLib.idle_add(self.entry.select_region, len(self.entry.get_text()), -1) if item is None and fallback_to_search: self._run_search() def _on_popup__create_item(self, popup): obj = self._run_editor(description=str(self.entry.get_text())) self.set_value(obj) def _on_edit_button__clicked(self, entry): current_obj = self.entry.read() obj = self._run_editor(current_obj) if obj: self.set_value(obj, force=True) def _on_info_button__clicked(self, entry): obj = self.entry.read() with api.new_store() as store: run_dialog(self.item_info_dialog, self._parent, store, store.fetch(obj))
class QueryExecuterTest(DomainTest): def setUp(self): DomainTest.setUp(self) self.qe = QueryExecuter(self.store) self.qe.set_search_spec(ClientCategory) self.sfilter = mock.Mock() self.qe.set_filter_columns(self.sfilter, ['name']) def _search_async(self, states): op = self.qe.search_async(states) self.qe._operation_executer._queue.join() return list(op.get_result()) def _search_string_all(self, text): return self.qe.search([ StringQueryState(filter=self.sfilter, mode=StringQueryState.CONTAINS_ALL, text=text)]) def _search_string_all_async(self, text): return self._search_async([ StringQueryState(filter=self.sfilter, mode=StringQueryState.CONTAINS_ALL, text=text)]) def _search_string_exactly(self, text): return self.qe.search([ StringQueryState(filter=self.sfilter, mode=StringQueryState.CONTAINS_EXACTLY, text=text)]) def _search_string_exactly_async(self, text): return self._search_async([ StringQueryState(filter=self.sfilter, mode=StringQueryState.CONTAINS_EXACTLY, text=text)]) def _search_string_not(self, text): return self.qe.search([ StringQueryState(filter=self.sfilter, mode=StringQueryState.NOT_CONTAINS, text=text)]) def _search_string_not_async(self, text): return self._search_async([ StringQueryState(filter=self.sfilter, mode=StringQueryState.NOT_CONTAINS, text=text)]) def test_string_query(self): self.assertEqual(self.store.find(ClientCategory).count(), 0) self.create_client_category(u'EYE MOON FLARE 110 0.5') self.create_client_category(u'EYE MOON FLARE 120 1.0') self.create_client_category(u'EYE SUN FLARE 120 1.0') self.create_client_category(u'EYE SUN FLARE 110 1.0') self.create_client_category(u'EYE SUN STONE 120 0.5') self.assertEqual(self._search_string_all(u'eye flare 110').count(), 2) self.assertEqual(self._search_string_all(u'eye 0.5').count(), 2) self.assertEqual(self._search_string_all(u'eye 120').count(), 3) self.assertEqual(self._search_string_exactly(u'eye flare 110').count(), 0) self.assertEqual(self._search_string_exactly(u'eye 0.5').count(), 0) self.assertEqual(self._search_string_exactly(u'eye 120').count(), 0) self.assertEqual(self._search_string_not(u'stone 110').count(), 2) self.assertEqual(self._search_string_not(u'eye').count(), 0) self.assertEqual(self._search_string_not(u'moon 120').count(), 1) def test_search_async(self): self.assertEqual(self.store.find(ClientCategory).count(), 0) try: self.create_client_category(u'EYE MOON FLARE 110 0.5') self.create_client_category(u'EYE MOON FLARE 120 1.0') self.create_client_category(u'EYE SUN FLARE 120 1.0') self.create_client_category(u'EYE SUN FLARE 110 1.0') self.create_client_category(u'EYE SUN STONE 120 0.5') # search_async uses another connection. Because of that, we need to # commit the store or else it will not be able to find the objects self.store.commit() self.assertEqual( len(self._search_string_all_async(u'eye flare 110')), 2) self.assertEqual( len(self._search_string_all_async(u'eye 0.5')), 2) self.assertEqual( len(self._search_string_all_async(u'eye 120')), 3) self.assertEqual( len(self._search_string_exactly_async(u'eye flare 110')), 0) self.assertEqual( len(self._search_string_exactly_async(u'eye 0.5')), 0) self.assertEqual( len(self._search_string_exactly_async(u'eye 120')), 0) self.assertEqual( len(self._search_string_not_async(u'stone 110')), 2) self.assertEqual( len(self._search_string_not_async(u'eye')), 0) self.assertEqual( len(self._search_string_not_async(u'moon 120')), 1) finally: self.clean_domain([ClientCategory]) self.store.commit()