# -*- coding: utf-8 -*-
# vi:si:et:sw=4:sts=4:ts=4
##
## Copyright (C) 2016 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>
##
"""Dialog to mass edit value of database objects
This will allow the user to choose the field he wants to mass update, the rule
for the update and set some parameters depending on those rules. For instance:
Price / Cost (Decimal)
- Multiply value of [ |v] by [ ]
- Add value of [ |v] with [ ]
- Set to [ ]
Description / Code / Barcode / Others (String)
- Replace [ ] by [ ]
- Append [ ]
- Prepend [ ]
- Set to (if not unique)
Category (Reference)
- Set to [ |v]
"""
import logging
import sys
import traceback
import datetime
from decimal import Decimal
import gtk
import pango
from kiwi import ValueUnset
from kiwi.currency import currency
from kiwi.datatypes import converter
from kiwi.ui.objectlist import Column
from kiwi.ui.widgets.combo import ProxyComboBox
from kiwi.ui.widgets.entry import ProxyEntry, ProxyDateEntry
from stoqlib.gui.dialogs.progressdialog import ProgressDialog
from stoqlib.gui.search.searchcolumns import SearchColumn
from stoqlib.gui.search.searchdialog import SearchDialog
from stoqlib.lib.message import marker, warning, yesno
from stoqlib.lib.translation import stoqlib_gettext
_ = stoqlib_gettext
log = logging.getLogger(__name__)
#
# Operations
#
[docs]class Operation(gtk.HBox):
"""Base class for an operation
An operation has some parameters (created by subclasses at will) and should
return a new value that will update a field in the objects.
"""
def __init__(self, store, field, other_fields):
self._store = store
self._field = field
gtk.HBox.__init__(self, spacing=6)
self.setup(other_fields)
self.show_all()
[docs] def set_field(self, field):
self._field = field
[docs] def add_label(self, label):
"""Add a label to self
"""
label = gtk.Label(label)
self.pack_start(label, False, False)
return label
[docs] def add_entry(self, data_type):
"""Add a entry with the specified data_type
The user will be able to provide any information in the entry that
should be used by this operation (for instance, a number do multiply a
value for or a string to replace a value for)
"""
entry = ProxyEntry(data_type=data_type)
self.pack_start(entry, False, False)
return entry
[docs] def add_combo(self, data=None):
"""Add a combo for selecting an option"""
combo = ProxyComboBox()
self.pack_start(combo, False, False)
if data:
combo.prefill(data)
return combo
[docs] def add_field_combo(self, fields):
"""Adds a combo for selecting another field.
The other field should be used as a reference value for the operation.
for instance: a value that should be multiplied by or added to.
"""
combo = self.add_combo()
for f in fields:
combo.append_item(f.label, f)
return combo
[docs] def setup(self): # pragma nocover
"""Setup this operation.
Subclasses should override this method and add other fields that the
user can set how the operation should work.
"""
raise NotImplementedError()
[docs] def get_new_value(self, item): # pragma nocover
"""Returns the new value for the item
Subclasses must override this method and return a new value for the
object field
"""
raise NotImplementedError()
[docs] def apply_operation(self, item):
value = self.get_new_value(item)
# If the value is not valid, do not update the object
if callable(self._field.validator) and not self._field.validator(value):
return
if value is not ValueUnset:
self._field.set_new_value(item, value)
[docs]class MultiplyOperation(Operation):
"""An operation that multiplies a field with a value"""
label = _('Multiply value of')
middle_label = _('by')
[docs] def setup(self, other_fields):
self.combo = self.add_field_combo(other_fields)
self.add_label(self.middle_label)
self.entry = self.add_entry(Decimal)
[docs] def get_new_value(self, item):
multiplier = self.entry.validate()
if multiplier is ValueUnset:
return ValueUnset
other_field = self.combo.get_selected()
old_value = other_field.get_value(item)
return old_value * multiplier
[docs]class AddOperation(MultiplyOperation):
"""An operation that adds a field with a value"""
label = _('Add value of')
middle_label = _('with')
[docs] def get_new_value(self, item):
value = self.entry.validate()
if value is ValueUnset:
return ValueUnset
other_field = self.combo.get_selected()
old_value = other_field.get_value(item)
return old_value + value
[docs]class DivideOperation(MultiplyOperation):
"""An operation that divides a field by a value"""
label = _('Divide value of')
middle_label = _('by')
[docs] def get_new_value(self, item):
divider = self.entry.validate()
if divider is ValueUnset:
return ValueUnset
other_field = self.combo.get_selected()
old_value = other_field.get_value(item)
return old_value / divider
[docs]class SetValueOperation(Operation):
"""An operation that sets a field to a specifc value.
This works for both strings and numerical values
"""
label = _('Set value to')
[docs] def setup(self, other_fields):
self.entry = self.add_entry(self._field.data_type)
[docs] def get_new_value(self, item):
value = self.entry.validate()
if value is ValueUnset:
return ValueUnset
return value
[docs]class ReplaceOperation(Operation):
"""An operation that replaces a string by another one"""
label = _('Replace')
[docs] def setup(self, other_fields):
self.one_entry = self.add_entry(unicode)
self.add_label(_('by'))
self.other_entry = self.add_entry(unicode)
[docs] def get_new_value(self, item):
old_value = self._field.get_value(item)
return old_value.replace(self.one_entry.read(), self.other_entry.read())
[docs]class SetObjectValueOperation(Operation):
"""An operation that sets a field to a specifc value.
This works only for object values.
"""
label = _('Set value to')
[docs] def setup(self, other_fields):
table = self._field._reference_class
data = self._store.find((self._field.get_search_spec(), table))
self.combo = self.add_combo([(_('Erase value'), None)] + list(data))
[docs] def get_new_value(self, item):
return self.combo.read()
[docs]class SetDateValueOperation(Operation):
"""An operation that sets a field to a specifc value.
This works only for object values.
"""
label = _('Set value to')
[docs] def setup(self, other_fields):
self.entry = ProxyDateEntry()
self.pack_start(self.entry, False, False)
self.entry.show()
[docs] def get_new_value(self, item):
return self.entry.read()
#
# Field Editors
#
[docs]class Editor(gtk.HBox):
"""Base class for field editors
Subclasses must define a list of operations and a datatype
"""
operations = []
data_type = None
def __init__(self, store, field, other_fields):
"""
:param store: a storm store if its needed
:param field: the field that is being edited
:param other_fields: other fields available for math operations
"""
assert len(self.operations)
self._store = store
self._other_fields = other_fields
self._oper = None
self._field = field
gtk.HBox.__init__(self, spacing=6)
self.operations_combo = ProxyComboBox()
self.pack_start(self.operations_combo)
self.operations_combo.connect('changed', self._on_operation_changed)
for oper in self.operations:
self.operations_combo.append_item(oper.label, oper)
self.operations_combo.select(self.operations[0])
self.show_all()
[docs] def set_field(self, field):
assert field.data_type == self.data_type
self._field = field
self._oper.set_field(field)
def _on_operation_changed(self, combo):
if self._oper is not None:
# Remove previous operation
self.remove(self._oper)
self._oper = combo.get_selected()(self._store, self._field,
self._other_fields)
self.pack_start(self._oper)
[docs] def apply_operation(self, item):
return self._oper.apply_operation(item)
[docs]class DecimalEditor(Editor):
operations = [
SetValueOperation,
AddOperation,
MultiplyOperation,
DivideOperation,
]
data_type = Decimal
[docs]class UnicodeEditor(Editor):
operations = [
ReplaceOperation,
SetValueOperation,
]
data_type = unicode
[docs]class ObjectEditor(Editor):
operations = [
SetObjectValueOperation,
]
data_type = object
[docs]class DateEditor(Editor):
operations = [
SetDateValueOperation,
]
data_type = datetime.date
#
# Fields
#
[docs]class Field(object):
"""Base class for fields in a mass editor
This class implements basic value caching/storage for the editor
"""
def __init__(self, data_type, validator=None, unique=False, visible=True,
read_only=False, format_func=None):
self.data_type = data_type
self.validator = validator
self.visible = visible
# FIXME: Use this
self.unique = unique
self.read_only = read_only
# An extra formatting that the column requires.
self._format_func = format_func
self.new_values = {}
[docs] def get_value(self, item): # pragma nocover
raise NotImplementedError()
[docs] def save_value(self, item): # pragma nocover
raise NotImplementedError()
[docs] def get_column(self, spec):
return Column('id', title=self.label, data_type=self.data_type,
format_func=self.format_func, format_func_data=self,
visible=self.visible)
[docs] def set_new_value(self, item, value):
old_value = self.get_new_value(item)
if value == old_value:
return
self.new_values[item] = value
[docs] def get_new_value(self, item):
return self.new_values.get(item, self.get_value(item))
[docs] def is_changed(self, item):
return self.get_new_value(item) != self.get_value(item)
[docs]class AccessorField(Field):
def __init__(self, label, obj_name, attribute, data_type, unique=False,
validator=None, visible=True, read_only=False, format_func=None):
"""A field that updates a value of another object
:param obj_name: the name of the object that will be updated, or None if
the attribute will be accessed directly
:param attribute: the attribute of obj that will be updated.
:param unique: If the field is unique, the user will not be able to set
the field to an specific value. FIXME: not implemented yet
:param validator: A callable that should return True/False
:param visible: If the column will be visible by default or not
:param editable: If the field should be editable or not. Sometimes it is
usefull to have fields visible that are not editable, but can be
filterable
:param read_only: If this field should be used only for informational purposes
and filtering
:param format_func: A function that will be called to format the data.
"""
super(AccessorField, self).__init__(data_type, validator=validator,
unique=unique, visible=visible,
read_only=read_only, format_func=format_func)
self.label = label
self.obj_name = obj_name
self.attribute = attribute
def _get_obj(self, item):
if self.obj_name:
return getattr(item, self.obj_name)
return item
[docs] def get_search_spec(self, spec):
"""Get the spec for filtering by this field."""
obj = self._get_obj(spec)
return getattr(obj, self.attribute)
[docs] def get_value(self, item):
obj = self._get_obj(item)
return obj and getattr(obj, self.attribute)
[docs] def save_value(self, item):
try:
value = self.new_values[item]
except KeyError:
# The value wasn't changed. Ignore
return
dest_obj = self._get_obj(item)
if dest_obj:
setattr(dest_obj, self.attribute, value)
[docs] def get_column(self, spec):
# SearchColumn expects str instead of unicode and objects are rendered
# as strings
data_type = {unicode: str, object: str}.get(self.data_type, self.data_type)
# FIXME Dont let the user edit unique fields for now, since that needs
# better handling
editable = self.data_type in [unicode, int, Decimal, currency,
datetime.date] and not self.unique and not self.read_only
# XXX: All columns use a non existing attr, but since we have a
# format_func, that will handle the correct value to display.
return SearchColumn('non_existing_attr', title=self.label,
data_type=data_type, editable=editable,
format_func=self.format_func,
search_attribute=self.get_search_spec(spec),
format_func_data=self, visible=self.visible)
[docs]class ReferenceField(AccessorField):
def __init__(self, label, obj_name, attribute, reference_class,
reference_attr, visible=True):
"""A field that updates a reference to another object
:param label: The label to be displayed
:param obj_name: the name of the object that will be updated, or None if
the attribute will be accessed directly
:param attribute: the attribute of obj that will be updated.
:param reference_class: the type of the reference that will be updated.
This should be an Domain object
:param reference_attr: The attribute of the referenced class that will
be used for both rendering the column and filtering the results.
"""
self._reference_class = reference_class
self._reference_attr = reference_attr
super(ReferenceField, self).__init__(label, obj_name, attribute,
data_type=object, unique=False,
visible=visible)
[docs] def get_search_spec(self, spec=None):
return getattr(self._reference_class, self._reference_attr)
[docs]class MassEditorSearch(SearchDialog):
size = (850, 450)
unlimited_results = True
save_columns = False
def __init__(self, store):
self._fields = self.get_fields(store)
SearchDialog.__init__(self, store, hide_footer=False)
self.set_ok_label(_('Save'))
self.ok_button.set_sensitive(True)
self.mass_editor = MassEditorWidget(store, self._fields, self.results)
self.search.vbox.pack_start(self.mass_editor, False, False)
self.search.vbox.reorder_child(self.mass_editor, 1)
self.mass_editor.show_all()
#
# Public API
#
[docs] def get_fields(self, store): # pragma nocover
"""Returns a list of fields for this mass editor
Subclasses can override this if they want dynamic fields (that depend on
a database state, for isntance)
"""
raise NotImplementedError()
[docs] def get_items(self, store): # pragma nocover
"""Get the list of objects that will be edited.
Subclasses must override this
"""
raise NotImplementedError()
#
# SearchDialog implementation
#
[docs] def create_filters(self):
self.search.set_query(self.get_items)
self.search.result_view.set_cell_data_func(self._on_cell_data_func)
[docs] def get_columns(self):
marker('_get_columns')
columns = []
text_columns = []
for field in self._fields:
col = field.get_column(self.search_spec)
columns.append(col)
if field.data_type is unicode and isinstance(col, SearchColumn):
text_columns.append(col.search_attribute)
self.text_field_columns = text_columns
marker('Done _get_columns')
return columns
[docs] def confirm(self, retval=None):
total_products = len(self.mass_editor.get_changed_objects())
if total_products == 0:
self.retval = False
self.close()
return
retval = yesno(
_('This will update {} products. Are you sure?'.format(total_products)),
gtk.RESPONSE_NO, _('Apply changes'), _('Don\'t apply.'))
if not retval:
# Don't close the dialog. Let the user make more changes if he
# wants to or cancel the dialog.
return
# FIXME: Is there a nicer way to display this progress?
self.ok_button.set_sensitive(False)
self.cancel_button.set_sensitive(False)
d = ProgressDialog(_('Updating items'), pulse=False)
d.set_transient_for(self)
d.start(wait=0)
d.cancel.hide()
try:
for i, total in self.mass_editor.confirm(self):
d.progressbar.set_text('%s/%s' % (i + 1, total))
d.progressbar.set_fraction((i + 1) / float(total))
while gtk.events_pending():
gtk.main_iteration(False)
except Exception as e:
d.stop()
self.retval = False
log.error(''.join(traceback.format_exception(*sys.exc_info())))
warning(_('There was an error saving one of the values'), str(e))
self.close()
return
d.stop()
self.retval = True
self.close()
#
# Callbacks
#
def _on_cell_data_func(self, column, renderer, item, text):
field = column.format_func_data
is_changed = field.is_changed(item)
text = field.format_func(item)
if isinstance(renderer, gtk.CellRendererToggle):
renderer.set_property('active', text == 'True')
return text == 'True'
renderer.set_property('weight-set', is_changed)
if is_changed:
renderer.set_property('weight', pango.WEIGHT_BOLD)
return text
def _on_results__cell_edited(self, results, obj, column):
field = column.format_func_data
# Since the columns use a non existing attribute, we must get the value
# from there after the user has edited.
value = obj.non_existing_attr
# Kiwi is setting as string, not unicode
if field.data_type is unicode:
value = unicode(value)
field.set_new_value(obj, value)