# -*- coding: utf-8 -*-
# vi:si:et:sw=4:sts=4:ts=4
##
## Copyright (C) 2013 Async Open Source <http://www.async.com.br>
## All rights reserved
##
## 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): Stoq Team <stoq-devel@async.com.br>
##
##
import gtk
import glib
from kiwi.ui.cellrenderer import ComboDetailsCellRenderer
from kiwi.ui.entry import ENTRY_MODE_DATA
from kiwi.ui.popup import PopupWindow
from kiwi.utils import gsignal
from stoqlib.api import api
from stoqlib.database.queryexecuter import QueryExecuter
from stoqlib.domain.person import Client, ClientView, Supplier, SupplierView
from stoqlib.domain.sale import SaleToken, SaleTokenView
from stoqlib.gui.base.dialogs import run_dialog
from stoqlib.gui.dialogs.clientdetails import ClientDetailsDialog
from stoqlib.gui.dialogs.supplierdetails import SupplierDetailsDialog
from stoqlib.gui.editors.personeditor import ClientEditor, SupplierEditor
from stoqlib.gui.search.personsearch import ClientSearch, SupplierSearch
from stoqlib.gui.search.searchfilters import StringSearchFilter
from stoqlib.gui.templates.persontemplate import BasePersonRoleEditor
from stoqlib.gui.wizards.personwizard import run_person_role_dialog
from stoqlib.lib.formatters import format_address
from stoqlib.lib.translation import stoqlib_gettext as _
_NEW_ITEM_MARKER = object()
_LOADING_ITEM_MARKER = object()
_NO_ITENS_MARKER = object()
(COL_ITEM,
COL_MARKUP,
COL_TOOLTIP,
COL_SPINNER_ACTIVE,
COL_SPINNER_PULSE) = range(5)
class _QueryEntryPopup(PopupWindow):
gsignal('item-selected', object, bool)
gsignal('create-item')
PROPAGATE_KEY_PRESS = True
GRAB_WINDOW = False
def __init__(self, entry_gadget, has_new_item=True):
self._has_new_item = has_new_item
self.loading = False
self.entry_gadget = entry_gadget
super(_QueryEntryPopup, self).__init__(entry_gadget.entry)
#
# Public API
#
def set_loading(self, loading):
if loading == self.loading:
return
self.loading = loading
self._model.clear()
if loading:
self._treeview.insert_column(self._spinner_column, 0)
self._model.append(
(_LOADING_ITEM_MARKER, self.entry_gadget.LOADING_ITEMS_TEXT,
None, True, 0))
glib.timeout_add(100, self._pulse_spinner_col)
else:
self._treeview.remove_column(self._spinner_column)
def add_items(self, items):
self.set_loading(False)
self._model.clear()
for item in items:
label, tooltip = self.entry_gadget.describe_item(item)
self._model.append((item, label, tooltip, False, 0))
if len(self._model):
self._selection.select_path(self._model[0].path)
if self._has_new_item:
self._model.append(
(_NEW_ITEM_MARKER, self.entry_gadget.NEW_ITEM_TEXT,
None, False, 0))
elif not len(self._model):
self._model.append(
(_NO_ITENS_MARKER, self.entry_gadget.NO_ITEMS_FOUND_TEXT,
None, False, 0))
glib.idle_add(self._resize)
def scroll(self, relative=None, absolute=None):
model, titer = self._selection.get_selected()
if titer is None:
row_no = 0
elif relative is not None:
row_no = model[titer].path[0] + relative
elif absolute is not None:
row_no = absolute
else:
raise TypeError("needs relative or absolute")
if row_no < 0:
path = (0, )
elif row_no >= len(model):
path = (len(model) - 1, )
else:
path = (row_no, )
titer = model[path].iter
self._selection.select_iter(titer)
self._treeview.scroll_to_cell(path, None, False, 0, 0)
#
# EntryPopup
#
def confirm(self, fallback_to_search=False):
self._activate_selected_item(fallback_to_search=fallback_to_search)
def handle_key_press_event(self, event):
keyval = event.keyval
# By default the PopupWindow will call confirm for both Return and
# KP_Enter, but also for Tab and Space. We want to fallback to search
# in those specific cases
if keyval in [gtk.keysyms.Return, gtk.keysyms.KP_Enter]:
self.confirm(fallback_to_search=True)
return True
return super(_QueryEntryPopup, self).handle_key_press_event(event)
def get_main_widget(self):
vbox = gtk.VBox()
self._sw = gtk.ScrolledWindow()
self._sw.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_NEVER)
vbox.pack_start(self._sw)
self._model = gtk.ListStore(object, str, str, bool, int)
self._treeview = gtk.TreeView(self._model)
self._treeview.connect('motion-notify-event',
self._on_treeview__motion_notify_event)
self._treeview.connect('button-release-event',
self._on_treeview__button_release_event)
self._treeview.add_events(
gtk.gdk.BUTTON_PRESS_MASK | gtk.gdk.KEY_PRESS_MASK)
self._treeview.modify_base(gtk.STATE_ACTIVE,
self._get_selection_color())
self._treeview.set_tooltip_column(COL_TOOLTIP)
self._treeview.set_enable_search(False)
self._selection = self._treeview.get_selection()
self._selection.set_mode(gtk.SELECTION_BROWSE)
self._spinner_renderer = gtk.CellRendererSpinner()
self._spinner_column = gtk.TreeViewColumn(
'', self._spinner_renderer,
active=COL_SPINNER_ACTIVE, pulse=COL_SPINNER_PULSE)
self._renderer = ComboDetailsCellRenderer(use_markup=True)
self._treeview.append_column(
gtk.TreeViewColumn('', self._renderer, label=COL_MARKUP))
self._treeview.set_headers_visible(False)
self._sw.add(self._treeview)
vbox.show_all()
return vbox
def get_size(self, allocation, monitor):
self._treeview.realize()
width = allocation.width
cells_height = sum(
self._treeview.get_background_area(
path, self._treeview.get_column(0)).height
for path in xrange(len(self._treeview.get_model())))
# Use half of the available screen space
height = min(cells_height, monitor.height / 2)
height += self.FRAME_PADDING[0] + self.FRAME_PADDING[1]
hscroll = self._sw.get_hscrollbar()
if hscroll is not None and hscroll.get_visible():
hscroll_allocation = hscroll.get_allocation()
height += hscroll_allocation.height
return width, height
def popup(self):
self.set_loading(True)
super(_QueryEntryPopup, self).popup()
self._treeview.set_size_request(-1, -1)
self.widget.grab_focus()
self.widget.select_region(len(self.widget.get_text()), -1)
def popdown(self):
super(_QueryEntryPopup, self).popdown()
self.set_loading(False)
#
# Private
#
def _resize(self):
widget = self.get_widget_for_popup()
allocation = widget.get_allocation()
screen = widget.get_screen()
window = widget.get_window()
# FIXME: window will be None on a test, but it is hard to tell which
# one since it breaks one randomly because of the idle_add.
if window is not None:
monitor_num = screen.get_monitor_at_window(widget.get_window())
else:
monitor_num = 0
monitor = screen.get_monitor_geometry(monitor_num)
self.set_size_request(*self.get_size(allocation, monitor))
self._treeview.set_size_request(-1, -1)
def _pulse_spinner_col(self):
for item in self._model:
if not item[COL_SPINNER_ACTIVE]:
continue
item[COL_SPINNER_PULSE] += 1
return self.loading
def _select_item(self, item, fallback_to_search=False):
if item in [_LOADING_ITEM_MARKER, _NO_ITENS_MARKER]:
pass
elif item is _NEW_ITEM_MARKER:
self.popdown()
self.emit('create-item')
else:
self.emit('item-selected', item, fallback_to_search)
def _get_selection_color(self):
settings = gtk.settings_get_default()
for line in settings.props.gtk_color_scheme.split('\n'):
if not line:
continue
key, value = line.split(' ')
if key == 'selected_bg_color:':
return gtk.gdk.color_parse(value)
return self.widget.style.base[gtk.STATE_SELECTED]
def _select_path_for_event(self, event):
path = self._treeview.get_path_at_pos(int(event.x), int(event.y))
if not path:
return
path, column, x, y = path
self._selection.select_path(path)
self._treeview.set_cursor(path)
def _activate_selected_item(self, fallback_to_search=False):
model, treeiter = self._selection.get_selected()
self._select_item(treeiter and model[treeiter][COL_ITEM],
fallback_to_search=fallback_to_search)
#
# Callbacks
#
def _on_treeview__motion_notify_event(self, treeview, event):
self._select_path_for_event(event)
def _on_treeview__button_release_event(self, treeview, event):
self._select_path_for_event(event)
self._activate_selected_item()
[docs]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
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._last_operation = None
self._source_id = None
self._setup()
self.set_value(initial_value, force=True)
#
# Public API
#
[docs] 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()
[docs] def set_editable(self, can_edit):
self.entry.set_property('editable', can_edit)
self._update_widgets()
[docs] def update_edit_button(self, stock, tooltip=None):
if self.edit_button is None:
return
image = gtk.image_new_from_stock(stock, gtk.ICON_SIZE_MENU)
self.edit_button.set_image(image)
if tooltip is not None:
self.edit_button.set_tooltip_text(tooltip)
[docs] def get_object_from_item(self, item):
return item
[docs] 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._can_edit or self._current_obj))
if self.info_button is not None:
self.info_button.set_sensitive(bool(self._current_obj))
def _add_button(self, stock):
image = gtk.image_new_from_stock(stock, gtk.ICON_SIZE_MENU)
button = gtk.Button()
button.set_relief(gtk.RELIEF_NONE)
button.set_image(image)
button.show()
self.box.pack_start(button, False, False)
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.parent
# stolen from gazpacho code (widgets/base/base.py):
props = {}
for pspec in gtk.container_class_list_child_properties(container):
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.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 == gtk.keysyms.Up or keyval == gtk.keysyms.KP_Up:
self._popup.scroll(relative=-1)
return True
elif keyval == gtk.keysyms.Down or keyval == gtk.keysyms.KP_Down:
self._popup.scroll(relative=+1)
return True
elif keyval == gtk.keysyms.Page_Up:
self._popup.scroll(relative=-14)
return True
elif keyval == gtk.keysyms.Page_Down:
self._popup.scroll(relative=+14)
return True
elif keyval == gtk.keysyms.Escape:
self._popup.popdown()
return True
return False
def _on_entry__changed(self, entry):
value = unicode(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=unicode(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))
[docs]class PersonEntryGadget(QueryEntryGadget):
person_type = None
def __init__(self, entry, store, initial_value=None,
parent=None, run_editor=None,
edit_button=None, info_button=None):
country = api.sysparam.get_string('COUNTRY_SUGGESTED')
self._company_l10n = api.get_l10n_field('company_document', country)
self._person_l10n = api.get_l10n_field('person_document', country)
super(PersonEntryGadget, self).__init__(
entry, store, initial_value=initial_value,
parent=parent, run_editor=run_editor,
edit_button=edit_button, info_button=info_button)
#
# QueryEntryGadget
#
[docs] def get_object_from_item(self, item):
return item and self.store.find(self.person_type, id=item.id).one()
[docs] def describe_item(self, person_view):
details = []
birth_date = (person_view.birth_date and
person_view.birth_date.strftime('%x'))
for label, value in [
(_("Phone"), person_view.phone_number),
(_("Mobile"), person_view.mobile_number),
(self._person_l10n.label, person_view.cpf),
(self._company_l10n.label, person_view.cnpj),
(_("RG"), person_view.rg_number),
(_("Birth date"), birth_date),
(_("Category"), getattr(person_view, 'client_category', None)),
(_("Address"), format_address(person_view))]:
if not value:
continue
details.append('%s: %s' % (label, api.escape(value)))
name = "<big>%s</big>" % (api.escape(person_view.get_description()), )
if details:
short = name + '\n<span size="small">%s</span>' % (
api.escape(', '.join(details[:1])))
complete = name + '\n<span size="small">%s</span>' % (
api.escape('\n'.join(details)))
else:
short = name
complete = name
return short, complete
[docs]class ClientEntryGadget(PersonEntryGadget):
LOADING_ITEMS_TEXT = _('Loading clients...')
NEW_ITEM_TEXT = _('Create a new client with this name...')
NEW_ITEM_TOOLTIP = _('Create a new client')
EDIT_ITEM_TOOLTIP = _('Edit the selected client')
INFO_ITEM_TOOLTIP = _('See info about the selected client')
item_editor = ClientEditor
item_info_dialog = ClientDetailsDialog
person_type = Client
search_class = ClientSearch
search_spec = ClientView
search_columns = [ClientView.name, ClientView.fancy_name,
ClientView.phone_number, ClientView.mobile_number,
ClientView.cpf, ClientView.rg_number]
[docs]class SupplierEntryGadget(PersonEntryGadget):
LOADING_ITEMS_TEXT = _('Loading suppliers...')
NEW_ITEM_TEXT = _('Create a new supplier with this name...')
NEW_ITEM_TOOLTIP = _('Create a new supplier')
EDIT_ITEM_TOOLTIP = _('Edit the selected supplier')
INFO_ITEM_TOOLTIP = _('See info about the selected supplier')
item_editor = SupplierEditor
item_info_dialog = SupplierDetailsDialog
person_type = Supplier
search_class = SupplierSearch
search_spec = SupplierView
search_columns = [SupplierView.name, SupplierView.fancy_name,
SupplierView.phone_number, SupplierView.mobile_number,
SupplierView.cpf, SupplierView.rg_number]
[docs]class SaleTokenEntryGadget(QueryEntryGadget):
LOADING_ITEMS_TEXT = _('Loading tokens...')
NO_ITEMS_FOUND_TEXT = _("No tokens found... Register some in the admin app")
advanced_search = False
selection_only = True
search_spec = SaleTokenView
search_columns = [SaleTokenView.code,
SaleTokenView.name,
SaleTokenView.client_name]
#
# QueryEntryGadget
#
[docs] def get_object_from_item(self, item):
return item and self.store.find(SaleToken, id=item.id).one()
[docs] def describe_item(self, sale_token_view):
details = []
for label, value in [
(_("Status"), sale_token_view.status_str),
(_("Sale"), sale_token_view.sale_identifier_str),
(_("Client"), sale_token_view.client_name)]:
if not value:
continue
details.append('{}: {}'.format(label, api.escape(value)))
name = "<big>{}</big>".format(
api.escape(sale_token_view.sale_token.description))
if details:
short = name + '\n<span size="small">{}</span>'.format(
api.escape(', '.join(details[:1])))
complete = name + '\n<span size="small">{}</span>'.format(
api.escape('\n'.join(details)))
else:
short = name
complete = name
return short, complete