# -*- 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 collections
import datetime
import decimal
import gtk
from kiwi import ValueUnset
from kiwi.datatypes import ValidationError
from kiwi.ui.objectlist import Column
from kiwi.ui.entry import ENTRY_MODE_DATA
from kiwi.ui.widgets.entry import ProxyEntry
from kiwi.ui.widgets.spinbutton import ProxySpinButton
from stoqlib.api import api
from stoqlib.domain.product import Storable, StorableBatch, StorableBatchView
from stoqlib.gui.editors.baseeditor import BaseEditor
from stoqlib.lib.defaults import QUANTITY_PRECISION, MAX_INT
from stoqlib.lib.formatters import format_quantity
from stoqlib.lib.message import warning
from stoqlib.lib.stringutils import next_value_for, max_value_for
from stoqlib.lib.translation import stoqlib_gettext
_ = stoqlib_gettext
[docs]class BatchSelectionDialog(BaseEditor):
"""A dialog for selecting batch quantities
This editor will help to generate quantities for batches given a
|storable|. By default, it will add an entry and a spin button to
select the batch and it's quantity. That spin button will be
pre-filled with *quantity* passed in from the dialog constructor
so you can just fill the batch and confirm the dialog.
But as soon as you fill a valid batch, a new entry and spin button
will be appended below the last ones, so you can add more quantities
to/from another batch.
When confirming, a dict will be returned mapping the batch to it's
quantity. Note that *batch' there can be a text (containing the
batch number) or an object (containing the |batch| in question).
That will depend on the editor (see :class:`.BatchIncreaseSelectionDialog`
and :class:`.BatchDecreaseSelectionDialog` for more information).
"""
#: if we should validate the quantity and treat is as a maximum
#: quantity. If ``True``, the sum of all quantities on spin buttons
#: cannot be greater than the *quantity* passed in from the dialog
#: constructor for the dialog to be confirmed. If ``False``, it will
#: have no limit.
validate_max_quantity = False
#: If we should show a list displaying the existing batches. It's used
#: to make easier to check for batch's stock, creation date, etc
show_existing_batches_list = True
#: If ``True``, activating an entry (e.g. pressing Enter) will confirm
#: the dialog. If ``False``, the default behaviour will happen that is
#: to set focus on the spinbutton (and that spinbutton is always
#: set to confirm the dialog on activation)
confirm_dialog_on_entry_activate = False
#: If we should allow to indicate a batch with a quantity of 0. The
#: default is ``False`` which will always set a quantity of 0 as invalid
allow_no_quantity = False
size = (600, 400)
title = _("Batch selection")
gladefile = 'BatchSelectionDialog'
model_type = Storable
proxy_widgets = [
'description',
]
def __init__(self, store, model, quantity, original_batches=None):
"""
:param store: the store for this editor
:param model: the |storable| used to generate the batch quantities
:param quantity: the quantity used to fill the first appended spin.
Note that if :attr:`.validate_max_quantity` is set to ``True``
and this is different than 0, it will be used to validate the
dialog as a maximum quantity (see the attr doc for more
information). Passing 0 here means forcing no validation
(so the user can type whatever he wants)
:param original_batches: a dict mapping the batch to it's
original quantity to be populated on entries. Very useful when
calling this editor to edit the same model
:param visual_mode: if we are working on visual mode
"""
if quantity < 0:
raise ValueError("The quantity cannot be negative")
# quantity = 0 means forcing no validation
if quantity == 0:
self._validate_max_quantity = False
else:
self._validate_max_quantity = self.validate_max_quantity
self._quantity = quantity
# A simple lock, used to avoid some problems (infinity recursion,
# spin being updated wrong, etc) when appending a new dumb row
self._append_dumb_row_lock = 0
# The last entry appended
self._last_entry = None
# This dicts store what is the spin given an entry,
# or the entry given the spin
self._spins = collections.OrderedDict()
self._entries = collections.OrderedDict()
BaseEditor.__init__(self, store, model=model, visual_mode=False)
self._append_initial_rows(original_batches)
#
# Public API
#
[docs] def get_entry_by_spin(self, spin):
"""Gets an entry given a *spin*
This will return the entry that makes a pair with the
spin on the dialog
:returns: a :class:`kiwi.ui.widgets.ProxyEntry`
"""
return self._entries[spin]
[docs] def get_spin_by_entry(self, entry):
"""Gets a spin given an *entry*
This will return the spin that makes a pair with the
entry on the dialog
:returns: a :class:`kiwi.ui.widgets.ProxyEntry`
"""
return self._spins[entry]
[docs] def get_batch_item(self, batch):
"""A hook called to get the batch item for the given batch
By default, it will return the batch itself. Subclasses can
override this if they are working with other type of items
on the entries (e.g. the batch number as a string)
:param batch: the |batch|
:returns: the batch item that will be used to update
the entry's value
"""
return batch
[docs] def setup_entry(self, entry):
"""A hook called every time a new entry is appended on the dialog
Subclasses can override this if they want to do some extra
setup on the entry (for example, setting a completion).
:param entry: the :class:`kiwi.ui.widgets.ProxyEntry`
"""
[docs] def setup_spin(self, entry):
"""A hook called every time a new spin button is appended on the dialog
Subclasses can override this if they want to do some extra
setup on the spin button.
:param entry: the :class:`kiwi.ui.widgets.ProxySpinButton`
"""
[docs] def validate_entry(self, entry):
"""A hook called to validate *entry*
This should return :class:`kiwi.datatypes.ValidationError` if
the *entry* is not valid.
Subclasses can override this.
:param entry: the :class:`kiwi.ui.widgets.ProxyEntry`
"""
[docs] def validate_spin(self, spin):
"""A hook called to validate *spin*
This should return :class:`kiwi.datatypes.ValidationError` if
the *spin* is not valid.
Subclasses can override this.
:param spin: the :class:`kiwi.ui.widgets.ProxySpinButton`
"""
#
# BaseEditor
#
[docs] def validate_confirm(self):
if self._get_diff_quantity() < 0:
warning(_("There's some outstanding quantity. Adjust them "
"before you can confirm"))
return False
return True
[docs] def setup_proxies(self):
# If there's no max quantity, there's no reason to
# have a missing/outstanding quantity
if not self._validate_max_quantity:
for widget in [self.diff_quantity, self.diff_quantity_lbl]:
widget.hide()
if self.show_existing_batches_list:
self.existing_batches.set_columns([
Column('batch_number', _(u"Number"), data_type=str, expand=True),
Column('create_date', _(u"Creation date"),
data_type=datetime.date, sorted=True),
Column('stock', _(u"Available"), data_type=decimal.Decimal,
format_func=format_quantity)])
self.existing_batches.add_list(self._get_existing_batches())
else:
self.existing_batches_expander.hide()
self.add_proxy(self.model, self.proxy_widgets)
[docs] def on_confirm(self):
self.retval = collections.OrderedDict()
for entry, spin in self._spins.items():
batch = entry.read()
if not batch or batch == ValueUnset:
continue
self.retval[batch] = spin.read()
#
# Private
#
def _get_existing_batches(self):
branch = api.get_current_branch(self.store)
return StorableBatchView.find_available_by_storable(
self.store, self.model, branch=branch)
def _get_total_sum(self):
return sum(spin.read() for entry, spin in self._spins.items() if
entry.read() and entry.read() != ValueUnset)
def _get_diff_quantity(self):
if not self._validate_max_quantity:
return 0
return self._quantity - self._get_total_sum()
def _append_initial_rows(self, batches=None):
self._append_dumb_row_lock += 1
batches = batches or {}
if not batches:
self._append_or_update_row(self._quantity, mandatory=True)
for batch, quantity in batches.items():
self._append_or_update_row(quantity, batch=batch)
self._update_view()
self._append_dumb_row_lock -= 1
def _create_entry(self, mandatory=False):
entry = ProxyEntry()
entry.data_type = unicode
# Set as empty or kiwi will return ValueUnset on entry.read()
# and we would have to take that in consideration everywhere here
entry.update(u'')
entry.mandatory = mandatory
self.setup_entry(entry)
entry.connect_after('content-changed',
self._after_entry__content_changed)
entry.connect_after('changed', self._after_entry__changed)
entry.connect('validate', self._on_entry__validate)
entry.connect('activate', self._on_entry__activate)
return entry
def _create_spin(self):
spin = ProxySpinButton()
spin.data_type = decimal.Decimal
unit = self.model.product.sellable.unit
upper = self._quantity if self._validate_max_quantity else MAX_INT
spin.set_adjustment(gtk.Adjustment(lower=0, upper=upper,
step_incr=1, page_incr=10))
if unit and unit.allow_fraction:
spin.set_digits(QUANTITY_PRECISION)
self.setup_spin(spin)
spin.connect_after('content-changed',
self._after_spinbutton__content_changed)
spin.connect('validate', self._on_spinbutton__validate)
return spin
def _append_or_update_row(self, quantity=None, batch=None,
mandatory=False, grab_focus=False):
last_entry = self._last_entry
last_spin = self._last_entry and self._spins[self._last_entry]
# If the last entry is not valid (no batch set), use it
# instead of appending a lot of invalids
if (last_entry is not None and
(not last_entry.read() or not last_spin.read())):
if quantity is not None:
last_spin.update(quantity)
# The batch is already None. Only update it if not None
# to avoid update_view being called again here (no problem
# for the spin because it should be insensitive)
if batch is not None:
self._last_entry.update(self.get_batch_item(batch))
if grab_focus:
last_spin.grab_focus()
return
self._append_dumb_row_lock += 1
entry = self._create_entry(mandatory)
spin = self._create_spin()
if quantity is not None:
spin.set_value(quantity)
self._spins[entry] = spin
self._entries[spin] = entry
self._last_entry = entry
n_rows = self.main_table.get_property('n-rows')
for i, widget in enumerate([entry, spin]):
self.main_table.attach(widget, i, i + 1, n_rows, n_rows + 1,
gtk.FILL, 0, 0, 0)
widget.show()
focus_chain = self.main_table.get_focus_chain() or []
self.main_table.set_focus_chain(focus_chain + [entry, spin])
# FIXME: Kiwi will only set mandatory and call validate events (and
# other stuff) on widgets on a proxy. Remove this when fixing kiwi
entry_name = '_entry_%d' % len(self._entries)
spin_name = '_spin_%d' % len(self._spins)
for widget, name in [(entry, entry_name), (spin, spin_name)]:
setattr(self, name, widget)
setattr(self.model, name, widget.read())
self.add_proxy(self.model, [entry_name, spin_name])
# Allow to confirm the editor by pressing enter on any spin
self.set_confirm_widget(spin)
if self.confirm_dialog_on_entry_activate:
self.set_confirm_widget(entry)
# Do this after adding the widget to the proxy so the validate
# signal gets emitted
entry.update(self.get_batch_item(batch))
# Don't grab focus on the spin if we have to fill the batch
if batch and grab_focus:
spin.grab_focus()
self._append_dumb_row_lock -= 1
def _append_or_update_dumb_row(self):
if self._append_dumb_row_lock > 0:
return
diff = self._get_diff_quantity()
# If we have a max quantity and there's no diff, don't append a new row
if self._validate_max_quantity and diff <= 0:
return
# No entry appended yet
if self._last_entry is None:
return
# If the last entry is the first entry (the only mandatory)
# wait until it's valid to add another one
if self._last_entry.mandatory and not self._last_entry.read():
return
self._append_or_update_row(abs(diff))
def _update_view(self):
diff = self._get_diff_quantity()
self.quantity.update(format_quantity(self._get_total_sum()))
self.diff_quantity.update(format_quantity(abs(diff)))
self.diff_quantity_lbl.set_text(
_("Missing quantity:") if diff >= 0 else
_("Outstanding quantity:"))
self._append_or_update_dumb_row()
def _validate_entry(self, entry, value):
self._spins[entry].validate(force=True)
for other_entry in self._entries.values():
if other_entry is entry:
continue
entry_value = other_entry.read()
if entry_value and entry_value == value:
return ValidationError(_("This batch is already selected"))
return self.validate_entry(entry=entry)
#
# Callbacks
#
[docs] def on_existing_batches__row_activated(self, existing_batches, item):
# If the item doesn't have stock, don't append it
if not item.stock:
return
# FIXME: When grabbing focus here (both cases below), if the row
# was activated by pressing 'Enter', the focus is grabbed right (and
# thus, we can type the quantity for that batch directly). But if it
# was activated by 'Double-click', it appears to be focussed, but the
# focus is still on the objectlist
batch = item.batch
for spin, entry in self._entries.items():
if entry.read() == batch:
spin.grab_focus()
return
diff = self._get_diff_quantity()
# The diff could be 0 or less if all the suggested quantity is filled.
# If that happens, pass None for the spin to use it's created value
if diff <= 0:
diff = None
self._append_or_update_row(diff, batch=batch, grab_focus=True)
def _after_entry__changed(self, entry):
# FIXME: This is a *very* ugly workaround, but if the entry is on
# data mode, it will only emit validate and content-changed when
# the widget goes from not-matched-object to a matched-object
# (and vice-versa). Remove this when fixed on kiwi
valid = self._validate_entry(entry, entry.get_text())
if isinstance(valid, ValidationError):
entry.set_invalid(str(valid))
def _on_entry__activate(self, entry):
# On activation, grab focus on entry's spin (but only if it's valid)
if entry.read():
self._spins[entry].grab_focus()
def _on_entry__validate(self, entry, value):
return self._validate_entry(entry, value)
def _after_entry__content_changed(self, entry):
self._spins[entry].set_sensitive(
entry.read() not in [None, u'', ValueUnset])
self._update_view()
def _on_spinbutton__validate(self, spin, value):
batch = self._entries[spin].read()
if batch is not None and not self.allow_no_quantity and value == 0:
return ValidationError(_("The quantity cannot be 0"))
sellable = self.model.product.sellable
if not self.model.product.sellable.is_valid_quantity(value):
return ValidationError(_("This product unit (%s) does not "
"support fractions.") %
sellable.unit_description)
return self.validate_spin(spin=spin)
def _after_spinbutton__content_changed(self, spin):
self._update_view()
[docs]class BatchDecreaseSelectionDialog(BatchSelectionDialog):
"""Batch selection for storable decreases
This is the same as :class:`BatchSelectionDialog`,
but since the quantity selected here is going to be decreased,
it will be validated for each batch (so no batch is allowed
to have more quantity than the available in stock)
Also, the *batch* key on the returned dict will be a |batch|.
"""
def __init__(self, store, model, quantity,
original_batches=None, decreased_batches=None):
"""
:param decreased_batches: a dict mapping the batch to it's
already decreased. Useful when you have some quantity
already decreased on a store for example and you want it to be
taken in consideration when checking for stock availability
"""
self._decreased_batches = decreased_batches or {}
BatchSelectionDialog.__init__(self, store, model, quantity,
original_batches=original_batches)
#
# BatchSelectionDialog
#
[docs] def setup_proxies(self):
BatchSelectionDialog.setup_proxies(self)
# For decreases, it's very useful for this to be expanded
self.existing_batches_expander.set_expanded(True)
[docs] def setup_entry(self, entry):
entry.set_mode(ENTRY_MODE_DATA)
entry.set_exact_completion()
completion = entry.get_completion()
completion.set_minimum_key_length = 1
items = self.model.get_available_batches(
api.get_current_branch(self.store))
entry.prefill(api.for_combo(items))
[docs] def validate_entry(self, entry):
text = entry.get_text()
if text == '':
return
if entry.read() is None:
return ValidationError(_("'%s' is not a valid batch") % text)
[docs] def validate_spin(self, spin):
quantity = spin.read()
batch = self.get_entry_by_spin(spin).read()
if batch is None:
return
branch = api.get_current_branch(self.store)
available_qty = batch.get_balance_for_branch(branch)
for decreased_batch, decreased_quantity in self._decreased_batches.items():
if decreased_batch == batch:
available_qty -= decreased_quantity
if quantity > available_qty:
return ValidationError(_("There's only %s available in stock for "
"the given batch") % available_qty)
# Editors/wizards using BatchIncreaseSelectionDialog will create the
# StorableBatch after confirming it, so we need this dict to known which ones
# are being used at the moment. That makes possible for us to validate already
# used batch numbers (they should be unique among all batches) and also to get
# the next value of the sequence based on the maximum here
_used_batches_mapper = {}
[docs]class BatchIncreaseSelectionDialog(BatchSelectionDialog):
"""Batch selection for storable increases
This is the same as :class:`BatchSelectionDialog`,
but since the quantity selected here is going to be increased
there's no limit for quantities in each batch (unless specified
by the *max_quantity* param)
Also, the *batch* key on the returned dict will
be a string object, containing the batch number.
"""
validate_max_quantity = True
#
# _BatchSelectionDialog
#
[docs] def on_confirm(self):
super(BatchIncreaseSelectionDialog, self).on_confirm()
used = set(batch for batch in self.retval)
# Replace the existing one instead of replacing since some batches
# may have been removed and thus are allowed for other storables
_used_batches_mapper[(self.store, self.model.id)] = used
[docs] def get_batch_item(self, batch):
if isinstance(batch, basestring):
return batch
if batch is not None:
return batch.batch_number
if not api.sysparam.get_bool('SUGGEST_BATCH_NUMBER'):
return None
return self._get_next_batch_number()
[docs] def validate_entry(self, entry):
batch_number = unicode(entry.get_text())
if not batch_number:
return
available = StorableBatch.is_batch_number_available(
self.store, batch_number, exclude_storable=self.model)
if (not available or
batch_number in self._get_used_batches(exclude=batch_number)):
return ValidationError(_("'%s' is already in use") % batch_number)
#
# Private
#
def _get_next_batch_number(self):
max_db = StorableBatch.get_max_batch_number(self.store)
max_used = max_value_for(self._get_used_batches() | set([max_db]))
if not api.sysparam.get_bool('SYNCHRONIZED_MODE'):
return next_value_for(max_used)
# On synchronized mode we need to append the branch acronym
# to avoid conflicts
max_used_list = max_used.split('-')
if len(max_used_list) == 1:
# '123'
max_used = max_used_list[0]
elif len(max_used_list) == 2:
# '123-AB'
max_used = max_used_list[0]
else:
# TODO: Maybe we should allow only one dash in the batch number
# '123-456-AB'
max_used = ''.join(max_used_list[:-1])
branch = api.get_current_branch(self.store)
if not branch.acronym:
raise ValueError("branch '%s' needs an acronym since we are on "
"synchronized mode" % (branch.get_description(),))
return '-'.join([next_value_for(max_used), branch.acronym])
def _get_used_batches(self, exclude=None):
in_use = set()
for k in _used_batches_mapper:
# Excluding our batches from the used to avoid 'already in use'
# problems when editing the same batches a second time
if k == (self.store, self.model.id):
continue
in_use.update(_used_batches_mapper[k])
for entry in self._entries.values():
batch_number = entry.read()
if batch_number == exclude:
continue
in_use.add(entry.read())
return in_use