Source code for stoq.gui.financial

# -*- Mode: Python; coding: utf-8 -*-
# vi:si:et:sw=4:sts=4:ts=4

##
## Copyright (C) 2011-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 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 General Public License for more details.
##
## You should have received a copy of the GNU 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>
##
"""
stoq/gui/financial/financial.py:

    Implementation of financial application.
"""

import datetime
import decimal

from dateutil.relativedelta import relativedelta
import gobject
import gtk
from kiwi.currency import currency
from kiwi.python import Settable
from kiwi.ui.dialogs import selectfile
from kiwi.ui.objectlist import ColoredColumn, Column
import pango
from stoqlib.api import api
from stoqlib.database.expr import Date
from stoqlib.database.queryexecuter import DateQueryState, DateIntervalQueryState
from stoqlib.domain.account import Account, AccountTransaction, AccountTransactionView
from stoqlib.domain.payment.method import PaymentMethod
from stoqlib.domain.payment.views import InPaymentView, OutPaymentView
from stoqlib.gui.base.dialogs import run_dialog
from stoqlib.gui.editors.accounteditor import AccountEditor
from stoqlib.gui.editors.accounttransactioneditor import AccountTransactionEditor
from stoqlib.gui.dialogs.spreadsheetexporterdialog import SpreadSheetExporter
from stoqlib.gui.dialogs.importerdialog import ImporterDialog
from stoqlib.gui.dialogs.financialreportdialog import FinancialReportDialog
from stoqlib.gui.search.searchcolumns import IdentifierColumn, SearchColumn
from stoqlib.gui.search.searchoptions import Any, DateSearchOption
from stoqlib.gui.search.searchfilters import DateSearchFilter
from stoqlib.gui.search.searchresultview import SearchResultListView
from stoqlib.gui.search.searchslave import SearchSlave
from stoqlib.gui.utils.keybindings import get_accels
from stoqlib.gui.utils.printing import print_report
from stoqlib.gui.widgets.accounttree import AccountTree
from stoqlib.gui.widgets.notebookbutton import NotebookCloseButton
from stoqlib.lib.dateutils import get_month_names
from stoqlib.lib.message import yesno
from stoqlib.lib.translation import stoqlib_gettext as _
from stoqlib.reporting.payment import AccountTransactionReport
from storm.expr import And, Or

from stoq.gui.shell.shellapp import ShellApp


[docs]class FinancialSearchResults(SearchResultListView):
[docs] def search_completed(self, results): page = self.page executer = page.search.get_query_executer() if executer.search_spec == AccountTransactionView: page.append_transactions(results) else: super(FinancialSearchResults, self).search_completed(results)
gobject.type_register(FinancialSearchResults)
[docs]class MonthOption(DateSearchOption): name = None year = None month = None
[docs] def get_interval(self): start = datetime.date(self.year, self.month, 1) end = start + relativedelta(months=1, days=-1) return start, end
[docs]class TransactionPage(object): # shows either a list of: # - transactions # - payments def __init__(self, model, app, parent): self.model = model self.app = app self.parent_window = parent self._block = False self._create_search() self._add_date_filter() self._setup_search() self.refresh()
[docs] def get_toplevel(self): return self.parent_window
def _create_search(self): self.search = SearchSlave(self._get_columns(self.model.kind), store=self.app.store) self.search.connect('result-item-activated', self._on_search__item_activated) self.search.enable_advanced_search() self.search.set_result_view(FinancialSearchResults) self.result_view = self.search.result_view self.result_view.page = self self.result_view.set_cell_data_func(self._on_result_view__cell_data_func) tree_view = self.search.result_view.get_treeview() tree_view.set_rules_hint(True) tree_view.set_grid_lines(gtk.TREE_VIEW_GRID_LINES_BOTH) def _add_date_filter(self): self.date_filter = DateSearchFilter(_('Date:')) self.date_filter.clear_options() self.date_filter.add_option(Any, 0) year = datetime.datetime.today().year month_names = get_month_names() for i, month in enumerate(month_names): name = month_names[i] option = type(name + 'Option', (MonthOption, ), {'name': _(name), 'month': i + 1, 'year': year}) self.date_filter.add_option(option, i + 1) self.date_filter.add_custom_options() self.date_filter.select(Any) self.search.add_filter(self.date_filter) def _append_date_query(self, field): date = self.date_filter.get_state() queries = [] if isinstance(date, DateQueryState) and date.date is not None: queries.append(Date(field) == date.date) elif isinstance(date, DateIntervalQueryState): queries.append(Date(field) >= date.start) queries.append(Date(field) <= date.end) return queries def _payment_query(self, store): executer = self.search.get_query_executer() search_spec = executer.search_spec queries = self._append_date_query(search_spec.due_date) if queries: return store.find(search_spec, And(*queries)) return store.find(search_spec) def _transaction_query(self, store): queries = [Or(self.model.id == AccountTransaction.account_id, self.model.id == AccountTransaction.source_account_id)] queries.extend(self._append_date_query(AccountTransaction.date)) return store.find(AccountTransactionView, And(*queries))
[docs] def show(self): self.search.show()
def _setup_search(self): if self.model.kind == 'account': self.search.set_search_spec(AccountTransactionView) self.search.set_text_field_columns(['description']) self.search.set_query(self._transaction_query) elif self.model.kind == 'payable': self.search.set_text_field_columns(['description', 'supplier_name']) self.search.set_search_spec(OutPaymentView) self.search.set_query(self._payment_query) elif self.model.kind == 'receivable': self.search.set_text_field_columns(['description', 'drawee']) self.search.set_search_spec(InPaymentView) self.search.set_query(self._payment_query) else: raise TypeError("unknown model kind: %r" % (self.model.kind, ))
[docs] def refresh(self): self.search.refresh()
def _get_columns(self, kind): if kind in ['payable', 'receivable']: return self._get_payment_columns() else: return self._get_account_columns() def _on_result_view__cell_data_func(self, column, renderer, account_view, text): if not isinstance(renderer, gtk.CellRendererText): return text if self.model.kind != 'account': return text trans = account_view.transaction is_imbalance = self.app._imbalance_account_id in [ trans.dest_account_id, trans.source_account_id] renderer.set_property('weight-set', is_imbalance) if is_imbalance: renderer.set_property('weight', pango.WEIGHT_BOLD) return text def _get_account_columns(self): def format_withdrawal(value): if value < 0: return currency(abs(value)).format(symbol=True, precision=2) def format_deposit(value): if value > 0: return currency(value).format(symbol=True, precision=2) if self.model.account_type == Account.TYPE_INCOME: color_func = lambda x: False else: color_func = lambda x: x < 0 return [Column('date', title=_("Date"), data_type=datetime.date, sorted=True), Column('code', title=_("Code"), data_type=unicode), Column('description', title=_("Description"), data_type=unicode, expand=True), Column('account', title=_("Account"), data_type=unicode), Column('value', title=self.model.account.get_type_label(out=False), data_type=currency, format_func=format_deposit), Column('value', title=self.model.account.get_type_label(out=True), data_type=currency, format_func=format_withdrawal), ColoredColumn('total', title=_("Total"), data_type=currency, color='red', data_func=color_func)] def _get_payment_columns(self): return [SearchColumn('due_date', title=_("Due date"), data_type=datetime.date, sorted=True), IdentifierColumn('identifier', title=_("Payment #")), SearchColumn('description', title=_("Description"), data_type=unicode, expand=True), SearchColumn('value', title=_("Value"), data_type=currency)]
[docs] def append_transactions(self, transactions): for transaction in transactions: description = transaction.get_account_description(self.model) value = transaction.get_value(self.model) # If a transaction has the source equals to the destination account. # Show the same transaction, but with reversed value. if transaction.source_account_id == transaction.dest_account_id: self._add_transaction(transaction, description, -value) self._add_transaction(transaction, description, value) self.update_totals()
def _add_transaction(self, transaction, description, value): item = Settable(transaction=transaction) self._update_transaction(item, transaction, description, value) self.search.result_view.append(item) return item def _update_transaction(self, item, transaction, description, value): item.account = description item.date = transaction.date item.description = transaction.description item.value = value item.code = transaction.code
[docs] def update_totals(self): total = decimal.Decimal('0') for item in self.search.result_view: total += item.value item.total = total
def _edit_transaction_dialog(self, item): store = api.new_store() if isinstance(item.transaction, AccountTransactionView): account_transaction = store.fetch(item.transaction.transaction) else: account_transaction = store.fetch(item.transaction) model = getattr(self.model, 'account', self.model) transaction = run_dialog(AccountTransactionEditor, self.app, store, account_transaction, model) store.confirm(transaction) if transaction: self.app.refresh_pages() self.update_totals() self.app.accounts.refresh_accounts(self.app.store) store.close()
[docs] def on_dialog__opened(self, dialog): dialog.connect('account-added', self.on_dialog__account_added)
[docs] def on_dialog__account_added(self, dialog): self.app.accounts.refresh_accounts(self.app.store)
[docs] def add_transaction_dialog(self): store = api.new_store() model = getattr(self.model, 'account', self.model) model = store.fetch(model) transaction = run_dialog(AccountTransactionEditor, self.app, store, None, model) store.confirm(transaction) if transaction: self.app.refresh_pages() self.update_totals() self.app.accounts.refresh_accounts(self.app.store) store.close()
def _on_search__item_activated(self, objectlist, item): if self.model.kind == 'account': self._edit_transaction_dialog(item)
[docs]class FinancialApp(ShellApp): app_title = _('Financial') gladefile = 'financial' def __init__(self, window, store=None): # Account id -> TransactionPage self._pages = {} self.accounts = AccountTree() ShellApp.__init__(self, window, store=store) self._tills_account_id = api.sysparam.get_object_id('TILLS_ACCOUNT') self._imbalance_account_id = api.sysparam.get_object_id('IMBALANCE_ACCOUNT') self._banks_account_id = api.sysparam.get_object_id('BANKS_ACCOUNT') # # ShellApp overrides #
[docs] def create_actions(self): group = get_accels('app.financial') actions = [ ('TransactionMenu', None, _('Transaction')), ('AccountMenu', None, _('Account')), ('Import', gtk.STOCK_ADD, _('Import...'), group.get('import'), _('Import a GnuCash or OFX file')), ('ConfigurePaymentMethods', None, _('Payment methods'), group.get('configure_payment_methods'), _('Select accounts for the payment methods on the system')), ('DeleteAccount', gtk.STOCK_DELETE, _('Delete...'), group.get('delete_account'), _('Delete the selected account')), ('DeleteTransaction', gtk.STOCK_DELETE, _('Delete...'), group.get('delete_transaction'), _('Delete the selected transaction')), ("NewAccount", gtk.STOCK_NEW, _("Account..."), group.get('new_account'), _("Add a new account")), ("NewTransaction", gtk.STOCK_NEW, _("Transaction..."), group.get('new_store'), _("Add a new transaction")), ("Edit", gtk.STOCK_EDIT, _("Edit..."), group.get('edit')), ] self.financial_ui = self.add_ui_actions('', actions, filename='financial.xml') self.set_help_section(_("Financial help"), 'app-financial') self.Edit.set_short_label(_('Edit')) self.DeleteAccount.set_short_label(_('Delete')) self.DeleteTransaction.set_short_label(_('Delete')) user = api.get_current_user(self.store) if not user.profile.check_app_permission(u'admin'): self.ConfigurePaymentMethods.set_sensitive(False)
[docs] def create_ui(self): self.trans_popup = self.uimanager.get_widget('/TransactionSelection') self.acc_popup = self.uimanager.get_widget('/AccountSelection') self.window.add_new_items([self.NewAccount, self.NewTransaction]) self.search_holder.add(self.accounts) self.accounts.show() self._create_initial_page() self._refresh_accounts()
[docs] def activate(self, refresh=True): if refresh: self.refresh_pages() self._update_actions() self._update_tooltips() self.window.SearchToolItem.set_sensitive(False)
[docs] def deactivate(self): self.uimanager.remove_ui(self.financial_ui) self.window.SearchToolItem.set_sensitive(True)
[docs] def print_activate(self): self._print_transaction_report()
[docs] def export_spreadsheet_activate(self): self._export_spreadsheet()
[docs] def get_current_page(self): widget = self._get_current_page_widget() if hasattr(widget, 'page'): return widget.page
# # Private # def _update_actions(self): is_accounts_tab = self._is_accounts_tab() self.AccountMenu.set_visible(is_accounts_tab) self.TransactionMenu.set_visible(not is_accounts_tab) self.DeleteAccount.set_visible(is_accounts_tab) self.DeleteTransaction.set_visible(not is_accounts_tab) self.window.ExportSpreadSheet.set_sensitive(True) self.window.Print.set_sensitive(not is_accounts_tab) self.NewAccount.set_sensitive(self._can_add_account()) self.DeleteAccount.set_sensitive(self._can_delete_account()) self.NewTransaction.set_sensitive(self._can_add_transaction()) self.DeleteTransaction.set_sensitive(self._can_delete_transaction()) self.Edit.set_sensitive(self._can_edit_account() or self._can_edit_transaction()) def _update_tooltips(self): if self._is_accounts_tab(): self.Edit.set_tooltip(_("Edit the selected account")) self.window.Print.set_tooltip("") else: self.Edit.set_tooltip(_("Edit the selected transaction")) self.window.Print.set_tooltip( _("Print a report of these transactions")) def _create_initial_page(self): pixbuf = self.accounts.render_icon('stoq-money', gtk.ICON_SIZE_MENU) page = self.notebook.get_nth_page(0) hbox = self._create_tab_label(_('Accounts'), pixbuf) self.notebook.set_tab_label(page, hbox) def _create_new_account(self): parent_view = None if self._is_accounts_tab(): parent_view = self.accounts.get_selected() else: page_id = self.notebook.get_current_page() widget = self.notebook.get_nth_page(page_id) page = widget.page if page.account_view.kind == 'account': parent_view = page.account_view retval = self._run_account_editor(None, parent_view) if retval: self.accounts.refresh_accounts(self.store) def _refresh_accounts(self): self.accounts.clear() self.accounts.insert_initial(self.store) def _edit_existing_account(self, account_view): assert account_view.kind == 'account' retval = self._run_account_editor(account_view, self.accounts.get_parent(account_view)) if not retval: return self.accounts.refresh_accounts(self.store) def _run_account_editor(self, model, parent_account): store = api.new_store() if model: model = store.fetch(model.account) if parent_account: if parent_account.kind in ['payable', 'receivable']: parent_account = None if api.sysparam.compare_object('IMBALANCE_ACCOUNT', parent_account): parent_account = None retval = self.run_dialog(AccountEditor, store, model=model, parent_account=parent_account) if store.confirm(retval): self.accounts.refresh_accounts(self.store) store.close() return retval def _close_current_page(self): assert self._can_close_tab() page = self.get_current_page() self._close_page(page) def _get_current_page_widget(self): page_id = self.notebook.get_current_page() widget = self.notebook.get_children()[page_id] return widget def _close_page(self, page): for page_id, child in enumerate(self.notebook.get_children()): if getattr(child, 'page', None) == page: self.notebook.remove_page(page_id) del self._pages[page.account_view.id] break else: raise AssertionError(page) def _is_accounts_tab(self): page_id = self.notebook.get_current_page() return page_id == 0 def _is_transaction_tab(self): page = self.get_current_page() if not isinstance(page, TransactionPage): return False if page.model.kind != 'account': return False if (api.sysparam.compare_object('TILLS_ACCOUNT', page.model.account) or page.model.parent_id == self._tills_account_id): return False return True def _can_close_tab(self): # The first tab is not closable return not self._is_accounts_tab() def _create_tab_label(self, title, pixbuf, account_view_id=None, page=None): hbox = gtk.HBox() image = gtk.image_new_from_pixbuf(pixbuf) hbox.pack_start(image, False, False) label = gtk.Label(title) hbox.pack_start(label, True, False) if account_view_id: button = NotebookCloseButton() if page: button.connect('clicked', lambda button: self._close_page(page)) hbox.pack_end(button, False, False) hbox.show_all() return hbox def _new_page(self, account_view): if account_view.id in self._pages: page = self._pages[account_view.id] page_id = self.notebook.page_num(page.search.vbox) else: pixbuf = self.accounts.get_pixbuf(account_view) page = TransactionPage(account_view, self, self.get_toplevel()) page.search.connect('result-selection-changed', self._on_search__result_selection_changed) page.search.connect('result-item-popup-menu', self._on_search__result_item_popup_menu) hbox = self._create_tab_label(account_view.description, pixbuf, account_view.id, page) widget = page.search.vbox widget.page = page page_id = self.notebook.append_page(widget, hbox) page.show() page.account_view = account_view self._pages[account_view.id] = page self.notebook.set_current_page(page_id) self._update_actions()
[docs] def refresh_pages(self): for page in self._pages.values(): page.refresh()
def _import(self): ffilters = [] all_filter = gtk.FileFilter() all_filter.set_name(_('All supported formats')) all_filter.add_pattern('*.ofx') all_filter.add_mime_type('application/xml') all_filter.add_mime_type('application/x-gzip') ffilters.append(all_filter) ofx_filter = gtk.FileFilter() ofx_filter.set_name(_('Open Financial Exchange (OFX)')) ofx_filter.add_pattern('*.ofx') ffilters.append(ofx_filter) gnucash_filter = gtk.FileFilter() gnucash_filter.set_name(_('GNUCash xml format')) gnucash_filter.add_mime_type('application/xml') gnucash_filter.add_mime_type('application/x-gzip') ffilters.append(gnucash_filter) with selectfile("Import", parent=self.get_toplevel(), filters=ffilters) as file_chooser: file_chooser.run() filename = file_chooser.get_filename() if not filename: return ffilter = file_chooser.get_filter() if ffilter == gnucash_filter: format = 'gnucash.xml' elif ffilter == ofx_filter: format = 'account.ofx' else: # Guess if filename.endswith('.ofx'): format = 'account.ofx' else: format = 'gnucash.xml' run_dialog(ImporterDialog, self, format, filename) # Refresh everthing after an import self.accounts.refresh_accounts(self.store) self.refresh_pages() def _export_spreadsheet(self): """Runs a dialog to export the current search results to a CSV file. """ if self._is_accounts_tab(): run_dialog(FinancialReportDialog, self, self.store) else: page = self.get_current_page() sse = SpreadSheetExporter() sse.export(object_list=page.result_view, name=self.app_title, filename_prefix=self.app_name) def _can_add_account(self): if self._is_accounts_tab(): return True return False def _can_edit_account(self): if not self._is_accounts_tab(): return False account_view = self.accounts.get_selected() if account_view is None: return False # Can only remove real accounts if account_view.kind != 'account': return False if account_view.id in [self._banks_account_id, self._imbalance_account_id, self._tills_account_id]: return False return True def _can_delete_account(self): if not self._is_accounts_tab(): return False account_view = self.accounts.get_selected() if account_view is None: return False # Can only remove real accounts if account_view.kind != 'account': return False return account_view.account.can_remove() def _can_add_transaction(self): if self._is_transaction_tab(): return True return False def _can_delete_transaction(self): if not self._is_transaction_tab(): return False page = self.get_current_page() transaction = page.result_view.get_selected() if transaction is None: return False return True def _can_edit_transaction(self): if not self._is_transaction_tab(): return False page = self.get_current_page() transaction = page.result_view.get_selected() if transaction is None: return False return True def _add_transaction(self): page = self.get_current_page() page.add_transaction_dialog() self._refresh_accounts() def _delete_account(self, account_view): store = api.new_store() account = store.fetch(account_view.account) methods = PaymentMethod.get_by_account(store, account) if methods.count() > 0: if not yesno( _('This account is used in at least one payment method.\n' 'To be able to delete it the payment methods needs to be' 're-configured first'), gtk.RESPONSE_NO, _("Configure payment methods"), _("Keep account")): store.close() return elif not yesno( _('Are you sure you want to remove account "%s" ?') % ( (account_view.description, )), gtk.RESPONSE_NO, _("Remove account"), _("Keep account")): store.close() return if account_view.id in self._pages: account_page = self._pages[account_view.id] self._close_page(account_page) self.accounts.remove(account_view) self.accounts.flush() imbalance_id = api.sysparam.get_object_id('IMBALANCE_ACCOUNT') for method in methods: method.destination_account_id = imbalance_id account.remove(store) store.commit(close=True) def _delete_transaction(self, item): msg = _('Are you sure you want to remove transaction "%s" ?') % ( (item.description)) if not yesno(msg, gtk.RESPONSE_YES, _(u"Remove transaction"), _(u"Keep transaction")): return account_transactions = self.get_current_page() account_transactions.result_view.remove(item) store = api.new_store() if isinstance(item.transaction, AccountTransactionView): account_transaction = store.fetch(item.transaction.transaction) else: account_transaction = store.fetch(item.transaction) account_transaction.delete(account_transaction.id, store=store) store.commit(close=True) account_transactions.update_totals() def _print_transaction_report(self): assert not self._is_accounts_tab() page = self.get_current_page() print_report(AccountTransactionReport, page.result_view, list(page.result_view), account=page.model, filters=page.search.get_search_filters()) # # Kiwi callbacks #
[docs] def key_escape(self): if self._can_close_tab(): self._close_current_page() return True
[docs] def key_control_w(self): if self._can_close_tab(): self._close_current_page() return True
[docs] def on_accounts__row_activated(self, ktree, account_view): self._new_page(account_view)
[docs] def on_accounts__selection_changed(self, ktree, account_view): self._update_actions()
[docs] def on_accounts__right_click(self, results, result, event): self.acc_popup.popup(None, None, None, event.button, event.time)
[docs] def on_Edit__activate(self, button): if self._is_accounts_tab(): account_view = self.accounts.get_selected() self._edit_existing_account(account_view) elif self._is_transaction_tab(): page = self.get_current_page() transaction = page.result_view.get_selected() page._edit_transaction_dialog(transaction)
[docs] def after_notebook__switch_page(self, notebook, page, page_id): self._update_actions() self._update_tooltips()
def _on_search__result_selection_changed(self, search): self._update_actions() def _on_search__result_item_popup_menu(self, search, result, event): self.trans_popup.popup(None, None, None, event.button, event.time) # Toolbar
[docs] def new_activate(self): if self._is_accounts_tab() and self._can_add_account(): self._create_new_account() elif self._is_transaction_tab() and self._can_add_transaction(): self._add_transaction()
[docs] def on_NewAccount__activate(self, action): self._create_new_account()
[docs] def on_NewTransaction__activate(self, action): self._add_transaction()
[docs] def on_DeleteAccount__activate(self, action): account_view = self.accounts.get_selected() self._delete_account(account_view)
[docs] def on_DeleteTransaction__activate(self, action): transactions = self.get_current_page() transaction = transactions.result_view.get_selected() self._delete_transaction(transaction) self.refresh_pages() self._refresh_accounts()
# Financial
[docs] def on_Import__activate(self, action): self._import()
# Edit
[docs] def on_ConfigurePaymentMethods__activate(self, action): from stoqlib.gui.dialogs.paymentmethod import PaymentMethodsDialog store = api.new_store() model = self.run_dialog(PaymentMethodsDialog, store) store.confirm(model) store.close()