# -*- coding: utf-8 -*-
# vi:si:et:sw=4:sts=4:ts=4
##
## Copyright (C) 2005-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>
##
""" Editors definitions for sellable"""
import collections
import gtk
from kiwi.datatypes import ValidationError
from kiwi.ui.forms import PercentageField, TextField
from stoqdrivers.enum import TaxType, UnitType
from stoqlib.api import api
from stoqlib.database.exceptions import IntegrityError
from stoqlib.domain.fiscal import CfopData
from stoqlib.domain.sellable import (SellableCategory, Sellable,
SellableUnit,
SellableTaxConstant,
ClientCategoryPrice)
from stoqlib.domain.product import Product
from stoqlib.gui.base.dialogs import run_dialog
from stoqlib.gui.dialogs.labeldialog import PrintLabelEditor
from stoqlib.gui.editors.baseeditor import BaseEditor
from stoqlib.gui.editors.categoryeditor import SellableCategoryEditor
from stoqlib.gui.slaves.commissionslave import CommissionSlave
from stoqlib.gui.slaves.imageslave import ImageGallerySlave
from stoqlib.gui.utils.databaseform import DatabaseForm
from stoqlib.gui.utils.printing import print_labels
from stoqlib.lib.decorators import cached_property
from stoqlib.lib.defaults import MAX_INT
from stoqlib.lib.formatters import get_price_format_str, get_formatted_price
from stoqlib.lib.message import yesno, warning
from stoqlib.lib.parameters import sysparam
from stoqlib.lib.stringutils import next_value_for
from stoqlib.lib.translation import stoqlib_gettext
_ = stoqlib_gettext
_DEMO_BAR_CODES = ['2368694135945', '6234564656756', '6985413595971',
'2692149835416', '1595843695465', '8596458216412',
'9586249534513', '7826592136954', '5892458629421',
'1598756984265', '1598756984265', '']
_DEMO_PRODUCT_LIMIT = 30
#
# Editors
#
[docs]class SellableTaxConstantEditor(BaseEditor):
model_type = SellableTaxConstant
model_name = _('Taxes and Tax rates')
@cached_property()
def fields(self):
return collections.OrderedDict(
description=TextField(_('Name'), proxy=True, mandatory=True),
tax_value=PercentageField(_('Value'), proxy=True, mandatory=True),
)
#
# BaseEditor
#
[docs] def create_model(self, store):
return SellableTaxConstant(tax_type=int(TaxType.CUSTOM),
tax_value=None,
description=u'',
store=store)
[docs]class BasePriceEditor(BaseEditor):
gladefile = 'SellablePriceEditor'
proxy_widgets = ['markup', 'cost', 'max_discount', 'price']
#
# BaseEditor hooks
#
[docs] def get_title(self, *args):
return _('Price settings')
[docs] def setup_proxies(self):
self._editing_price = True
self.markup.update(self.model.markup)
# These are used to avoid circular updates when changing price or markup
self._editing_price = False
self._editing_markup = False
self.set_widget_formats()
self.main_proxy = self.add_proxy(self.model, self.proxy_widgets)
if self.model.markup is not None:
return
sellable = self.model.sellable
self.model.markup = sellable.get_suggested_markup()
self.main_proxy.update('markup')
#
# Kiwi handlers
#
[docs] def on_price__validate(self, entry, value):
if value <= 0:
return ValidationError(_("Price cannot be zero or negative"))
[docs] def after_price__content_changed(self, entry_box):
# If markup is being edited, dont update the price, or the markup may be
# programatically changed (if there was any rounding involved)
if self._editing_markup:
return
self._editing_price = True
self.markup.update(self.model.markup)
self._editing_price = False
[docs] def after_markup__content_changed(self, spin_button):
# Like above, if the price is being edited, dont update the markup, or
# the price may change again.
if self._editing_price:
return
self._editing_markup = True
self.main_proxy.update("price")
self._editing_markup = False
[docs]class SellablePriceEditor(BasePriceEditor):
model_name = _(u'Product Price')
model_type = Sellable
[docs] def setup_slaves(self):
from stoqlib.gui.slaves.sellableslave import OnSaleInfoSlave
slave = OnSaleInfoSlave(self.store, self.model)
self.attach_slave('on_sale_holder', slave)
commission_slave = CommissionSlave(self.store, self.model)
self.attach_slave('on_commission_data_holder', commission_slave)
if self.model.category:
desc = self.model.category.description
label = _('Calculate Commission From: %s') % desc
commission_slave.change_label(label)
[docs]class CategoryPriceEditor(BasePriceEditor):
model_name = _(u'Category Price')
model_type = ClientCategoryPrice
sellable_widgets = ('cost', )
proxy_widgets = ('markup', 'max_discount', 'price')
[docs] def setup_proxies(self):
self.sellable_proxy = self.add_proxy(self.model.sellable,
self.sellable_widgets)
BasePriceEditor.setup_proxies(self)
#
# Editors
#
[docs]class SellableEditor(BaseEditor):
"""This is a base class for ProductEditor and ServiceEditor and should
be used when editing sellable objects. Note that sellable objects
are instances inherited by Sellable."""
# This must be be properly defined in the child classes
model_name = None
model_type = None
gladefile = 'SellableEditor'
confirm_widgets = ['description', 'cost', 'price']
ui_form_name = None
sellable_tax_widgets = ['tax_constant', 'tax_value']
sellable_widgets = ['code',
'barcode',
'description',
'category_combo',
'cost',
'price',
'status_str',
'default_sale_cfop',
'unit_combo']
proxy_widgets = (sellable_tax_widgets + sellable_widgets)
def __init__(self, store, model=None, visual_mode=False):
from stoqlib.gui.slaves.sellableslave import CategoryPriceSlave
is_new = not model
self._sellable = None
self._demo_mode = sysparam.get_bool('DEMO_MODE')
self._requires_weighing_text = (
"<b>%s</b>" % api.escape(_("This unit type requires weighing")))
if self.ui_form_name:
self.db_form = DatabaseForm(self.ui_form_name)
else:
self.db_form = None
BaseEditor.__init__(self, store, model, visual_mode)
self.enable_window_controls()
if self._demo_mode:
self._add_demo_warning()
# Code suggestion. We need to do this before disabling sensitivity,
# otherwise, the sellable will not be updated.
if not self.code.read():
self._update_default_sellable_code()
edit_code_product = sysparam.get_bool('EDIT_CODE_PRODUCT')
self.code.set_sensitive(not edit_code_product and not self.visual_mode)
self.description.grab_focus()
self.table.set_focus_chain([self.code,
self.barcode,
self.default_sale_cfop,
self.description,
self.cost_hbox,
self.price_hbox,
self.category_combo,
self.tax_hbox,
self.unit_combo,
])
self._print_labels_btn = self.add_button('print_labels', gtk.STOCK_PRINT)
self._print_labels_btn.connect('clicked', self.on_print_labels_clicked,
'print_labels')
label = self._print_labels_btn.get_children()[0]
label = label.get_children()[0].get_children()[1]
label.set_label(_(u'Print labels'))
self.setup_widgets()
if not is_new and not self.visual_mode:
# Although a sellable can be both removed/closed, we show only one,
# to avoid having *lots* of buttons. If it's closed, provide a way
# to reopen it, else, show a delete button if it can be removed
# or a close button if it can be closed
if self._sellable.is_closed():
self._add_reopen_button()
elif self._sellable.can_remove():
self._add_delete_button()
elif self._sellable.can_close():
self._add_close_button()
self.set_main_tab_label(self.model_name)
image_gallery_slave = ImageGallerySlave(
self.store, self.model.sellable, self.visual_mode)
self.add_extra_tab(_(u'Images'), image_gallery_slave)
price_slave = CategoryPriceSlave(self.store, self.model.sellable,
self.visual_mode)
self.add_extra_tab(_(u'Category Prices'), price_slave)
self._setup_ui_forms()
self._update_print_labels()
self._update_price()
def _add_demo_warning(self):
fmt = _("This is a demostration mode of Stoq, you cannot "
"create more than %d products.\n"
"To avoid this limitation, enable production mode.")
self.set_message(fmt % (_DEMO_PRODUCT_LIMIT))
if self.store.find(Sellable).count() > _DEMO_PRODUCT_LIMIT:
self.disable_ok()
def _add_extra_button(self, label, stock=None,
callback_func=None, connect_on='clicked'):
button = self.add_button(label, stock)
if callback_func:
button.connect(connect_on, callback_func, label)
def _add_delete_button(self):
self._add_extra_button(_('Remove'), gtk.STOCK_DELETE,
self._on_delete_button__clicked)
def _add_close_button(self):
if self._sellable.product:
label = _('Close Product')
else:
label = _('Close Service')
self._add_extra_button(label, None,
self._on_close_sellable_button__clicked)
def _add_reopen_button(self):
if self._sellable.product:
label = _('Reopen Product')
else:
label = _('Reopen Service')
self._add_extra_button(label, None,
self._on_reopen_sellable_button__clicked)
def _update_default_sellable_code(self, category=None):
if category:
query = (Sellable.category_id == category.id)
code = Sellable.get_max_value(self.store, Sellable.code, query=query)
else:
code = Sellable.get_max_value(self.store, Sellable.code)
self.code.update(next_value_for(code))
def _update_price(self):
self.sellable_proxy.update('base_price')
if self._sellable.is_on_sale():
text = _("Currently on sale for %s") % (
get_formatted_price(self._sellable.on_sale_price), )
else:
text = ''
self.on_sale_lbl.set_text(text)
def _update_print_labels(self):
sellable = self.model.sellable
self._print_labels_btn.set_sensitive(
all([sellable.code, sellable.barcode,
sellable.description, sellable.price]))
def _setup_ui_forms(self):
if not self.db_form:
return
self.db_form.update_widget(self.code, other=self.code_lbl)
self.db_form.update_widget(self.barcode, other=self.barcode_lbl)
self.db_form.update_widget(self.category_combo,
other=self.category_lbl)
#
# Public API
#
[docs] def set_main_tab_label(self, tabname):
self.sellable_notebook.set_tab_label(self.sellable_tab,
gtk.Label(tabname))
[docs] def edit_sale_price(self):
sellable = self.model.sellable
self.store.savepoint('before_run_editor_sellable_price')
result = run_dialog(SellablePriceEditor,
self.get_toplevel().get_toplevel(),
self.store, sellable)
if result:
self._update_price()
else:
self.store.rollback_to_savepoint('before_run_editor_sellable_price')
[docs] def update_requires_weighing_label(self):
unit = self._sellable.unit
if unit and unit.unit_index == UnitType.WEIGHT:
self.requires_weighing_label.set_text(self._requires_weighing_text)
else:
self.requires_weighing_label.set_text("")
def _update_tax_value(self):
if not hasattr(self, 'tax_proxy'):
return
self.tax_proxy.update('tax_constant.tax_value')
[docs] def get_taxes(self):
"""Subclasses may override this method to provide a custom
tax selection.
:returns: a list of tuples containing the tax description and a
:class:`stoqlib.domain.sellable.SellableTaxConstant` object.
"""
return []
def _fill_categories(self):
categories = self.store.find(SellableCategory)
self.category_combo.set_sensitive(any(categories) and not self.visual_mode)
self.category_combo.prefill(api.for_combo(categories,
attr='full_description'))
#
# BaseEditor hooks
#
[docs] def update_visual_mode(self):
self.add_category.set_sensitive(False)
self.sale_price_button.set_sensitive(False)
[docs] def setup_sellable_combos(self):
self._fill_categories()
self.edit_category.set_sensitive(False)
cfops = CfopData.get_for_sale(self.store)
self.default_sale_cfop.prefill(api.for_combo(cfops, empty=''))
self.setup_unit_combo()
[docs] def setup_unit_combo(self):
units = self.store.find(SellableUnit)
self.unit_combo.prefill(api.for_combo(units, empty=_('No units')))
[docs] def setup_tax_constants(self):
taxes = self.get_taxes()
self.tax_constant.prefill(taxes)
[docs] def setup_proxies(self):
self.set_widget_formats()
self._sellable = self.model.sellable
self.add_category.set_tooltip_text(_("Add a new category"))
self.edit_category.set_tooltip_text(_("Edit the selected category"))
self.setup_sellable_combos()
self.setup_tax_constants()
self.tax_proxy = self.add_proxy(self._sellable,
SellableEditor.sellable_tax_widgets)
self.sellable_proxy = self.add_proxy(self._sellable,
SellableEditor.sellable_widgets)
self.update_requires_weighing_label()
[docs] def setup_slaves(self):
from stoqlib.gui.slaves.sellableslave import SellableDetailsSlave
details_slave = SellableDetailsSlave(self.store, self.model.sellable,
visual_mode=self.visual_mode)
self.attach_slave('slave_holder', details_slave)
if isinstance(self.model, Product) and self.model.parent is not None:
details_slave.notes.set_property('sensitive', False)
# Make everything aligned by pytting notes_lbl on the same size group
self.left_labels_group.add_widget(details_slave.notes_lbl)
def _run_category_editor(self, category=None):
self.store.savepoint('before_run_editor_sellable_category')
model = run_dialog(SellableCategoryEditor, self, self.store, category)
if model:
self._fill_categories()
self.category_combo.select(model)
else:
self.store.rollback_to_savepoint('before_run_editor_sellable_category')
#
# Kiwi handlers
#
def _on_delete_button__clicked(self, button, parent_button_label=None):
sellable_description = self._sellable.get_description()
msg = (_("This will delete '%s' from the database. Are you sure?")
% sellable_description)
if not yesno(msg, gtk.RESPONSE_NO, _("Delete"), _("Keep")):
return
try:
self._sellable.remove()
except IntegrityError as details:
warning(_("It was not possible to remove '%s'")
% sellable_description, str(details))
return
# We are doing this by hand instead of calling confirm/cancel because,
# if we call self.cancel(), the transaction will not be committed. If
# we call self.confirm(), it will, but some on_confirm hooks (like
# ProductComponentSlave's one) will try to create other objects and
# relate them with this product that doesn't exist anymore (we removed
# them above), resulting in an IntegrityError.
self.retval = self.model
self.store.retval = self.retval
self.main_dialog.close()
def _on_close_sellable_button__clicked(self, button,
parent_button_label=None):
msg = (_("Do you really want to close '%s'?\n"
"Please note that when it's closed, you won't be able to "
"commercialize it anymore.")
% self._sellable.get_description())
if not yesno(msg, gtk.RESPONSE_NO,
parent_button_label, _("Don't close")):
return
self._sellable.close()
self.confirm()
def _on_reopen_sellable_button__clicked(self, button,
parent_button_label=None):
msg = (_("Do you really want to reopen '%s'?\n"
"Note that when it's opened, you will be able to "
"commercialize it again.") % self._sellable.get_description())
if not yesno(msg, gtk.RESPONSE_NO,
parent_button_label, _("Keep closed")):
return
self._sellable.set_available()
self.confirm()
[docs] def on_category_combo__content_changed(self, combo):
# This is being called twice (because of a kiwi bug). Run this code only
# once.
category = combo.get_selected()
if category == self._sellable.category:
return
category_suggest = api.sysparam.get_object(self.store,
'SUGGEST_PRODUCT_CODE_BASED_CATEGORY')
if not self.edit_mode and category_suggest:
self._update_default_sellable_code(category)
self.edit_category.set_sensitive(bool(category))
[docs] def on_tax_constant__changed(self, combo):
self._update_tax_value()
[docs] def on_unit_combo__changed(self, combo):
self.update_requires_weighing_label()
[docs] def on_add_category__clicked(self, widget):
self._run_category_editor()
[docs] def on_edit_category__clicked(self, widget):
self._run_category_editor(self.category_combo.get_selected())
[docs] def on_code__validate(self, widget, value):
if not value:
return ValidationError(_(u'The code can not be empty.'))
if self.model.sellable.check_code_exists(value):
return ValidationError(_(u'The code %s already exists.') % value)
[docs] def on_barcode__validate(self, widget, value):
if not value:
return
if value and len(value) > 14:
return ValidationError(_(u'Barcode must have 14 digits or less.'))
if self.model.sellable.check_barcode_exists(value):
return ValidationError(_('The barcode %s already exists') % value)
if self._demo_mode and value not in _DEMO_BAR_CODES:
return ValidationError(_("Cannot create new barcodes in "
"demonstration mode"))
[docs] def on_price__validate(self, entry, value):
if value <= 0:
return ValidationError(_("Price cannot be zero or negative"))
[docs] def on_cost__validate(self, entry, value):
if value <= 0:
return ValidationError(_("Cost cannot be zero or negative"))
[docs] def after_description__changed(self, widget):
self._update_print_labels()
[docs] def after_code__changed(self, widget):
self._update_print_labels()
[docs] def after_barcode__changed(self, widget):
self._update_print_labels()
[docs] def after_price__changed(self, widget):
# This gets called when setting the proxy. It is yet too soon
# to update the print labels button
if not hasattr(self, '_print_labels_btn'):
return
self._update_print_labels()
[docs] def on_print_labels_clicked(self, button, parent_label_button=None):
label_data = run_dialog(PrintLabelEditor, None, self.store,
self.model.sellable)
if label_data:
print_labels(label_data, self.store)
[docs]def test_sellable_tax_constant(): # pragma nocover
ec = api.prepare_test()
tax_constant = api.sysparam.get_object(ec.store, 'DEFAULT_PRODUCT_TAX_CONSTANT')
run_dialog(SellableTaxConstantEditor,
parent=None, store=ec.store, model=tax_constant)
print(tax_constant)
[docs]def test_price_editor(): # pragma nocover
from decimal import Decimal
ec = api.prepare_test()
sellable = ec.create_sellable()
sellable.cost = Decimal('15.55')
sellable.price = Decimal('21.50')
run_dialog(SellablePriceEditor,
parent=None, store=ec.store, model=sellable)
if __name__ == '__main__': # pragma nocover
test_price_editor()