Source code for piecash.core.book

from collections import defaultdict
from operator import attrgetter

from sqlalchemy import Column, VARCHAR, ForeignKey, inspect
from sqlalchemy.orm import relation, aliased, joinedload
from sqlalchemy.orm.base import instance_state
from sqlalchemy.orm.exc import NoResultFound

from . import factories
from .account import Account
from .commodity import Commodity, Price
from .transaction import Split, Transaction
from .._common import CallableList, GnucashException
from .._declbase import DeclarativeBaseGuid
from ..business.invoice import Invoice
from ..sa_extra import kvp_attribute


[docs]class Book(DeclarativeBaseGuid): """ A Book represents a GnuCash document. It is created through one of the two factory functions :func:`create_book` and :func:`open_book`. Canonical use is as a context manager like (the book is automatically closed at the end of the with block):: with create_book() as book: ... .. note:: If you do not use the context manager, do not forget to close the session explicitly (``book.close()``) to release any lock on the file/DB. The book puts at disposal several attributes to access the main objects of the GnuCash document:: # to get the book and the root_account ra = book.root_account # to get the list of accounts, commodities or transactions for acc in book.accounts: # or book.commodities or book.transactions # do something with acc # to get a specific element of these lists EUR = book.commodities(namespace="CURRENCY", mnemonic="EUR") # to get a list of all objects of some class (even non core classes) budgets = book.get(Budget) # or a specific object budget = book.get(Budget, name="my first budget") You can check a session has changes (new, deleted, changed objects) by getting the ``book.is_saved`` property. To save or cancel changes, use ``book.save()`` or ``book.cancel()``:: # save a session if it is no saved (saving a unchanged session is a no-op) if not book.is_saved: book.save() Attributes: root_account (:class:`piecash.core.account.Account`): the root account of the book root_template (:class:`piecash.core.account.Account`): the root template of the book (usage not yet clear...) default_currency (:class:`piecash.core.commodity.Commodity`): the currency of the root account (=default currency of the book) uri (str): connection string of the book (set by the GncSession when accessing the book) session (:class:`sqlalchemy.orm.session.Session`): the sqlalchemy session encapsulating the book use_trading_accounts (bool): true if option "Use trading accounts" is enabled use_split_action_field (bool): true if option "Use Split Action Field for Number" is enabled RO_threshold_day (int): value of Day Threshold for Read-Only Transactions (red line) control_mode (list(str)) : list of allowed non-standard operations like : "allow-root-subaccounts" counter_customer (int) : counter for :class:`piecash.business.person.Customer` id (link to slot "counters/gncCustomer") counter_vendor (int) : counter for :class:`piecash.business.person.Vendor` id (link to slot "counters/gncVendor") counter_employee (int) : counter for :class:`piecash.business.person.Employee` id (link to slot "counters/gncEmployee") counter_invoice (int) : counter for :class:`piecash.business.invoice.Invoice` id (link to slot "counters/gncInvoice") counter_job (int) : counter for :class:`piecash.business.invoice.Job` id (link to slot "counters/gncJob") counter_bill (int) : counter for :class:`piecash.business.invoice.Bill` id (link to slot "counters/gncBill") counter_exp_voucher (int) : counter for :class:`piecash.business.invoice.Invoice` id (link to slot "counters/gncExpVoucher") counter_order (int) : counter for :class:`piecash.business.invoice.Order` id (link to slot "counters/gncOrder") business_company_phone (str): phone number of book company (link to slit "options/Business/Company Phone Number") business_company_email (str): email of book company (link to slit "options/Business/Company Email Address") business_company_contact (str): contact person of book company (link to slit "options/Business/Company Contact Person") business_company_ID (str): ID of book company (link to slit "options/Business/Company ID") business_company_name (str): name of book company (link to slit "options/Business/Company Name") business_company_address (str): address of book company (link to slit "options/Business/Company Address") business_company_website (str): website URL of book company (link to slit "options/Business/Company Website URL") """ __tablename__ = "books" __table_args__ = {} # column definitions root_account_guid = Column( "root_account_guid", VARCHAR(length=32), ForeignKey("accounts.guid"), nullable=False, ) root_template_guid = Column( "root_template_guid", VARCHAR(length=32), ForeignKey("accounts.guid"), nullable=False, ) # relation definitions root_account = relation( "Account", # back_populates='root_book', foreign_keys=[root_account_guid], ) root_template = relation("Account", foreign_keys=[root_template_guid]) uri = None session = None # link options to KVP use_trading_accounts = kvp_attribute( "options/Accounts/Use Trading Accounts", from_gnc=lambda v: v == "t", to_gnc=lambda v: "t", default=False, ) use_split_action_field = kvp_attribute( "options/Accounts/Use Split Action Field for Number", from_gnc=lambda v: v == "t", to_gnc=lambda v: "t" if v else "f", default=False, ) RO_threshold_day = kvp_attribute( "options/Accounts/Day Threshold for Read-Only Transactions (red line)", from_gnc=lambda v: int(v), to_gnc=lambda v: float(v), default=0, ) counter_customer = kvp_attribute("counters/gncCustomer", default=0) counter_vendor = kvp_attribute("counters/gncVendor", default=0) counter_employee = kvp_attribute("counters/gncEmployee", default=0) counter_invoice = kvp_attribute("counters/gncInvoice", default=0) counter_job = kvp_attribute("counters/gncJob", default=0) counter_bill = kvp_attribute("counters/gncBill", default=0) counter_exp_voucher = kvp_attribute("counters/gncExpVoucher", default=0) counter_order = kvp_attribute("counters/gncOrder", default=0) business_company_phone = kvp_attribute( "options/Business/Company Phone Number", default="" ) business_company_email = kvp_attribute( "options/Business/Company Email Address", default="" ) business_company_contact = kvp_attribute( "options/Business/Company Contact Person", default="" ) business_company_ID = kvp_attribute("options/Business/Company ID", default="") business_company_name = kvp_attribute("options/Business/Company Name", default="") business_company_address = kvp_attribute( "options/Business/Company Address", default="" ) business_company_website = kvp_attribute( "options/Business/Company Website URL", default="" ) def __init__(self, root_account=None, root_template=None): self.root_account = root_account self.root_template = root_template def __str__(self): return "Book<{}>".format(self.uri) _control_mode = None @property def control_mode(self): if self._control_mode is None: self._control_mode = [] return self._control_mode @property def default_currency(self): return self.root_account.commodity @default_currency.setter def default_currency(self, value): assert isinstance(value, Commodity) and value.namespace == "CURRENCY" self.root_account.commodity = value
[docs] def validate(self): Book.validate_book(self.session)
[docs] @staticmethod def track_dirty(session, flush_context, instances): """ Record in session._all_changes the objects that have been modified before each flush """ for change, l in { "dirty": session.dirty, "new": session.new, "deleted": session.deleted, }.items(): for obj in l: # retrieve the dictionary of changes for the given obj attrs = session._all_changes.setdefault(id(obj), {}) # add the change of state to the list of state changes attrs.setdefault("STATE_CHANGES", []).append(change) attrs.setdefault("OBJECT", obj) # save old value of attr if not already saved # (if a value is changed multiple time, we keep only the first "old value") for k, v in instance_state(obj).committed_state.items(): if k not in attrs: attrs[k] = v
@staticmethod def validate_book(session): session.flush() # identify object to validate txs = set() # iterate on all explicitly changes objects to see # if we need to add other objects for check # skip deleted objects for attrs in session._all_changes.values(): obj = attrs["OBJECT"] for o_to_validate in obj.object_to_validate(attrs["STATE_CHANGES"]): if not inspect(o_to_validate).deleted: txs.add(o_to_validate) assert None not in txs, "No object should return None to validate. fix the code" # sort object from local to global (ensure Split checked before Transaction) and remove deleted objects from . import Account, Transaction, Split, Commodity sort_order = defaultdict( lambda: 20, {Account: 10, Transaction: 5, Split: 3, Commodity: 2} ) txs = sorted(txs, key=lambda x: sort_order[type(x)]) # for each object, validate it for tx in txs: tx.validate() _trading_accounts = None
[docs] def trading_account(self, cdty): """Return the trading account related to the commodity. If it does not exist and the option "Use Trading Accounts" is enabled, create it on the fly""" key = namespace, mnemonic = cdty.namespace, cdty.mnemonic if self._trading_accounts is None: self._trading_accounts = {} tacc = self._trading_accounts.get(key, None) if tacc: return tacc from .account import Account try: trading = self.root_account.children(name="Trading") except KeyError: trading = Account( name="Trading", type="TRADING", placeholder=1, commodity=self.default_currency, parent=self.root_account, ) try: nspc = trading.children(name=cdty.namespace) except KeyError: nspc = Account( name=namespace, type="TRADING", placeholder=1, commodity=self.default_currency, parent=trading, ) try: tacc = nspc.children(name=cdty.mnemonic) except KeyError: tacc = Account( name=mnemonic, type="TRADING", placeholder=0, commodity=cdty, parent=nspc, ) # self.flush() return tacc
# add session alike functions
[docs] def add(self, obj): """Add an object to the book (to be used if object not linked in any way to the book)""" self.session.add(obj) obj.on_book_add()
[docs] def delete(self, obj): """Delete an object from the book (to remove permanently an object)""" self.session.delete(obj)
[docs] def save(self): """Save the changes to the file/DB (=commit transaction)""" self.session.commit()
[docs] def flush(self): """Flush the book""" self.session.flush()
[docs] def cancel(self): """Cancel all the changes that have not been saved (=rollback transaction)""" self.session.rollback()
@property def is_saved(self): """Are all the changes saved to the file/DB? You can check a session has changes (new, deleted, changed objects) by getting the ``book.is_saved`` property. """ return self.session.is_saved # add context manager that close the session when leaving def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): self.close()
[docs] def close(self): """Close a session. Any changes not yet saved are rolled back. Any lock on the file/DB is released.""" session = self.session # cancel pending changes session.rollback() # if self._acquire_lock: # # remove the lock # session.delete_lock() session.close()
# add general getters for gnucash classes
[docs] def get(self, cls, **kwargs): """ Generic getter for a GnuCash object in the `GncSession`. If no kwargs is given, it returns the list of all objects of type cls (uses the sqlalchemy session.query(cls).all()). Otherwise, it gets the unique object which attributes match the kwargs (uses the sqlalchemy session.query(cls).filter_by(\*\*kwargs).one() underneath):: # to get the first account with name="Income" inc_account = session.get(Account, name="Income") # to get all accounts accs = session.get(Account) Args: cls (class): the class of the object to retrieve (Account, Price, Budget,...) kwargs (dict): the attributes to filter on Returns: object: the unique object if it exists, raises exceptions otherwise """ if kwargs: try: return self.session.query(cls).filter_by(**kwargs).one() except NoResultFound: raise ValueError("Could not find a {}({})".format(cls.__name__, kwargs)) else: return self.session.query(cls)
@property def transactions(self): """ gives easy access to all transactions in the book through a :class:`piecash.model_common.CallableList` of :class:`piecash.core.transaction.Transaction` """ from .transaction import Transaction return CallableList(self.session.query(Transaction)) @property def splits(self): """ gives easy access to all splits in the book through a :class:`piecash.model_common.CallableList` of :class:`piecash.core.transaction.Split` """ from .transaction import Split return CallableList(self.session.query(Split)) @property def accounts(self): """ gives easy access to all accounts in the book through a :class:`piecash.model_common.CallableList` of :class:`piecash.core.account.Account` """ from .account import Account return CallableList(self.session.query(Account).filter(Account.parent != None)) @property def commodities(self): """ gives easy access to all commodities in the book through a :class:`piecash.model_common.CallableList` of :class:`piecash.core.commodity.Commodity` """ from .commodity import Commodity return CallableList(self.session.query(Commodity)) @property def invoices(self): """ gives easy access to all commodities in the book through a :class:`piecash.model_common.CallableList` of :class:`piecash.core.commodity.Commodity` """ return CallableList(self.session.query(Invoice)) @property def currencies(self): """ gives easy access to all currencies in the book through a :class:`piecash.model_common.CallableList` of :class:`piecash.core.commodity.Commodity` """ from .commodity import Commodity def fallback(mnemonic): cur = factories.create_currency_from_ISO(isocode=mnemonic) self.add(cur) self.flush() return cur cl = CallableList(self.session.query(Commodity).filter_by(namespace="CURRENCY")) cl.fallback = fallback return cl @property def prices(self): """ gives easy access to all prices in the book through a :class:`piecash.model_common.CallableList` of :class:`piecash.core.commodity.Price` """ from .commodity import Price return CallableList(self.session.query(Price)) @property def customers(self): """ gives easy access to all commodities in the book through a :class:`piecash.model_common.CallableList` of :class:`piecash.business.people.Customer` """ from ..business import Customer return CallableList(self.session.query(Customer)) @property def vendors(self): """ gives easy access to all commodities in the book through a :class:`piecash.model_common.CallableList` of :class:`piecash.business.people.Vendor` """ from ..business import Vendor return CallableList(self.session.query(Vendor)) @property def employees(self): """ gives easy access to all commodities in the book through a :class:`piecash.model_common.CallableList` of :class:`piecash.business.people.Employee` """ from ..business import Employee return CallableList(self.session.query(Employee)) @property def taxtables(self): """ gives easy access to all commodities in the book through a :class:`piecash.model_common.CallableList` of :class:`piecash.business.tax.Taxtable` """ from ..business import Taxtable return CallableList(self.session.query(Taxtable)) @property def query(self): """ proxy for the query function of the underlying sqlalchemy session """ return self.session.query def preload(self): # preload list of accounts accounts = ( self.session.query(Account) .options( joinedload("splits").joinedload("transaction"), joinedload("children"), joinedload("commodity"), ) .all() ) # load all splits splits = ( self.session.query(Split) .join(Transaction) .options(joinedload("account"), joinedload("lot")) .order_by(Transaction.post_date, Split.value) .all() ) return accounts, splits
[docs] def splits_df(self, additional_fields=None): """ Return a pandas DataFrame with all splits (:class:`piecash.core.commodity.Split`) from the book :parameters: :class:`list` :return: :class:`pandas.DataFrame` """ try: import pandas except ImportError: raise GnucashException("pandas is required to output dataframes") # Initialise default argument here additional_fields = additional_fields if additional_fields else [] # preload list of accounts accounts = self.session.query(Account).all() # preload list of commodities commodities = ( self.session.query(Commodity) .filter(Commodity.namespace != "template") .all() ) # preload list of transactions transactions = self.session.query(Transaction).all() # load all splits splits = ( self.session.query(Split) .join(Transaction) .order_by(Transaction.post_date, Split.value) .all() ) # build dataframe. Adds additional transaction.guid field. fields = [ "guid", "value", "quantity", "memo", "transaction.guid", "transaction.description", "transaction.post_date", "transaction.currency.guid", "transaction.currency.mnemonic", "account.fullname", "account.commodity.guid", "account.commodity.mnemonic", ] + additional_fields fields_getter = [attrgetter(fld) for fld in fields] df_splits = pandas.DataFrame( [[fg(sp) for fg in fields_getter] for sp in splits], columns=fields ) df_splits = df_splits[df_splits["account.commodity.mnemonic"] != "template"] df_splits = df_splits.set_index("guid") return df_splits
[docs] def prices_df(self): """ Return a pandas DataFrame with all prices (:class:`piecash.core.commodity.Price`) from the book :return: :class:`pandas.DataFrame` """ try: import pandas except ImportError: raise GnucashException("pandas is required to output dataframes") # preload list of commodities commodities = self.session.query(Commodity).all() # load all prices Currency = aliased(Commodity) prices = ( self.session.query(Price) .join(Commodity, Price.commodity) .join(Currency, Price.currency) .order_by(Commodity.mnemonic, Price.date, Currency.mnemonic) .all() ) fields = [ "date", "type", "value", "commodity.guid", "commodity.mnemonic", "currency.guid", "currency.mnemonic", ] fields_getter = [attrgetter(fld) for fld in fields] df_prices = pandas.DataFrame( [[fg(pr) for fg in fields_getter] for pr in prices], columns=fields ) return df_prices