diff --git a/README.rst b/README.rst index 83d5baa..a59a4bd 100644 --- a/README.rst +++ b/README.rst @@ -9,7 +9,7 @@ pip install mds-cashbook Requires ======== -- Tryton 6.0 +- Tryton 6.8 How to ====== @@ -153,6 +153,27 @@ currency are converted into the display currency of the parent cash book. Changes ======= -*6.0.0 - 05.08.2022* +*6.8.33 - 31.12.2023* + +- remove caching +- add worker-based precalculation of cashbook-values + +*6.8.32 - 06.12.2023* + +- columns optional + +*6.8.31 - 30.11.2023* + +- optimized ir.rule + +*6.8.30 - 25.07.2023* + +- updt: optimize code, add tests + +*6.8.29 - 24.07.2023* + +- fix: type of indexes + +*6.8.28 - 05.06.2023* - init diff --git a/__init__.py b/__init__.py index 5439e0e..a2adccf 100644 --- a/__init__.py +++ b/__init__.py @@ -16,12 +16,12 @@ from .category import Category from .reconciliation import Reconciliation from .cbreport import ReconciliationReport from .currency import CurrencyRate -from .model import MemCache +from .valuestore import ValueStore +from .cron import Cron def register(): Pool.register( - MemCache, Configuration, UserConfiguration, CurrencyRate, @@ -35,6 +35,8 @@ def register(): OpenCashBookStart, RunCbReportStart, EnterBookingStart, + ValueStore, + Cron, module='cashbook', type_='model') Pool.register( ReconciliationReport, diff --git a/book.py b/book.py index 3e3fa0f..2b4eb39 100644 --- a/book.py +++ b/book.py @@ -4,30 +4,19 @@ # full copyright notices and license terms. from trytond.model import ( - Workflow, ModelView, ModelSQL, fields, Check, - tree, Index) -from trytond.pyson import Eval, Or, Bool, Id, Len + Workflow, ModelView, ModelSQL, fields, Check, tree, Index) +from trytond.pyson import Eval, Or, Bool, Id from trytond.exceptions import UserError from trytond.i18n import gettext from trytond.transaction import Transaction from trytond.pool import Pool from trytond.report import Report -from trytond.config import config from decimal import Decimal from datetime import date from sql.aggregate import Sum from sql.conditionals import Case -from .model import order_name_hierarchical, sub_ids_hierarchical, \ - AnyInArray, CACHEKEY_CURRENCY - - -# enable/disable caching of cachekey for 'currency.rate' -if config.get( - 'cashbook', 'cache_currency', default='yes' - ).lower() in ['yes', '1', 'true']: - ENA_CURRKEY = True -else: - ENA_CURRKEY = False +from .model import ( + order_name_hierarchical, sub_ids_hierarchical, AnyInArray) STATES = { @@ -73,10 +62,9 @@ class Book(tree(separator='/'), Workflow, ModelSQL, ModelView): model_name='cashbook.type', ondelete='RESTRICT', states={ 'readonly': Or( - STATES['readonly'], - Len(Eval('lines')) > 0, - ), - }, depends=DEPENDS+['lines']) + STATES['readonly'], + Eval('has_lines', False))}, + depends=DEPENDS+['has_lines']) feature = fields.Function(fields.Char( string='Feature', readonly=True, states={'invisible': True}), 'on_change_with_feature') @@ -98,6 +86,9 @@ class Book(tree(separator='/'), Workflow, ModelSQL, ModelView): string='Lines', field='cashbook', model_name='cashbook.line', states=STATES, depends=DEPENDS) + has_lines = fields.Function(fields.Boolean( + string='Has Lines', readonly=True, states={'invisible': True}), + 'on_change_with_has_lines') reconciliations = fields.One2Many( string='Reconciliations', field='cashbook', model_name='cashbook.recon', @@ -124,11 +115,14 @@ class Book(tree(separator='/'), Workflow, ModelSQL, ModelView): states={ 'readonly': Or( STATES2['readonly'], - Len(Eval('lines')) > 0, - ), + Eval('has_lines', False)), 'invisible': STATES2['invisible'], 'required': ~STATES2['invisible'], - }, depends=DEPENDS2+['lines']) + }, depends=DEPENDS2+['has_lines']) + + value_store = fields.One2Many( + string='Values', model_name='cashbook.values', field='cashbook', + readonly=True) balance = fields.Function(fields.Numeric( string='Balance', readonly=True, depends=['currency_digits'], @@ -141,7 +135,6 @@ class Book(tree(separator='/'), Workflow, ModelSQL, ModelView): help='Balance of all bookings', digits=(16, Eval('currency_digits', 2))), 'get_balance_cashbook', searcher='search_balance') - balance_ref = fields.Function(fields.Numeric( string='Balance (Ref.)', help='Balance in company currency', @@ -149,7 +142,7 @@ class Book(tree(separator='/'), Workflow, ModelSQL, ModelView): states={ 'invisible': ~Bool(Eval('company_currency')), }, depends=['company_currency_digits', 'company_currency']), - 'get_balance_cashbook') + 'get_balance_cashbook', searcher='search_balance') company_currency = fields.Function(fields.Many2One( readonly=True, string='Company Currency', states={'invisible': True}, @@ -165,9 +158,8 @@ class Book(tree(separator='/'), Workflow, ModelSQL, ModelView): states={ 'readonly': Or( STATES2['readonly'], - Len(Eval('lines', [])) > 0, - ), - }, depends=DEPENDS2+['lines']) + Eval('has_lines', False))}, + depends=DEPENDS2+['has_lines']) currency_digits = fields.Function(fields.Integer( string='Currency Digits', readonly=True), 'on_change_with_currency_digits') @@ -202,9 +194,6 @@ class Book(tree(separator='/'), Workflow, ModelSQL, ModelView): Index( t, (t.btype, Index.Equality())), - Index( - t, - (t.parent, Index.Equality())), Index( t, (t.company, Index.Equality())), @@ -360,48 +349,141 @@ class Book(tree(separator='/'), Workflow, ModelSQL, ModelView): ) return (query, tab_line) + @classmethod + def work_order_balance(cls, tables, field_name): + """ get order-query + """ + pool = Pool() + Book2 = pool.get('cashbook.book') + ValueStore = pool.get('cashbook.values') + context = Transaction().context + + query_date = context.get('date', None) + table, _ = tables[None] + if query_date is not None: + if field_name == 'balance_ref': + raise UserError(gettext( + 'cashbook.msg_nosearch_with_date', + fname=field_name, model=Book2.__name__)) + + (tab_book, tab2) = Book2.get_balance_of_cashbook_sql() + query = tab_book.select( + getattr(tab_book, field_name), + where=tab_book.cashbook == table.id) + return [query] + else: + tab_val = ValueStore.__table__() + tab_book = Book2.__table__() + + query = tab_book.join( + tab_val, + condition=( + tab_book.id == tab_val.cashbook) & ( + tab_val.field_name == field_name), + ).select( + tab_val.numvalue, + where=tab_book.id == table.id) + return [query] + @staticmethod def order_balance(tables): """ order by balance """ Book2 = Pool().get('cashbook.book') - (tab_book, tab2) = Book2.get_balance_of_cashbook_sql() - table, _ = tables[None] - - query = tab_book.select( - tab_book.balance, - where=tab_book.cashbook == table.id) - return [query] + return Book2.work_order_balance(tables, 'balance') @staticmethod def order_balance_all(tables): """ order by balance-all """ Book2 = Pool().get('cashbook.book') - (tab_book, tab2) = Book2.get_balance_of_cashbook_sql() - table, _ = tables[None] + return Book2.work_order_balance(tables, 'balance_all') - query = tab_book.select( - tab_book.balance_all, - where=tab_book.cashbook == table.id) - return [query] + @staticmethod + def order_balance_ref(tables): + """ order by balance-all + """ + Book2 = Pool().get('cashbook.book') + return Book2.work_order_balance(tables, 'balance_ref') @classmethod def search_balance(cls, name, clause): """ search in 'balance' """ - (tab_line, tab2) = cls.get_balance_of_cashbook_sql() + ValueStore = Pool().get('cashbook.values') Operator = fields.SQL_OPERATORS[clause[1]] + context = Transaction().context - query = tab_line.select( - tab_line.cashbook, - where=Operator( - getattr(tab_line, name), clause[2]), - ) - return [('id', 'in', query)] + query_date = context.get('date', None) + if query_date is not None: + + if name == 'balance_ref': + raise UserError(gettext( + 'cashbook.msg_nosearch_with_date', + fname=name, model=cls.__name__)) + + (tab_line, tab2) = cls.get_balance_of_cashbook_sql() + query = tab_line.select( + tab_line.cashbook, + where=Operator( + getattr(tab_line, name), clause[2])) + return [('id', 'in', query)] + else: + value_query = ValueStore.search([ + ('field_name', '=', clause[0]), + ('numvalue',) + tuple(clause[1:]), + ], + query=True) + return [('value_store', 'in', value_query)] + + @classmethod + def valuestore_delete_records(cls, records): + """ delete value-records + """ + ValStore = Pool().get('cashbook.values') + if records: + ValStore.delete_values(records) + + @classmethod + def valuestore_fields(cls): + """ field to update + """ + return ['balance', 'balance_all', 'balance_ref'] + + @classmethod + def valuestore_update_records(cls, records): + """ compute current values of records, + store to global storage + """ + ValStore = Pool().get('cashbook.values') + + if records: + ValStore.update_values( + cls.get_balance_values( + records, + ['balance', 'balance_all', 'balance_ref'])) @classmethod def get_balance_cashbook(cls, cashbooks, names): + """ get balance of cashbooks + """ + context = Transaction().context + + result = {x: {y.id: Decimal('0.0') for y in cashbooks} for x in names} + + # return computed values if 'date' is in context + query_date = context.get('date', None) + if query_date is not None: + return cls.get_balance_values(cashbooks, names) + + for cashbook in cashbooks: + for value in cashbook.value_store: + if value.field_name in names: + result[value.field_name][cashbook.id] = value.numvalue + return result + + @classmethod + def get_balance_values(cls, cashbooks, names): """ get balance of cashbook """ pool = Pool() @@ -409,7 +491,6 @@ class Book(tree(separator='/'), Workflow, ModelSQL, ModelView): Currency = pool.get('currency.currency') Company = pool.get('company.company') IrDate = pool.get('ir.date') - MemCache = pool.get('cashbook.memcache') tab_book = Book2.__table__() tab_comp = Company.__table__() cursor = Transaction().connection.cursor() @@ -427,28 +508,6 @@ class Book(tree(separator='/'), Workflow, ModelSQL, ModelView): except Exception: query_date = IrDate.today() - cache_keys = { - x.id: MemCache.get_key_by_record( - name='get_balance_cashbook', - record=x, - query=[{ - 'model': 'cashbook.line', - 'query': [('cashbook.parent', 'child_of', [x.id])], - }, { - 'model': 'currency.currency.rate', - 'query': [('currency.id', '=', x.currency.id)], - 'cachekey' if ENA_CURRKEY - else 'disabled': CACHEKEY_CURRENCY % x.currency.id, - }, ], - addkeys=[query_date.isoformat()]) - for x in cashbooks} - - # read from cache - (todo_cashbook, result) = MemCache.read_from_cache( - cashbooks, cache_keys, names, result) - if len(todo_cashbook) == 0: - return result - # query balances of cashbooks and sub-cashbooks with Transaction().set_context({ 'date': query_date}): @@ -473,7 +532,7 @@ class Book(tree(separator='/'), Workflow, ModelSQL, ModelView): Sum(tab_line.balance_all).as_('balance_all'), group_by=[ tab_book.id, tab_line.currency, tab_comp.currency], - where=tab_book.id.in_([x.id for x in todo_cashbook]), + where=tab_book.id.in_([x.id for x in cashbooks]), ) cursor.execute(*query) records = cursor.fetchall() @@ -485,10 +544,19 @@ class Book(tree(separator='/'), Workflow, ModelSQL, ModelView): record[2], record[5], record[1]) result['balance_ref'][record[0]] += Currency.compute( record[2], record[5], record[3]) - - MemCache.store_result(cashbooks, cache_keys, result, todo_cashbook) return result + @fields.depends('id') + def on_change_with_has_lines(self, name=None): + """ return True if cashbook has lines + (we dont use 'if self.lines:' this would slow down the client) + """ + Line = Pool().get('cashbook.line') + + if Line.search_count([('cashbook', '=', self.id)]): + return True + return False + @fields.depends('btype') def on_change_with_feature(self, name=None): """ get feature-set @@ -540,6 +608,14 @@ class Book(tree(separator='/'), Workflow, ModelSQL, ModelView): """ pass + @classmethod + def create(cls, vlist): + """ update values + """ + records = super(Book, cls).create(vlist) + cls.valuestore_update_records(records) + return records + @classmethod def write(cls, *args): """ deny update if book is not 'open' @@ -548,7 +624,9 @@ class Book(tree(separator='/'), Workflow, ModelSQL, ModelView): actions = iter(args) to_write_config = [] + to_update = [] for books, values in zip(actions, actions): + to_update.extend(books) for book in books: # deny btype-->None if lines not empty if 'btype' in values.keys(): @@ -574,7 +652,7 @@ class Book(tree(separator='/'), Workflow, ModelSQL, ModelView): 'defbook', 'book1', 'book2', 'book3', 'book4', 'book5']: cfg1 = ConfigUser.search([ - ('iduser.id', '=', book.owner.id), + ('iduser', '=', book.owner.id), ('%s.id' % x, '=', book.id)]) if len(cfg1) > 0: to_write_config.extend([cfg1, {x: None}]) @@ -582,6 +660,7 @@ class Book(tree(separator='/'), Workflow, ModelSQL, ModelView): if len(to_write_config) > 0: ConfigUser.write(*to_write_config) + cls.valuestore_update_records(to_update) @classmethod def delete(cls, books): diff --git a/book.xml b/book.xml index 9b113ee..4520409 100644 --- a/book.xml +++ b/book.xml @@ -143,8 +143,8 @@ full copyright notices and license terms. --> diff --git a/category.py b/category.py index 888697a..a10b3d1 100644 --- a/category.py +++ b/category.py @@ -11,6 +11,7 @@ from trytond.exceptions import UserError from trytond.i18n import gettext from sql.operators import Equal from .model import order_name_hierarchical +from .const import DEF_NONE sel_categorytype = [ @@ -70,7 +71,7 @@ class Category(tree(separator='/'), ModelSQL, ModelView): t, (t.name, Equal), (t.cattype, Equal), - where=(t.parent == None)), + where=(t.parent == DEF_NONE)), 'cashbook.msg_category_name_unique'), ]) diff --git a/configuration.py b/configuration.py index 2da84ee..2f2f5db 100644 --- a/configuration.py +++ b/configuration.py @@ -17,7 +17,8 @@ field_done = fields.Boolean( help='Show cashbook lines in Done-state.') field_catnamelong = fields.Boolean( string='Category: Show long name', - help='Shows the long name of the category in the Category field of a cash book line.') + help='Shows the long name of the category in the Category ' + + 'field of a cash book line.') class Configuration(ModelSingleton, ModelSQL, ModelView, UserMultiValueMixin): @@ -29,60 +30,47 @@ class Configuration(ModelSingleton, ModelSQL, ModelView, UserMultiValueMixin): domain=[ If(Eval('date_to') & Eval('date_from'), ('date_from', '<=', Eval('date_to')), - ()), - ])) + ())])) date_to = fields.MultiValue(fields.Date( string='End Date', depends=['date_from'], domain=[ If(Eval('date_to') & Eval('date_from'), ('date_from', '<=', Eval('date_to')), - ()), - ])) + ())])) checked = fields.MultiValue(field_checked) done = fields.MultiValue(field_done) catnamelong = fields.MultiValue(field_catnamelong) defbook = fields.MultiValue(fields.Many2One( string='Default Cashbook', - help='The default cashbook is selected when you open the booking wizard.', + help='The default cashbook is selected when you open ' + + 'the booking wizard.', model_name='cashbook.book', ondelete='SET NULL', - domain=[ - ('btype', '!=', None), ('state', '=', 'open'), - ])) + domain=[('btype', '!=', None), ('state', '=', 'open')])) book1 = fields.MultiValue(fields.Many2One( string='Cashbook 1', help='Cash book available in selection dialog.', model_name='cashbook.book', ondelete='SET NULL', - domain=[ - ('btype', '!=', None), ('state', '=', 'open'), - ])) + domain=[('btype', '!=', None), ('state', '=', 'open')])) book2 = fields.MultiValue(fields.Many2One( string='Cashbook 2', help='Cash book available in selection dialog.', model_name='cashbook.book', ondelete='SET NULL', - domain=[ - ('btype', '!=', None), ('state', '=', 'open'), - ])) + domain=[('btype', '!=', None), ('state', '=', 'open')])) book3 = fields.MultiValue(fields.Many2One( string='Cashbook 3', help='Cash book available in selection dialog.', model_name='cashbook.book', ondelete='SET NULL', - domain=[ - ('btype', '!=', None), ('state', '=', 'open'), - ])) + domain=[('btype', '!=', None), ('state', '=', 'open')])) book4 = fields.MultiValue(fields.Many2One( string='Cashbook 4', help='Cash book available in selection dialog.', model_name='cashbook.book', ondelete='SET NULL', - domain=[ - ('btype', '!=', None), ('state', '=', 'open'), - ])) + domain=[('btype', '!=', None), ('state', '=', 'open')])) book5 = fields.MultiValue(fields.Many2One( string='Cashbook 5', help='Cash book available in selection dialog.', model_name='cashbook.book', ondelete='SET NULL', - domain=[ - ('btype', '!=', None), ('state', '=', 'open'), - ])) + domain=[('btype', '!=', None), ('state', '=', 'open')])) @classmethod def multivalue_model(cls, field): @@ -121,21 +109,20 @@ class UserConfiguration(ModelSQL, UserValueMixin): domain=[ If(Eval('date_to') & Eval('date_from'), ('date_from', '<=', Eval('date_to')), - ()), - ]) + ())]) date_to = fields.Date( string='End Date', depends=['date_from'], domain=[ If(Eval('date_to') & Eval('date_from'), ('date_from', '<=', Eval('date_to')), - ()), - ]) + ())]) checked = field_checked done = field_done catnamelong = field_catnamelong defbook = fields.Many2One( string='Default Cashbook', - help='The default cashbook is selected when you open the booking wizard.', + help='The default cashbook is selected when you open ' + + 'the booking wizard.', model_name='cashbook.book', ondelete='SET NULL', domain=[ ('btype', '!=', None), diff --git a/const.py b/const.py new file mode 100644 index 0000000..6eaa075 --- /dev/null +++ b/const.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# This file is part of the cashbook-module from m-ds.de for Tryton. +# The COPYRIGHT file at the top level of this repository contains the +# full copyright notices and license terms. + + +DEF_NONE = None + diff --git a/cron.py b/cron.py new file mode 100644 index 0000000..36020ae --- /dev/null +++ b/cron.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# This file is part of the cashbook-module from m-ds.de for Tryton. +# The COPYRIGHT file at the top level of this repository contains the +# full copyright notices and license terms. + +from trytond.pool import PoolMeta + + +class Cron(metaclass=PoolMeta): + __name__ = 'ir.cron' + + @classmethod + def __setup__(cls): + super(Cron, cls).__setup__() + cls.method.selection.append( + ('cashbook.values|maintenance_values', "Update Cashbooks")) + +# end Cron diff --git a/cron.xml b/cron.xml new file mode 100644 index 0000000..f1bd33e --- /dev/null +++ b/cron.xml @@ -0,0 +1,15 @@ + + + + + + + cashbook.values|maintenance_values + + hours + + + + diff --git a/currency.py b/currency.py index 9d406c6..9b4bf3e 100644 --- a/currency.py +++ b/currency.py @@ -3,8 +3,7 @@ # The COPYRIGHT file at the top level of this repository contains the # full copyright notices and license terms. -from trytond.pool import Pool, PoolMeta -from .model import CACHEKEY_CURRENCY +from trytond.pool import PoolMeta, Pool class CurrencyRate(metaclass=PoolMeta): @@ -14,35 +13,51 @@ class CurrencyRate(metaclass=PoolMeta): def create(cls, vlist): """ update cache-value """ - MemCache = Pool().get('cashbook.memcache') + pool = Pool() + Cashbook = pool.get('cashbook.book') + ValueStore = pool.get('cashbook.values') records = super(CurrencyRate, cls).create(vlist) - for rate in records: - MemCache.record_update(CACHEKEY_CURRENCY % rate.currency.id, rate) + + ValueStore.update_books( + ValueStore.get_book_by_books( + Cashbook.search([ + ('currency', 'in', [ + x.currency.id for x in records])]))) return records @classmethod def write(cls, *args): """ update cache-value """ - MemCache = Pool().get('cashbook.memcache') + pool = Pool() + Cashbook = pool.get('cashbook.book') + ValueStore = pool.get('cashbook.values') + + actions = iter(args) + all_rates = [] + for rates, values in zip(actions, actions): + all_rates.extend(rates) super(CurrencyRate, cls).write(*args) - actions = iter(args) - for rates, values in zip(actions, actions): - for rate in rates: - MemCache.record_update( - CACHEKEY_CURRENCY % rate.currency.id, rate) + ValueStore.update_books( + ValueStore.get_book_by_books( + Cashbook.search([ + ('currency', 'in', [ + x.currency.id for x in all_rates])]))) @classmethod def delete(cls, records): """ set cache to None """ - MemCache = Pool().get('cashbook.memcache') + pool = Pool() + Cashbook = pool.get('cashbook.book') + ValueStore = pool.get('cashbook.values') - for record in records: - MemCache.record_update(CACHEKEY_CURRENCY % record.currency.id, None) + books = ValueStore.get_book_by_books(Cashbook.search([ + ('currency', 'in', [x.currency.id for x in records])])) super(CurrencyRate, cls).delete(records) + ValueStore.update_books(books) # end diff --git a/line.py b/line.py index 18cbb3e..34e4971 100644 --- a/line.py +++ b/line.py @@ -15,7 +15,8 @@ from sql import Literal from sql.functions import DatePart from sql.conditionals import Case from .book import sel_state_book -from .mixin import SecondCurrencyMixin, MemCacheIndexMx +from .mixin import SecondCurrencyMixin +from .const import DEF_NONE sel_payee = [ @@ -23,7 +24,7 @@ sel_payee = [ ('party.party', 'Party') ] -sel_linetype = [ +sel_linestate = [ ('edit', 'Edit'), ('check', 'Checked'), ('recon', 'Reconciled'), @@ -48,7 +49,7 @@ STATES = { DEPENDS = ['state', 'state_cashbook'] -class Line(SecondCurrencyMixin, MemCacheIndexMx, Workflow, ModelSQL, ModelView): +class Line(SecondCurrencyMixin, Workflow, ModelSQL, ModelView): 'Cashbook Line' __name__ = 'cashbook.line' @@ -75,8 +76,7 @@ class Line(SecondCurrencyMixin, MemCacheIndexMx, Workflow, ModelSQL, ModelView): states={ 'readonly': Or( STATES['readonly'], - Bool(Eval('bookingtype')) == False, - ), + ~Bool(Eval('bookingtype'))), 'required': Eval('bookingtype', '').in_(['in', 'out']), 'invisible': ~Eval('bookingtype', '').in_(['in', 'out']), }, depends=DEPENDS+['bookingtype'], @@ -161,7 +161,8 @@ class Line(SecondCurrencyMixin, MemCacheIndexMx, Workflow, ModelSQL, ModelView): splitlines = fields.One2Many( string='Split booking lines', model_name='cashbook.split', - help='Rows with different categories form the total sum of the booking', + help='Rows with different categories form the total ' + + 'sum of the booking', states={ 'invisible': ~Eval('bookingtype' '').in_(['spin', 'spout']), 'readonly': Or( @@ -197,7 +198,7 @@ class Line(SecondCurrencyMixin, MemCacheIndexMx, Workflow, ModelSQL, ModelView): state = fields.Selection( string='State', required=True, readonly=True, - selection=sel_linetype) + selection=sel_linestate) state_string = state.translated('state') state_cashbook = fields.Function(fields.Selection( string='State of Cashbook', @@ -225,9 +226,6 @@ class Line(SecondCurrencyMixin, MemCacheIndexMx, Workflow, ModelSQL, ModelView): t = cls.__table__() cls._sql_indexes.update({ - Index( - t, - (t.cashbook, Index.Equality())), Index( t, (t.date, Index.Range(order='ASC'))), @@ -245,7 +243,7 @@ class Line(SecondCurrencyMixin, MemCacheIndexMx, Workflow, ModelSQL, ModelView): (t.state, Index.Equality())), Index( t, - (t.reference, Index.Range())), + (t.reference, Index.Equality())), }) cls._sql_constraints.extend([ ('state_val2', @@ -308,7 +306,7 @@ class Line(SecondCurrencyMixin, MemCacheIndexMx, Workflow, ModelSQL, ModelView): ).select( tab_line.id, where=tab_line.bookingtype.in_(['mvin', 'mvout']) & - (tab_line.amount_2nd_currency == None) & + (tab_line.amount_2nd_currency == DEF_NONE) & (tab_book.currency != tab_book2.currency) ) lines = Line2.search([('id', 'in', query)]) @@ -344,7 +342,7 @@ class Line(SecondCurrencyMixin, MemCacheIndexMx, Workflow, ModelSQL, ModelView): for line in lines: if line.reference: if Transaction().context.get( - 'line.allow.wfedit', False) == False: + 'line.allow.wfedit', False) is False: raise UserError(gettext( 'cashbook.msg_line_denywf_by_reference', recname=line.reference.rec_name, @@ -375,7 +373,7 @@ class Line(SecondCurrencyMixin, MemCacheIndexMx, Workflow, ModelSQL, ModelView): # allow cashbook-line at range-limits if Recon.search_count([ ('state', 'in', ['check', 'done']), - ('cashbook.id', '=', line.cashbook.id), + ('cashbook', '=', line.cashbook.id), ('date_from', '<', line.date), ('date_to', '>', line.date)]) > 0: raise UserError(gettext( @@ -386,7 +384,7 @@ class Line(SecondCurrencyMixin, MemCacheIndexMx, Workflow, ModelSQL, ModelView): # reconciliations exist if Recon.search_count([ ('state', 'in', ['check', 'done']), - ('cashbook.id', '=', line.cashbook.id), + ('cashbook', '=', line.cashbook.id), ['OR', ('date_from', '=', line.date), ('date_to', '=', line.date)]]) > 1: @@ -508,19 +506,20 @@ class Line(SecondCurrencyMixin, MemCacheIndexMx, Workflow, ModelSQL, ModelView): """ credit = self.credit if self.credit is not None else Decimal('0.0') debit = self.debit if self.debit is not None else Decimal('0.0') - return '%(date)s|%(type)s|%(amount)s %(symbol)s|%(desc)s [%(category)s]' % { - 'date': Report.format_date(self.date), - 'desc': (self.description or '-')[:40], - 'amount': Report.format_number( - credit - debit, None, - digits=getattr(self.currency, 'digits', 2)), - 'symbol': getattr(self.currency, 'symbol', '-'), - 'category': self.category_view - if self.bookingtype in ['in', 'out'] - else getattr(self.booktransf, 'rec_name', '-'), - 'type': gettext( - 'cashbook.msg_line_bookingtype_%s' % - self.bookingtype)} + return '|'.join([ + Report.format_date(self.date), + gettext('cashbook.msg_line_bookingtype_%s' % self.bookingtype), + '%(amount)s %(symbol)s' % { + 'amount': Report.format_number( + credit - debit, None, + digits=getattr(self.currency, 'digits', 2)), + 'symbol': getattr(self.currency, 'symbol', '-')}, + '%(desc)s [%(category)s]' % { + 'desc': (self.description or '-')[:40], + 'category': self.category_view + if self.bookingtype in ['in', 'out'] + else getattr(self.booktransf, 'rec_name', '-')}, + ]) @staticmethod def order_state(tables): @@ -535,8 +534,7 @@ class Line(SecondCurrencyMixin, MemCacheIndexMx, Workflow, ModelSQL, ModelView): (tab_line.state == 'edit', 1), (tab_line.state.in_(['check', 'recon', 'done']), 0), else_=2), - where=tab_line.id == table.id - ) + where=tab_line.id == table.id) return [query] @staticmethod @@ -626,7 +624,8 @@ class Line(SecondCurrencyMixin, MemCacheIndexMx, Workflow, ModelSQL, ModelView): if self.bookingtype: if self.category: - if self.bookingtype not in types.get(self.category.cattype, ''): + if self.bookingtype not in types.get( + self.category.cattype, ''): self.category = None if self.bookingtype.startswith('sp'): # split booking @@ -751,7 +750,7 @@ class Line(SecondCurrencyMixin, MemCacheIndexMx, Workflow, ModelSQL, ModelView): end_value = None recons = Reconciliation.search([ - ('cashbook.id', '=', line.cashbook.id), + ('cashbook', '=', line.cashbook.id), ('date_to', '<=', line2.date), ('state', '=', 'done'), ], order=[('date_from', 'DESC')], limit=1) @@ -761,14 +760,14 @@ class Line(SecondCurrencyMixin, MemCacheIndexMx, Workflow, ModelSQL, ModelView): ('date', '<=', line2.date), ['OR', ('reconciliation', '=', None), - ('reconciliation.id', '!=', recons[0])], + ('reconciliation', '!=', recons[0])], ]) end_value = getattr(recons[0], 'end_%s' % field_name) return (query2, end_value) if line.cashbook: query = [ - ('cashbook.id', '=', line.cashbook.id), + ('cashbook', '=', line.cashbook.id), ] balance = Decimal('0.0') @@ -778,7 +777,7 @@ class Line(SecondCurrencyMixin, MemCacheIndexMx, Workflow, ModelSQL, ModelView): if line.reconciliation: if line.reconciliation.state == 'done': query.append( - ('reconciliation.id', '=', line.reconciliation.id), + ('reconciliation', '=', line.reconciliation.id), ) balance = getattr( line.reconciliation, 'start_%s' % field_name) @@ -1036,6 +1035,8 @@ class Line(SecondCurrencyMixin, MemCacheIndexMx, Workflow, ModelSQL, ModelView): def create(cls, vlist): """ add debit/credit """ + ValueStore = Pool().get('cashbook.values') + vlist = [x.copy() for x in vlist] for values in vlist: values.update(cls.add_values_from_splitlines(values)) @@ -1055,18 +1056,26 @@ class Line(SecondCurrencyMixin, MemCacheIndexMx, Workflow, ModelSQL, ModelView): recname='%(date)s|%(descr)s' % { 'date': date_txt, 'descr': values.get('description', '-')})) - return super(Line, cls).create(vlist) + records = super(Line, cls).create(vlist) + + if records: + ValueStore.update_books(ValueStore.get_book_by_line(records)) + return records @classmethod def write(cls, *args): """ deny update if cashbook.line!='open', add or update debit/credit """ + ValueStore = Pool().get('cashbook.values') + actions = iter(args) to_write = [] + to_update = [] for lines, values in zip(actions, actions): cls.check_permission_write(lines, values) + to_update.extend(lines) for line in lines: if line.reconciliation: # deny state-change to 'edit' if line is @@ -1110,12 +1119,19 @@ class Line(SecondCurrencyMixin, MemCacheIndexMx, Workflow, ModelSQL, ModelView): super(Line, cls).write(*to_write) + if to_update: + ValueStore.update_books(ValueStore.get_book_by_line(to_update)) + @classmethod def delete(cls, lines): """ deny delete if book is not 'open' or wf is not 'edit' """ + ValueStore = Pool().get('cashbook.values') + cls.check_permission_delete(lines) + to_update = ValueStore.get_book_by_line(lines) super(Line, cls).delete(lines) + ValueStore.update_books(to_update) # end Line diff --git a/line.xml b/line.xml index b26fb1a..e618696 100644 --- a/line.xml +++ b/line.xml @@ -146,7 +146,7 @@ full copyright notices and license terms. --> @@ -168,7 +168,7 @@ full copyright notices and license terms. --> diff --git a/locale/de.po b/locale/de.po index 5bf26cc..403ee1e 100644 --- a/locale/de.po +++ b/locale/de.po @@ -3,6 +3,14 @@ msgid "" msgstr "Content-Type: text/plain; charset=utf-8\n" +########### +# ir.cron # +########### +msgctxt "selection:ir.cron,method:" +msgid "Update Cashbooks" +msgstr "Kassenbücher Aktualisieren" + + ############## # ir.message # ############## @@ -158,6 +166,14 @@ msgctxt "model:ir.message,text:msg_btype_general" msgid "General" msgstr "Allgemein" +msgctxt "model:ir.message,text:msg_value_exists_in_store" +msgid "The value already exists for the record." +msgstr "Der Wert existiert für den Datensatz bereits." + +msgctxt "model:ir.message,text:msg_nosearch_with_date" +msgid "Search with 'date' no allowed for field '%(fname)s' on model '%(model)s'." +msgstr "Suche mit 'date' nicht erlaubt für Feld '%(fname)s' auf Modell '%(model)s'." + ############# # res.group # @@ -602,6 +618,10 @@ msgctxt "field:cashbook.book,booktransf_feature:" msgid "Feature" msgstr "Merkmal" +msgctxt "field:cashbook.book,value_store:" +msgid "Values" +msgstr "Werte" + ################## # cashbook.split # @@ -1657,3 +1677,27 @@ msgstr "Speichern" msgctxt "wizard_button:cashbook.enterbooking,start,savenext_:" msgid "Save & Next" msgstr "Speichern & Weiter" + + +################### +# cashbook.values # +################### +msgctxt "model:cashbook.values,name:" +msgid "Value Store" +msgstr "Wertespeicher" + +msgctxt "field:cashbook.values,resource:" +msgid "Resource" +msgstr "Ressource" + +msgctxt "field:cashbook.values,field_name:" +msgid "Field Name" +msgstr "Feldname" + +msgctxt "field:cashbook.values,numvalue:" +msgid "Value" +msgstr "Wert" + +msgctxt "field:cashbook.values,valuedigits:" +msgid "Digits" +msgstr "Dezimalstellen" diff --git a/locale/en.po b/locale/en.po index 8c5b79e..1452c27 100644 --- a/locale/en.po +++ b/locale/en.po @@ -2,6 +2,10 @@ msgid "" msgstr "Content-Type: text/plain; charset=utf-8\n" +msgctxt "selection:ir.cron,method:" +msgid "Update Cashbooks" +msgstr "Update Cashbooks" + msgctxt "model:ir.message,text:msg_type_short_unique" msgid "The Abbreviation must be unique." msgstr "The Abbreviation must be unique." @@ -154,6 +158,14 @@ msgctxt "model:ir.message,text:msg_btype_general" msgid "General" msgstr "General" +msgctxt "model:ir.message,text:msg_value_exists_in_store" +msgid "The value already exists for the record." +msgstr "The value already exists for the record." + +msgctxt "model:ir.message,text:msg_nosearch_with_date" +msgid "Search with 'date' no allowed for field '%(fname)s' on model '%(model)s'." +msgstr "Search with 'date' no allowed for field '%(fname)s' on model '%(model)s'." + msgctxt "model:res.group,name:group_cashbook" msgid "Cashbook" msgstr "Cashbook" @@ -566,6 +578,10 @@ msgctxt "field:cashbook.book,booktransf_feature:" msgid "Feature" msgstr "Feature" +msgctxt "field:cashbook.book,value_store:" +msgid "Values" +msgstr "Values" + msgctxt "model:cashbook.split,name:" msgid "Split booking line" msgstr "Split booking line" @@ -1562,3 +1578,23 @@ msgctxt "wizard_button:cashbook.enterbooking,start,save_:" msgid "Save" msgstr "Save" +msgctxt "wizard_button:cashbook.enterbooking,start,savenext_:" +msgid "Save & Next" +msgstr "Save & Next" + +msgctxt "model:cashbook.values,name:" +msgid "Value Store" +msgstr "Value Store" + +msgctxt "field:cashbook.values,resource:" +msgid "Resource" +msgstr "Resource" + +msgctxt "field:cashbook.values,field_name:" +msgid "Field Name" +msgstr "Field Name" + +msgctxt "field:cashbook.values,numvalue:" +msgid "Value" +msgstr "Value" + diff --git a/message.xml b/message.xml index ed21703..57a9823 100644 --- a/message.xml +++ b/message.xml @@ -119,6 +119,12 @@ full copyright notices and license terms. --> General + + The value already exists for the record. + + + Search with 'date' no allowed for field '%(fname)s' on model '%(model)s'. + diff --git a/mixin.py b/mixin.py index 5b4f054..e261af2 100644 --- a/mixin.py +++ b/mixin.py @@ -3,7 +3,7 @@ # The COPYRIGHT file at the top level of this repository contains the # full copyright notices and license terms. -from trytond.model import fields, Index +from trytond.model import fields from trytond.pyson import Eval, Bool, Or from trytond.pool import Pool from trytond.modules.currency.ir import rate_decimal @@ -31,20 +31,19 @@ class SecondCurrencyMixin: states={ 'readonly': Or( STATES['readonly'], - ~Bool(Eval('currency2nd')) - ), + ~Bool(Eval('currency2nd'))), 'required': Bool(Eval('currency2nd')), 'invisible': ~Bool(Eval('currency2nd')), }, depends=DEPENDS+['currency2nd_digits', 'currency2nd']) rate_2nd_currency = fields.Function(fields.Numeric( string='Rate', - help='Exchange rate between the currencies of the participating cashbooks.', + help='Exchange rate between the currencies of the ' + + 'participating cashbooks.', digits=(rate_decimal * 2, rate_decimal), states={ 'readonly': Or( STATES['readonly'], - ~Bool(Eval('currency2nd')) - ), + ~Bool(Eval('currency2nd'))), 'required': Bool(Eval('currency2nd')), 'invisible': ~Bool(Eval('currency2nd')), }, depends=DEPENDS+['currency2nd_digits', 'currency2nd']), @@ -124,15 +123,13 @@ class SecondCurrencyMixin: self.amount_2nd_currency = Currency.compute( self.currency, self.amount, - self.booktransf.currency - ) + self.booktransf.currency) if self.amount != Decimal('0.0'): self.rate_2nd_currency = \ self.amount_2nd_currency / self.amount else: self.amount_2nd_currency = self.booktransf.currency.round( - self.amount * self.rate_2nd_currency - ) + self.amount * self.rate_2nd_currency) @classmethod def set_rate_2nd_currency(cls, lines, name, value): @@ -199,25 +196,3 @@ class SecondCurrencyMixin: return 2 # end SecondCurrencyMixin - - -class MemCacheIndexMx: - """ add index to 'create_date' + 'write_date' - """ - __slots__ = () - - @classmethod - def __setup__(cls): - super(MemCacheIndexMx, cls).__setup__() - t = cls.__table__() - # add index - cls._sql_indexes.update({ - Index( - t, - (t.write_date, Index.Range())), - Index( - t, - (t.create_date, Index.Range())), - }) - -# end MemCacheIndexMx diff --git a/model.py b/model.py index 0d5598c..e50adf7 100644 --- a/model.py +++ b/model.py @@ -4,31 +4,12 @@ # full copyright notices and license terms. from trytond.model import ( - MultiValueMixin, ValueMixin, fields, Unique, Model, Index) + MultiValueMixin, ValueMixin, fields, Unique, Index) from trytond.transaction import Transaction from trytond.pool import Pool -from trytond.cache import MemoryCache -from trytond.config import config -from datetime import timedelta -from decimal import Decimal from sql import With from sql.functions import Function -from sql.conditionals import Coalesce -import copy - -if config.get('cashbook', 'memcache', default='yes').lower() \ - in ['yes', '1', 'true']: - ENABLE_CACHE = True -else: - ENABLE_CACHE = False - -if config.get('cashbook', 'sync', default='yes').lower() \ - in ['yes', '1', 'true']: - ENABLE_CACHESYNC = True -else: - ENABLE_CACHESYNC = False - -CACHEKEY_CURRENCY = 'currency-%s' +from .const import DEF_NONE class ArrayAgg(Function): @@ -84,155 +65,6 @@ class Array(Function): # end Array -class MemCache(Model): - """ store values to cache - """ - __name__ = 'cashbook.memcache' - - _cashbook_value_cache = MemoryCache( - 'cashbook.book.valuecache', - context=False, - duration=timedelta(seconds=60*60*4)) - - @classmethod - def read_value(cls, cache_key): - """ read values from cache - """ - if ENABLE_CACHE is False: - return None - return copy.deepcopy(cls._cashbook_value_cache.get(cache_key)) - - @classmethod - def store_result(cls, records, cache_keys, values, skip_records=[]): - """ store result to cache - """ - if ENABLE_CACHE is False: - return - for record in records: - if record not in skip_records: - continue - data = { - x: values[x][record.id] - for x in values.keys() if record.id in values[x].keys()} - cls._cashbook_value_cache.set( - cache_keys[record.id], copy.deepcopy(data)) - if ENABLE_CACHESYNC is True: - cls._cashbook_value_cache.sync(Transaction()) - - @classmethod - def store_value(cls, cache_key, values): - """ store values to cache - """ - if ENABLE_CACHE is False: - return - cls._cashbook_value_cache.set(cache_key, copy.deepcopy(values)) - - @classmethod - def read_from_cache(cls, records, cache_keys, names, result): - """ get stored values from memcache - """ - if ENABLE_CACHE is False: - return (records, result) - - todo_records = [] - for record in records: - values = copy.deepcopy(cls.read_value(cache_keys[record.id])) - if values: - for name in names: - if name not in values.keys(): - continue - if values[name] is None: - continue - if result[name][record.id] is None: - result[name][record.id] = Decimal('0.0') - result[name][record.id] += values[name] - else: - todo_records.append(record) - return (todo_records, result) - - @classmethod - def get_key_by_record(cls, name, record, query, addkeys=[]): - """ read records to build a cache-key - """ - pool = Pool() - cursor = Transaction().connection.cursor() - - if ENABLE_CACHE is False: - return '-' - - fname = [name, str(record.id)] - fname.extend(addkeys) - - # query the last edited record for each item in 'query' - for line in query: - if len(line.keys()) == 0: - continue - - if 'cachekey' in line.keys(): - key = cls.read_value(line['cachekey']) - if key: - fname.append(key) - continue - - Model = pool.get(line['model']) - tab_model = Model.__table__() - - tab_query = Model.search(line['query'], query=True) - qu1 = tab_model.join( - tab_query, - condition=tab_query.id == tab_model.id, - ).select( - tab_model.id, - tab_model.write_date, - tab_model.create_date, - limit=1, - order_by=[ - Coalesce( - tab_model.write_date, tab_model.create_date).desc, - tab_model.id.desc, - ], - ) - cursor.execute(*qu1) - records = cursor.fetchall() - if len(records) > 0: - fname.append(cls.genkey( - records[0][0], - records[0][1], - records[0][2], - )) - else: - fname.append('0') - - if 'cachekey' in line.keys(): - key = cls.store_value(line['cachekey'], fname[-1]) - return '-'.join(fname) - - @classmethod - def genkey(cls, id_record, write_date, create_date): - """ get key as text - """ - date_val = write_date if write_date is not None else create_date - return '-'.join([ - str(id_record), - '%s%s' % ( - 'w' if write_date is not None else 'c', - date_val.timestamp() if date_val is not None else '-'), - ]) - - @classmethod - def record_update(cls, cache_key, record): - """ update cache-value - """ - if ENABLE_CACHE is False: - return - cls.store_value( - cache_key, - cls.genkey(record.id, record.write_date, record.create_date) - if record is not None else None) - -# end mem_cache - - def sub_ids_hierarchical(model_name): """ get table with id and sub-ids """ @@ -269,7 +101,7 @@ def order_name_hierarchical(model_name, tables): lines = With('id', 'name', 'name_path', recursive=True) lines.query = tab_mod.select( tab_mod.id, tab_mod.name, Array(tab_mod.name), - where=tab_mod.parent == None, + where=tab_mod.parent == DEF_NONE, ) lines.query |= tab_mod2.join( lines, diff --git a/reconciliation.py b/reconciliation.py index 044f85c..d0d5196 100644 --- a/reconciliation.py +++ b/reconciliation.py @@ -183,7 +183,7 @@ class Reconciliation(Workflow, ModelSQL, ModelView): if Line.search_count([ ('date', '>', reconciliation.date_from), ('date', '<', reconciliation.date_to), - ('cashbook.id', '=', reconciliation.cashbook.id), + ('cashbook', '=', reconciliation.cashbook.id), ('state', 'not in', ['check', 'recon']), ]) > 0: raise UserError(gettext( @@ -204,7 +204,8 @@ class Reconciliation(Workflow, ModelSQL, ModelView): # unlink lines from reconciliation if len(reconciliation.lines) > 0: - values['lines'] = [('remove', [x.id for x in reconciliation.lines])] + values['lines'] = [ + ('remove', [x.id for x in reconciliation.lines])] return values @classmethod @@ -224,7 +225,7 @@ class Reconciliation(Workflow, ModelSQL, ModelView): lines = Line.search([ ('date', '>=', reconciliation.date_from), ('date', '<=', reconciliation.date_to), - ('cashbook.id', '=', reconciliation.cashbook.id), + ('cashbook', '=', reconciliation.cashbook.id), ('reconciliation', '=', None), ('state', 'in', ['check', 'recon']), ]) @@ -315,7 +316,7 @@ class Reconciliation(Workflow, ModelSQL, ModelView): # deny if there are lines not linked to reconciliation if Line.search_count([ - ('cashbook.id', '=', reconciliation.cashbook.id), + ('cashbook', '=', reconciliation.cashbook.id), ('reconciliation', '=', None), ['OR', [ # lines inside of date-range @@ -339,20 +340,24 @@ class Reconciliation(Workflow, ModelSQL, ModelView): def get_rec_name(self, name): """ short + name """ - return '%(from)s - %(to)s | %(start_amount)s %(symbol)s - %(end_amount)s %(symbol)s [%(num)s]' % { - 'from': Report.format_date(self.date_from, None) + return ' '.join([ + Report.format_date(self.date_from, None) if self.date_from is not None else '-', - 'to': Report.format_date(self.date_to, None) - if self.date_to is not None else '-', - 'start_amount': Report.format_number( + '-', + Report.format_date(self.date_to, None) + if self.date_to is not None else '-', + '|', + Report.format_number( self.start_amount or 0.0, None, digits=getattr(self.currency, 'digits', 2)), - 'end_amount': Report.format_number( + getattr(self.currency, 'symbol', '-'), + '-', + Report.format_number( self.end_amount or 0.0, None, digits=getattr(self.currency, 'digits', 2)), - 'symbol': getattr(self.currency, 'symbol', '-'), - 'num': len(self.lines), - } + getattr(self.currency, 'symbol', '-'), + '[%(num)s]' % {'num': len(self.lines)}, + ]) @classmethod def default_date_from(cls): @@ -405,7 +410,7 @@ class Reconciliation(Workflow, ModelSQL, ModelView): if self.cashbook: if self.date_from is not None: reconciliations = Recon.search([ - ('cashbook.id', '=', self.cashbook.id), + ('cashbook', '=', self.cashbook.id), ('date_from', '<', self.date_from), ], order=[('date_from', 'DESC')], limit=1) if len(reconciliations) > 0: @@ -457,7 +462,7 @@ class Reconciliation(Workflow, ModelSQL, ModelView): # set date_from to date_to of predecessor recons = Recon.search([ - ('cashbook.id', '=', id_cashbook), + ('cashbook', '=', id_cashbook), ], order=[('date_to', 'DESC')], limit=1) if len(recons) > 0: values['date_from'] = recons[0].date_to @@ -466,7 +471,7 @@ class Reconciliation(Workflow, ModelSQL, ModelView): # set date_to to day of last 'checked'-booking in selected cashbook lines = Line.search([ - ('cashbook.id', '=', id_cashbook), + ('cashbook', '=', id_cashbook), ('state', '=', 'check'), ('reconciliation', '=', None), ], order=[('date', 'DESC')], limit=1) diff --git a/reconciliation.xml b/reconciliation.xml index 6e6576d..ca2f603 100644 --- a/reconciliation.xml +++ b/reconciliation.xml @@ -97,7 +97,7 @@ full copyright notices and license terms. --> @@ -119,7 +119,7 @@ full copyright notices and license terms. --> diff --git a/setup.py b/setup.py index ff59617..d7a3bc9 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ """ # Always prefer setuptools over distutils -from setuptools import setup, find_packages +from setuptools import setup # To use a consistent encoding from codecs import open from os import path @@ -36,11 +36,11 @@ with open(path.join(here, 'versiondep.txt'), encoding='utf-8') as f: l2 = i.strip().split(';') if len(l2) < 4: continue - modversion[l2[0]] = {'min':l2[1], 'max':l2[2], 'prefix':l2[3]} + modversion[l2[0]] = {'min': l2[1], 'max': l2[2], 'prefix': l2[3]} # tryton-version major_version = 6 -minor_version = 0 +minor_version = 8 requires = ['python-slugify'] for dep in info.get('depends', []): @@ -51,19 +51,21 @@ for dep in info.get('depends', []): prefix = modversion[dep]['prefix'] if len(modversion[dep]['max']) > 0: - requires.append('%s_%s >= %s, <= %s' % - (prefix, dep, modversion[dep]['min'], modversion[dep]['max'])) - else : - requires.append('%s_%s >= %s' % - (prefix, dep, modversion[dep]['min'])) - else : - requires.append('%s_%s >= %s.%s, < %s.%s' % - ('trytond', dep, major_version, minor_version, + requires.append('%s_%s >= %s, <= %s' % ( + prefix, dep, modversion[dep]['min'], + modversion[dep]['max'])) + else: + requires.append('%s_%s >= %s' % ( + prefix, dep, modversion[dep]['min'])) + else: + requires.append('%s_%s >= %s.%s, < %s.%s' % ( + 'trytond', dep, major_version, minor_version, major_version, minor_version + 1)) -requires.append('trytond >= %s.%s, < %s.%s' % - (major_version, minor_version, major_version, minor_version + 1)) +requires.append('trytond >= %s.%s, < %s.%s' % ( + major_version, minor_version, major_version, minor_version + 1)) -setup(name='%s_%s' % (PREFIX, MODULE), +setup( + name='%s_%s' % (PREFIX, MODULE), version=info.get('version', '0.0.1'), description='Tryton module to add a cashbook.', long_description=long_description, @@ -74,21 +76,21 @@ setup(name='%s_%s' % (PREFIX, MODULE), author_email='service@m-ds.de', license='GPL-3', classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Environment :: Plugins', - 'Framework :: Tryton', - 'Intended Audience :: Developers', - 'Intended Audience :: Customer Service', - 'Intended Audience :: Information Technology', - 'Intended Audience :: Financial and Insurance Industry', - 'Topic :: Office/Business', - 'Topic :: Office/Business :: Financial :: Accounting', - 'Natural Language :: German', - 'Natural Language :: English', - 'Operating System :: OS Independent', - 'License :: OSI Approved :: GNU General Public License (GPL)', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', + 'Development Status :: 5 - Production/Stable', + 'Environment :: Plugins', + 'Framework :: Tryton', + 'Intended Audience :: Developers', + 'Intended Audience :: Customer Service', + 'Intended Audience :: Information Technology', + 'Intended Audience :: Financial and Insurance Industry', + 'Topic :: Office/Business', + 'Topic :: Office/Business :: Financial :: Accounting', + 'Natural Language :: German', + 'Natural Language :: English', + 'Operating System :: OS Independent', + 'License :: OSI Approved :: GNU General Public License (GPL)', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', ], keywords='tryton cashbook', @@ -97,10 +99,10 @@ setup(name='%s_%s' % (PREFIX, MODULE), 'trytond.modules.%s' % MODULE, ], package_data={ - 'trytond.modules.%s' % MODULE: (info.get('xml', []) - + ['tryton.cfg', 'locale/*.po', 'tests/*.py', - 'view/*.xml', 'icon/*.svg', 'docs/*.txt', - 'report/*.fods', 'versiondep.txt', 'README.rst']), + 'trytond.modules.%s' % MODULE: (info.get('xml', []) + [ + 'tryton.cfg', 'locale/*.po', 'tests/*.py', + 'view/*.xml', 'icon/*.svg', 'docs/*.txt', + 'report/*.fods', 'versiondep.txt', 'README.rst']), }, install_requires=requires, diff --git a/splitline.py b/splitline.py index a09bd77..6546992 100644 --- a/splitline.py +++ b/splitline.py @@ -9,9 +9,9 @@ from trytond.pool import Pool from trytond.pyson import Eval, If from trytond.report import Report from trytond.i18n import gettext -from .line import sel_bookingtype, STATES, DEPENDS +from .line import sel_bookingtype, sel_linestate, STATES, DEPENDS from .book import sel_state_book -from .mixin import SecondCurrencyMixin, MemCacheIndexMx +from .mixin import SecondCurrencyMixin sel_linetype = [ @@ -25,7 +25,7 @@ sel_target = [ ] -class SplitLine(SecondCurrencyMixin, MemCacheIndexMx, ModelSQL, ModelView): +class SplitLine(SecondCurrencyMixin, ModelSQL, ModelView): 'Split booking line' __name__ = 'cashbook.split' @@ -90,7 +90,7 @@ class SplitLine(SecondCurrencyMixin, MemCacheIndexMx, ModelSQL, ModelView): selection=sel_bookingtype), 'on_change_with_bookingtype') state = fields.Function(fields.Selection( string='State', readonly=True, - selection=sel_linetype), 'on_change_with_state') + selection=sel_linestate), 'on_change_with_state') cashbook = fields.Function(fields.Many2One( string='Cashbook', readonly=True, states={'invisible': True}, model_name='cashbook.book'), diff --git a/splitline.xml b/splitline.xml index d2d3f87..4ab74c0 100644 --- a/splitline.xml +++ b/splitline.xml @@ -81,7 +81,7 @@ full copyright notices and license terms. --> @@ -103,7 +103,7 @@ full copyright notices and license terms. --> diff --git a/tests/book.py b/tests/book.py index 8678173..fa7d028 100644 --- a/tests/book.py +++ b/tests/book.py @@ -307,6 +307,10 @@ class BookTestCase(object): Book.search_count([('balance_all', '<', Decimal('5.0'))]), 0) + self.assertEqual( + Book.search_count([('balance_ref', '<', Decimal('5.0'))]), + 0) + @with_transaction() def test_book_deny_btype_set_none(self): """ create cashbook, add lines, diff --git a/tests/currency.py b/tests/currency.py deleted file mode 100644 index 316ddb3..0000000 --- a/tests/currency.py +++ /dev/null @@ -1,85 +0,0 @@ -# -*- coding: utf-8 -*- -# This file is part of the cashbook-module from m-ds for Tryton. -# The COPYRIGHT file at the top level of this repository contains the -# full copyright notices and license terms. - -from trytond.tests.test_tryton import with_transaction -from trytond.pool import Pool -from trytond.modules.cashbook.model import CACHEKEY_CURRENCY, ENABLE_CACHE -from datetime import date -from decimal import Decimal -import time - - -class CurrencyTestCase(object): - """ test currency - """ - @with_transaction() - def test_currency_update_cache(self): - """ add/update/del rate of currency, check cache - """ - pool = Pool() - MemCache = pool.get('cashbook.memcache') - Currency = pool.get('currency.currency') - CurrencyRate = pool.get('currency.currency.rate') - - self.prep_config() - self.prep_company() - - MemCache._cashbook_value_cache.clear_all() - currency, = Currency.search([('name', '=', 'usd')]) - - cache_key = CACHEKEY_CURRENCY % currency.id - - # cache should be empty - self.assertEqual(MemCache.read_value(cache_key), None) - CurrencyRate.delete(currency.rates) - self.assertEqual(len(currency.rates), 0) - - # add rate - Currency.write(*[ - [currency], - { - 'rates': [('create', [{ - 'date': date(2022, 5, 1), - 'rate': Decimal('1.05'), - }])], - }]) - self.assertEqual(len(currency.rates), 1) - - # expected key - value = '%d-c%s' % ( - currency.rates[0].id, - str(currency.rates[0].create_date.timestamp())) - if ENABLE_CACHE is True: - self.assertEqual(MemCache.read_value(cache_key), value) - else: - self.assertEqual(MemCache.read_value(cache_key), None) - time.sleep(1.0) - - Currency.write(*[ - [currency], - { - 'rates': [ - ('write', [currency.rates[0].id], { - 'rate': Decimal('1.06'), - })], - }]) - self.assertEqual(len(currency.rates), 1) - - value = '%d-w%s' % ( - currency.rates[0].id, - str(currency.rates[0].write_date.timestamp())) - if ENABLE_CACHE is True: - self.assertEqual(MemCache.read_value(cache_key), value) - else: - self.assertEqual(MemCache.read_value(cache_key), None) - - Currency.write(*[ - [currency], - { - 'rates': [('delete', [currency.rates[0].id])], - }]) - self.assertEqual(MemCache.read_value(cache_key), None) - -# end CurrencyTestCase diff --git a/tests/line.py b/tests/line.py index 63a6682..2175a11 100644 --- a/tests/line.py +++ b/tests/line.py @@ -291,6 +291,8 @@ class LineTestCase(object): self.assertEqual(books[0].lines[0].reference, None) self.assertEqual(len(books[0].lines[0].references), 1) + self.prep_valstore_run_worker() + self.assertEqual( books[0].lines[0].rec_name, '05/05/2022|to|-10.00 usd|Transfer USD --> ' + @@ -366,6 +368,8 @@ class LineTestCase(object): self.assertEqual(books[0].lines[0].reference, None) self.assertEqual(len(books[0].lines[0].references), 1) + self.prep_valstore_run_worker() + self.assertEqual( books[0].lines[0].rec_name, '05/05/2022|from|10.00 usd|Transfer USD <-- ' + @@ -458,6 +462,8 @@ class LineTestCase(object): self.assertEqual(books[0].lines[0].reference, None) self.assertEqual(len(books[0].lines[0].references), 1) + self.prep_valstore_run_worker() + # 10 CHF --> USD: USD = CHF * 1.05 / 1.04 # 10 CHF = 10.0961538 USD # EUR | USD | CHF @@ -1147,6 +1153,8 @@ class LineTestCase(object): # set line to 'checked', this creates the counterpart Line.wfcheck(list(book.lines)) + self.prep_valstore_run_worker() + self.assertEqual(len(book.lines), 1) self.assertEqual( book.lines[0].rec_name, @@ -1216,6 +1224,8 @@ class LineTestCase(object): # set line to 'checked', this creates the counterpart Line.wfcheck(list(book.lines)) + self.prep_valstore_run_worker() + self.assertEqual(len(book.lines), 1) self.assertEqual( book.lines[0].rec_name, diff --git a/tests/splitline.py b/tests/splitline.py index 629b7e9..477e23b 100644 --- a/tests/splitline.py +++ b/tests/splitline.py @@ -73,6 +73,26 @@ class SplitLineTestCase(object): book.lines[0].splitlines[1].rec_name, 'Rev/Sp|6.00 usd|from cashbook [Cat1]') + # check function fields + self.assertEqual( + book.lines[0].splitlines[0].category_view, + 'Cat1') + self.assertEqual(book.lines[0].splitlines[0].date, date(2022, 5, 1)) + self.assertEqual(book.lines[0].splitlines[0].target.rec_name, 'Cat1') + self.assertEqual(book.lines[0].splitlines[0].currency.rec_name, 'usd') + self.assertEqual(book.lines[0].splitlines[0].currency_digits, 2) + self.assertEqual(book.lines[0].splitlines[0].bookingtype, 'spin') + self.assertEqual(book.lines[0].splitlines[0].state, 'edit') + self.assertEqual( + book.lines[0].splitlines[0].cashbook.rec_name, + 'Book 1 | 11.00 usd | Open') + self.assertEqual(book.lines[0].splitlines[0].feature, 'gen') + self.assertEqual(book.lines[0].splitlines[0].booktransf_feature, None) + self.assertEqual(book.lines[0].splitlines[0].state_cashbook, 'open') + self.assertEqual( + book.lines[0].splitlines[0].owner_cashbook.rec_name, + 'Administrator') + @with_transaction() def test_splitline_category_and_transfer(self): """ add book, line, two split-lines, diff --git a/tests/test_module.py b/tests/test_module.py index 87f9d68..f2f5687 100644 --- a/tests/test_module.py +++ b/tests/test_module.py @@ -14,11 +14,11 @@ from .config import ConfigTestCase from .category import CategoryTestCase from .reconciliation import ReconTestCase from .bookingwiz import BookingWizardTestCase -from .currency import CurrencyTestCase +from .valuestore import ValuestoreTestCase class CashbookTestCase( - CurrencyTestCase, + ValuestoreTestCase, BookingWizardTestCase, ReconTestCase, CategoryTestCase, diff --git a/tests/valuestore.py b/tests/valuestore.py new file mode 100644 index 0000000..885c00e --- /dev/null +++ b/tests/valuestore.py @@ -0,0 +1,833 @@ +# -*- coding: utf-8 -*- +# This file is part of the cashbook-module from m-ds for Tryton. +# The COPYRIGHT file at the top level of this repository contains the +# full copyright notices and license terms. + +from datetime import date, timedelta +from decimal import Decimal +from trytond.tests.test_tryton import with_transaction +from trytond.pool import Pool +from trytond.exceptions import UserError +from trytond.transaction import Transaction + + +class ValuestoreTestCase(object): + """ test storage of values + """ + @with_transaction() + def test_valstore_update_currency_rate(self): + """ create cashbook, check update of cashbook on + update of rates of currency + """ + pool = Pool() + Book = pool.get('cashbook.book') + ValueStore = pool.get('cashbook.values') + Currency = pool.get('currency.currency') + Queue = pool.get('ir.queue') + + types = self.prep_type() + company = self.prep_company() + (usd, euro) = self.prep_2nd_currency(company) + self.assertEqual(company.currency.rec_name, 'Euro') + + with Transaction().set_context({ + 'company': company.id, + 'date': date(2022, 5, 20)}): + + self.assertEqual(Queue.search_count([]), 0) + + category = self.prep_category(cattype='in') + party = self.prep_party() + book, = Book.create([{ + 'name': 'Book 1', + 'btype': types.id, + 'company': company.id, + 'currency': usd.id, + 'number_sequ': self.prep_sequence().id, + 'start_date': date(2022, 5, 1), + 'lines': [('create', [{ + 'date': date(2022, 5, 1), + 'description': '10 US$', + 'category': category.id, + 'bookingtype': 'in', + 'amount': Decimal('10.0'), + 'party': party.id, + }, { + 'date': date(2022, 5, 10), + 'description': '5 US$', + 'category': category.id, + 'bookingtype': 'in', + 'amount': Decimal('5.0'), + 'party': party.id, + }])], + }]) + + # run worker + self.assertEqual( + ValueStore.search_count([]), + len(Book.valuestore_fields())) + self.prep_valstore_run_worker() + + book, = Book.search([]) + self.assertEqual(book.rec_name, 'Book 1 | 15.00 usd | Open') + self.assertEqual(book.balance, Decimal('15.0')) + self.assertEqual(book.balance_all, Decimal('15.0')) + self.assertEqual(book.balance_ref, Decimal('14.29')) + self.assertEqual( + len(book.value_store), + len(Book.valuestore_fields())) + self.assertEqual( + book.value_store[0].rec_name, + '[Book 1 | 15.00 usd | Open]|balance|15.00|2') + self.assertEqual( + book.value_store[1].rec_name, + '[Book 1 | 15.00 usd | Open]|balance_all|15.00|2') + self.assertEqual( + book.value_store[2].rec_name, + '[Book 1 | 15.00 usd | Open]|balance_ref|14.29|2') + + # add rate to usd + self.assertEqual(Queue.search_count([]), 0) + Currency.write(*[ + [usd], + { + 'rates': [('create', [{ + 'date': date(2022, 5, 6), + 'rate': Decimal('1.08'), + }])], + }]) + self.assertEqual(Queue.search_count([]), 1) + self.prep_valstore_run_worker() + self.assertEqual(Queue.search_count([]), 0) + + # check reference-currency + book, = Book.search([]) + self.assertEqual(book.rec_name, 'Book 1 | 15.00 usd | Open') + self.assertEqual(book.balance, Decimal('15.0')) + self.assertEqual(book.balance_all, Decimal('15.0')) + self.assertEqual(book.balance_ref, Decimal('13.89')) + self.assertEqual( + len(book.value_store), + len(Book.valuestore_fields())) + self.assertEqual( + book.value_store[0].rec_name, + '[Book 1 | 15.00 usd | Open]|balance|15.00|2') + self.assertEqual( + book.value_store[1].rec_name, + '[Book 1 | 15.00 usd | Open]|balance_all|15.00|2') + self.assertEqual( + book.value_store[2].rec_name, + '[Book 1 | 15.00 usd | Open]|balance_ref|13.89|2') + + # find rate + self.assertEqual(len(usd.rates), 2) + self.assertEqual(usd.rates[0].date, date(2022, 5, 6)) + self.assertEqual(usd.rates[0].rate, Decimal('1.08')) + + # update rate + self.assertEqual(Queue.search_count([]), 0) + Currency.write(*[ + [usd], + { + 'rates': [( + 'write', + [usd.rates[0]], + {'rate': Decimal('1.12')})], + }]) + self.assertEqual(Queue.search_count([]), 1) + self.prep_valstore_run_worker() + self.assertEqual(Queue.search_count([]), 0) + + book, = Book.search([]) + self.assertEqual(book.rec_name, 'Book 1 | 15.00 usd | Open') + self.assertEqual(book.balance_ref, Decimal('13.39')) + self.assertEqual( + book.value_store[2].rec_name, + '[Book 1 | 15.00 usd | Open]|balance_ref|13.39|2') + + # delete rate + self.assertEqual(Queue.search_count([]), 0) + Currency.write(*[ + [usd], + { + 'rates': [('delete', [usd.rates[0]])], + }]) + self.assertEqual(Queue.search_count([]), 1) + self.prep_valstore_run_worker() + self.assertEqual(Queue.search_count([]), 0) + + book, = Book.search([]) + self.assertEqual(book.rec_name, 'Book 1 | 15.00 usd | Open') + self.assertEqual(book.balance_ref, Decimal('14.29')) + self.assertEqual( + book.value_store[2].rec_name, + '[Book 1 | 15.00 usd | Open]|balance_ref|14.29|2') + + def prep_valstore_run_worker(self): + """ run tasks from queue + """ + Queue = Pool().get('ir.queue') + + tasks = Queue.search([]) + for task in tasks: + task.run() + Queue.delete(tasks) + + @with_transaction() + def test_valstore_update_store_values(self): + """ create cashbook, store value + """ + pool = Pool() + Book = pool.get('cashbook.book') + ValueStore = pool.get('cashbook.values') + Queue = pool.get('ir.queue') + + types = self.prep_type() + company = self.prep_company() + (usd, euro) = self.prep_2nd_currency(company) + self.assertEqual(company.currency.rec_name, 'Euro') + + with Transaction().set_context({'company': company.id}): + + self.assertEqual(Queue.search_count([]), 0) + + category = self.prep_category(cattype='in') + party = self.prep_party() + book, = Book.create([{ + 'name': 'Book 1', + 'btype': types.id, + 'company': company.id, + 'currency': usd.id, + 'number_sequ': self.prep_sequence().id, + 'start_date': date(2022, 5, 1), + 'lines': [('create', [{ + 'date': date(2022, 5, 1), + 'description': '10 US$', + 'category': category.id, + 'bookingtype': 'in', + 'amount': Decimal('10.0'), + 'party': party.id, + }, { + 'date': date(2022, 5, 10), + 'description': '5 US$', + 'category': category.id, + 'bookingtype': 'in', + 'amount': Decimal('5.0'), + 'party': party.id, + }])], + }]) + + # run worker + self.assertEqual( + ValueStore.search_count([]), + len(Book.valuestore_fields())) + self.prep_valstore_run_worker() + + # check values until 2022-05-05 + with Transaction().set_context({ + 'date': date(2022, 5, 5)}): + + book, = Book.search([]) + self.assertEqual(book.rec_name, 'Book 1 | 10.00 usd | Open') + self.assertEqual(book.balance, Decimal('10.0')) + self.assertEqual(book.balance_all, Decimal('15.0')) + self.assertEqual(book.balance_ref, Decimal('14.29')) + self.assertEqual( + len(book.value_store), + len(Book.valuestore_fields())) + self.assertEqual( + book.value_store[0].rec_name, + '[Book 1 | 10.00 usd | Open]|balance|15.00|2') + self.assertEqual( + book.value_store[1].rec_name, + '[Book 1 | 10.00 usd | Open]|balance_all|15.00|2') + self.assertEqual( + book.value_store[2].rec_name, + '[Book 1 | 10.00 usd | Open]|balance_ref|14.29|2') + + # values created by book-create, without context + self.assertEqual( + ValueStore.search_count([]), + len(Book.valuestore_fields())) + + values = ValueStore.search([], order=[('field_name', 'ASC')]) + self.assertEqual( + len(values), + len(Book.valuestore_fields())) + self.assertEqual( + values[0].rec_name, + '[Book 1 | 10.00 usd | Open]|balance|15.00|2') + self.assertEqual( + values[1].rec_name, + '[Book 1 | 10.00 usd | Open]|balance_all|15.00|2') + self.assertEqual( + values[2].rec_name, + '[Book 1 | 10.00 usd | Open]|balance_ref|14.29|2') + + # check write of too much digits + self.assertRaisesRegex( + UserError, + r'The number of digits in the value ' + + r'"' + r"'12.345'" + + r'" for field "Value" in "[Book 1 | 10\.00 usd | ' + + r'Open]|balance|12\.35|2" of "Value Store" exceeds ' + + r'the limit of "2".', + ValueStore.write, + *[ + [values[0]], + { + 'numvalue': Decimal('12.345'), + } + ]) + + # update with context + Book.valuestore_update_records([book]) + + values = ValueStore.search([], order=[('field_name', 'ASC')]) + self.assertEqual( + len(values), + len(Book.valuestore_fields())) + + self.assertEqual( + values[0].rec_name, + '[Book 1 | 10.00 usd | Open]|balance|10.00|2') + self.assertEqual( + values[1].rec_name, + '[Book 1 | 10.00 usd | Open]|balance_all|15.00|2') + self.assertEqual( + values[2].rec_name, + '[Book 1 | 10.00 usd | Open]|balance_ref|14.29|2') + + # check values until 2022-05-15 + with Transaction().set_context({ + 'date': date(2022, 5, 15)}): + + book, = Book.search([]) + self.assertEqual(book.rec_name, 'Book 1 | 15.00 usd | Open') + self.assertEqual(book.balance, Decimal('15.0')) + self.assertEqual(book.balance_all, Decimal('15.0')) + self.assertEqual(book.balance_ref, Decimal('14.29')) + + # update values + self.assertEqual( + ValueStore.search_count([]), + len(Book.valuestore_fields())) + Book.valuestore_update_records([book]) + + values = ValueStore.search( + [('field_name', 'in', [ + 'balance', 'balance_all', 'balance_ref'])], + order=[('field_name', 'ASC')]) + self.assertEqual(len(values), 3) + + self.assertEqual( + values[0].rec_name, + '[Book 1 | 15.00 usd | Open]|balance|15.00|2') + self.assertEqual( + values[1].rec_name, + '[Book 1 | 15.00 usd | Open]|balance_all|15.00|2') + self.assertEqual( + values[2].rec_name, + '[Book 1 | 15.00 usd | Open]|balance_ref|14.29|2') + + # delete book, should delete values + Book.write(*[ + [book], + {'lines': [('delete', [x.id for x in book.lines])]} + ]) + self.assertEqual( + ValueStore.search_count([]), + len(Book.valuestore_fields())) + Book.delete([book]) + self.assertEqual(ValueStore.search_count([]), 0) + + @with_transaction() + def test_valstore_update_store_values_line(self): + """ create cashbooks hierarchical, add lines + check update of parent cashbooks + """ + pool = Pool() + Book = pool.get('cashbook.book') + Line = pool.get('cashbook.line') + ValueStore = pool.get('cashbook.values') + + types = self.prep_type() + company = self.prep_company() + (usd, euro) = self.prep_2nd_currency(company) + self.assertEqual(company.currency.rec_name, 'Euro') + + with Transaction().set_context({'company': company.id}): + category = self.prep_category(cattype='in') + party = self.prep_party() + book, = Book.create([{ + 'name': 'Lev 0', + 'btype': types.id, + 'company': company.id, + 'currency': usd.id, + 'number_sequ': self.prep_sequence().id, + 'start_date': date(2022, 5, 1), + 'lines': [('create', [{ + 'date': date(2022, 5, 1), + 'description': '10 US$', + 'category': category.id, + 'bookingtype': 'in', + 'amount': Decimal('10.0'), + 'party': party.id, + }])], + 'childs': [('create', [{ + 'name': 'Lev 1a', + 'btype': types.id, + 'company': company.id, + 'currency': euro.id, + 'number_sequ': self.prep_sequence().id, + 'start_date': date(2022, 5, 1), + }, { + 'name': 'Lev 1b', + 'btype': types.id, + 'company': company.id, + 'currency': euro.id, + 'number_sequ': self.prep_sequence().id, + 'start_date': date(2022, 5, 1), + }])], + }]) + self.assertEqual(book.rec_name, 'Lev 0 | 10.00 usd | Open') + self.assertEqual(len(book.lines), 1) + self.assertEqual( + book.lines[0].rec_name, + '05/01/2022|Rev|10.00 usd|10 US$ [Cat1]') + self.assertEqual(len(book.childs), 2) + self.assertEqual( + book.childs[0].rec_name, 'Lev 0/Lev 1a | 0.00 € | Open') + self.assertEqual(len(book.childs[0].lines), 0) + self.assertEqual( + book.childs[1].rec_name, 'Lev 0/Lev 1b | 0.00 € | Open') + self.assertEqual(len(book.childs[1].lines), 0) + + self.assertEqual( + ValueStore.search_count([]), + 3 * len(Book.valuestore_fields())) + self.prep_valstore_run_worker() + + values = ValueStore.search( + [('field_name', 'in', [ + 'balance', 'balance_all', 'balance_ref'])], + order=[('cashbook', 'ASC'), ('field_name', 'ASC')]) + self.assertEqual(len(values), 3 * 3) + + self.assertEqual( + values[0].rec_name, + '[Lev 0 | 10.00 usd | Open]|balance|10.00|2') + self.assertEqual( + values[1].rec_name, + '[Lev 0 | 10.00 usd | Open]|balance_all|10.00|2') + self.assertEqual( + values[2].rec_name, + '[Lev 0 | 10.00 usd | Open]|balance_ref|9.52|2') + + self.assertEqual( + values[3].rec_name, + '[Lev 0/Lev 1a | 0.00 € | Open]|balance|0.00|2') + self.assertEqual( + values[4].rec_name, + '[Lev 0/Lev 1a | 0.00 € | Open]|balance_all|0.00|2') + self.assertEqual( + values[5].rec_name, + '[Lev 0/Lev 1a | 0.00 € | Open]|balance_ref|0.00|2') + + self.assertEqual( + values[6].rec_name, + '[Lev 0/Lev 1b | 0.00 € | Open]|balance|0.00|2') + self.assertEqual( + values[7].rec_name, + '[Lev 0/Lev 1b | 0.00 € | Open]|balance_all|0.00|2') + self.assertEqual( + values[8].rec_name, + '[Lev 0/Lev 1b | 0.00 € | Open]|balance_ref|0.00|2') + + # add bookings + Line.create([{ + 'cashbook': values[0].cashbook.id, # Lev 0 + 'amount': Decimal('2.0'), + 'bookingtype': 'in', + 'category': category.id, + 'date': date(2022, 5, 10), + }, { + 'cashbook': values[3].cashbook.id, # Lev 1a + 'amount': Decimal('3.0'), + 'bookingtype': 'in', + 'category': category.id, + 'date': date(2022, 5, 10), + }]) + + # add 'date' to context, will return computed + # (not stored) values + with Transaction().set_context({'date': date(2022, 5, 10)}): + values = ValueStore.search( + [('field_name', 'in', [ + 'balance', 'balance_all', 'balance_ref'])], + order=[('cashbook', 'ASC'), ('field_name', 'ASC')]) + self.assertEqual(len(values), 9) + self.assertEqual( + values[0].rec_name, + '[Lev 0 | 15.15 usd | Open]|balance|10.00|2') + self.assertEqual( + values[1].rec_name, + '[Lev 0 | 15.15 usd | Open]|balance_all|10.00|2') + self.assertEqual( + values[2].rec_name, + '[Lev 0 | 15.15 usd | Open]|balance_ref|9.52|2') + + self.assertEqual( + values[3].rec_name, + '[Lev 0/Lev 1a | 3.00 € | Open]|balance|0.00|2') + self.assertEqual( + values[4].rec_name, + '[Lev 0/Lev 1a | 3.00 € | Open]|balance_all|0.00|2') + self.assertEqual( + values[5].rec_name, + '[Lev 0/Lev 1a | 3.00 € | Open]|balance_ref|0.00|2') + + self.assertEqual( + values[6].rec_name, + '[Lev 0/Lev 1b | 0.00 € | Open]|balance|0.00|2') + self.assertEqual( + values[7].rec_name, + '[Lev 0/Lev 1b | 0.00 € | Open]|balance_all|0.00|2') + self.assertEqual( + values[8].rec_name, + '[Lev 0/Lev 1b | 0.00 € | Open]|balance_ref|0.00|2') + + # before run of workers - w/o 'date' in context + values = ValueStore.search( + [('field_name', 'in', [ + 'balance', 'balance_all', 'balance_ref'])], + order=[('cashbook', 'ASC'), ('field_name', 'ASC')]) + self.assertEqual(len(values), 9) + self.assertEqual( + values[0].rec_name, + '[Lev 0 | 10.00 usd | Open]|balance|10.00|2') + self.assertEqual( + values[1].rec_name, + '[Lev 0 | 10.00 usd | Open]|balance_all|10.00|2') + self.assertEqual( + values[2].rec_name, + '[Lev 0 | 10.00 usd | Open]|balance_ref|9.52|2') + + self.assertEqual( + values[3].rec_name, + '[Lev 0/Lev 1a | 0.00 € | Open]|balance|0.00|2') + self.assertEqual( + values[4].rec_name, + '[Lev 0/Lev 1a | 0.00 € | Open]|balance_all|0.00|2') + self.assertEqual( + values[5].rec_name, + '[Lev 0/Lev 1a | 0.00 € | Open]|balance_ref|0.00|2') + + self.assertEqual( + values[6].rec_name, + '[Lev 0/Lev 1b | 0.00 € | Open]|balance|0.00|2') + self.assertEqual( + values[7].rec_name, + '[Lev 0/Lev 1b | 0.00 € | Open]|balance_all|0.00|2') + self.assertEqual( + values[8].rec_name, + '[Lev 0/Lev 1b | 0.00 € | Open]|balance_ref|0.00|2') + + self.prep_valstore_run_worker() + + # after run of workers + values = ValueStore.search( + [('field_name', 'in', [ + 'balance', 'balance_all', 'balance_ref'])], + order=[('cashbook', 'ASC'), ('field_name', 'ASC')]) + self.assertEqual(len(values), 9) + + self.assertEqual( + values[0].rec_name, + '[Lev 0 | 15.15 usd | Open]|balance|15.15|2') + self.assertEqual( + values[1].rec_name, + '[Lev 0 | 15.15 usd | Open]|balance_all|15.15|2') + self.assertEqual( + values[2].rec_name, + '[Lev 0 | 15.15 usd | Open]|balance_ref|14.43|2') + + self.assertEqual( + values[3].rec_name, + '[Lev 0/Lev 1a | 3.00 € | Open]|balance|3.00|2') + self.assertEqual( + values[4].rec_name, + '[Lev 0/Lev 1a | 3.00 € | Open]|balance_all|3.00|2') + self.assertEqual( + values[5].rec_name, + '[Lev 0/Lev 1a | 3.00 € | Open]|balance_ref|3.00|2') + + self.assertEqual( + values[6].rec_name, + '[Lev 0/Lev 1b | 0.00 € | Open]|balance|0.00|2') + self.assertEqual( + values[7].rec_name, + '[Lev 0/Lev 1b | 0.00 € | Open]|balance_all|0.00|2') + self.assertEqual( + values[8].rec_name, + '[Lev 0/Lev 1b | 0.00 € | Open]|balance_ref|0.00|2') + + @with_transaction() + def test_valstore_search_sort_books(self): + """ create cashbooks add lines, search/sort + with and w/o 'date' in context + """ + pool = Pool() + Book = pool.get('cashbook.book') + Line = pool.get('cashbook.line') + + types = self.prep_type() + company = self.prep_company() + (usd, euro) = self.prep_2nd_currency(company) + self.assertEqual(company.currency.rec_name, 'Euro') + + with Transaction().set_context({'company': company.id}): + category = self.prep_category(cattype='in') + party = self.prep_party() + books = Book.create([{ + 'name': 'Cashbook 1', + 'btype': types.id, + 'company': company.id, + 'currency': usd.id, + 'number_sequ': self.prep_sequence().id, + 'start_date': date(2022, 5, 1), + 'lines': [('create', [{ + 'date': date(2022, 5, 1), + 'description': '10 US$', + 'category': category.id, + 'bookingtype': 'in', + 'amount': Decimal('10.0'), + 'party': party.id, + }])]}, { + 'name': 'Cashbook 2', + 'btype': types.id, + 'company': company.id, + 'currency': euro.id, + 'number_sequ': self.prep_sequence().id, + 'start_date': date(2022, 5, 1), + }, { + 'name': 'Cashbook 3', + 'btype': types.id, + 'company': company.id, + 'currency': euro.id, + 'number_sequ': self.prep_sequence().id, + 'start_date': date(2022, 5, 1), + }]) + self.assertEqual(len(books), 3) + self.assertEqual(books[0].rec_name, 'Cashbook 1 | 10.00 usd | Open') + self.assertEqual(books[1].rec_name, 'Cashbook 2 | 0.00 € | Open') + self.assertEqual(books[2].rec_name, 'Cashbook 3 | 0.00 € | Open') + + Line.create([{ + 'cashbook': books[1].id, + 'bookingtype': 'in', + 'amount': Decimal('5.0'), + 'date': date(2022, 5, 6), + 'description': '5€ in', + 'category': category.id, + }, { + 'cashbook': books[2].id, + 'bookingtype': 'in', + 'amount': Decimal('6.0'), + 'date': date(2022, 5, 7), + 'description': '6€ in', + 'category': category.id, + }]) + + # no 'date' in context, using stored values + # workers not yet processed + books = Book.search([], order=[('name', 'ASC')]) + self.assertEqual(len(books), 3) + self.assertEqual(books[0].rec_name, 'Cashbook 1 | 10.00 usd | Open') + self.assertEqual(books[1].rec_name, 'Cashbook 2 | 0.00 € | Open') + self.assertEqual(books[2].rec_name, 'Cashbook 3 | 0.00 € | Open') + + self.assertEqual( + Book.search_count([('balance', '=', Decimal('0.0'))]), + 2) + self.assertEqual( + Book.search_count([('balance_all', '=', Decimal('0.0'))]), + 2) + self.assertEqual( + Book.search_count([('balance_ref', '=', Decimal('0.0'))]), + 2) + + # check sorting - using stored values + books = Book.search([], order=[ + ('balance_all', 'DESC'), ('name', 'ASC')]) + self.assertEqual(len(books), 3) + self.assertEqual(books[0].rec_name, 'Cashbook 1 | 10.00 usd | Open') + self.assertEqual(books[1].rec_name, 'Cashbook 2 | 0.00 € | Open') + self.assertEqual(books[2].rec_name, 'Cashbook 3 | 0.00 € | Open') + + # search again with 'date' - using computed values + with Transaction().set_context({'date': date(2022, 5, 6)}): + self.assertEqual( + Book.search_count([('balance', '=', Decimal('5.0'))]), + 1) + self.assertEqual( + Book.search_count([ + ('balance', '>=', Decimal('5.0')), + ('balance', '<', Decimal('9.0')), + ]), + 1) + self.assertEqual( + Book.search_count([ + ('balance_all', '>=', Decimal('5.0'))]), + 3) + self.assertRaisesRegex( + UserError, + "Search with 'date' no allowed for field " + + "'balance_ref' on model 'cashbook.book'.", + Book.search_count, + [('balance_ref', '=', Decimal('0.0'))]) + + self.assertRaisesRegex( + UserError, + "Search with 'date' no allowed for field " + + "'balance_ref' on model 'cashbook.book'.", + Book.search, + [], order=[('balance_ref', 'ASC')]) + + # check sorting - using computed values + books = Book.search([], order=[ + ('balance_all', 'DESC'), + ('name', 'ASC'), + ('balance', 'ASC')]) + self.assertEqual(len(books), 3) + self.assertEqual( + books[0].rec_name, 'Cashbook 1 | 10.00 usd | Open') + self.assertEqual( + books[1].rec_name, 'Cashbook 3 | 0.00 € | Open') + self.assertEqual(books[1].balance_all, Decimal('6.0')) + self.assertEqual( + books[2].rec_name, 'Cashbook 2 | 5.00 € | Open') + + with Transaction().set_context({'date': date(2022, 5, 7)}): + self.assertEqual( + Book.search_count([('balance', '=', Decimal('5.0'))]), + 1) + self.assertEqual( + Book.search_count([ + ('balance', '>=', Decimal('5.0')), + ('balance', '<', Decimal('9.0')), + ]), + 2) + + # run workers + self.prep_valstore_run_worker() + + # check stored values - no 'date' in context + books = Book.search([], order=[('name', 'ASC')]) + self.assertEqual(len(books), 3) + self.assertEqual(books[0].rec_name, 'Cashbook 1 | 10.00 usd | Open') + self.assertEqual(books[1].rec_name, 'Cashbook 2 | 5.00 € | Open') + self.assertEqual(books[2].rec_name, 'Cashbook 3 | 6.00 € | Open') + + # check sorting - using stored values + # run most sorters + books = Book.search([], order=[ + ('balance_all', 'DESC'), + ('name', 'ASC'), + ('balance', 'ASC'), + ('balance_ref', 'ASC')]) + self.assertEqual(len(books), 3) + self.assertEqual(books[0].rec_name, 'Cashbook 1 | 10.00 usd | Open') + self.assertEqual(books[1].rec_name, 'Cashbook 3 | 6.00 € | Open') + self.assertEqual(books[2].rec_name, 'Cashbook 2 | 5.00 € | Open') + + self.assertEqual( + Book.search_count([('balance', '=', Decimal('0.0'))]), + 0) + self.assertEqual( + Book.search_count([('balance', '=', Decimal('5.0'))]), + 1) + self.assertEqual( + Book.search_count([('balance', '=', Decimal('6.0'))]), + 1) + self.assertEqual( + Book.search_count([ + ('balance', '>=', Decimal('5.0')), + ('balance', '<', Decimal('9.0')), + ]), + 2) + self.assertEqual( + Book.search_count([('balance_ref', '=', Decimal('6.0'))]), + 1) + + @with_transaction() + def test_valstore_maintain_values(self): + """ create cashbook, check maintenance - + update records by cron, delete lost records + """ + pool = Pool() + Book = pool.get('cashbook.book') + ValueStore = pool.get('cashbook.values') + tab_book = Book.__table__() + cursor = Transaction().connection.cursor() + + types = self.prep_type() + company = self.prep_company() + (usd, euro) = self.prep_2nd_currency(company) + self.assertEqual(company.currency.rec_name, 'Euro') + + category = self.prep_category(cattype='in') + party = self.prep_party() + book, = Book.create([{ + 'name': 'Book 1', + 'btype': types.id, + 'company': company.id, + 'currency': usd.id, + 'number_sequ': self.prep_sequence().id, + 'start_date': date(2022, 5, 1), + 'lines': [('create', [{ + 'date': date(2022, 5, 1), + 'description': '10 US$', + 'category': category.id, + 'bookingtype': 'in', + 'amount': Decimal('10.0'), + 'party': party.id, + }, { + 'date': date(2022, 5, 10), + 'description': '5 US$', + 'category': category.id, + 'bookingtype': 'in', + 'amount': Decimal('5.0'), + 'party': party.id, + }])], + }]) + + # clean 'numvalue' + # maintenance_values() will restore it + val1, = ValueStore.search([('field_name', '=', 'balance')]) + ValueStore.write(*[[val1], {'numvalue': None}]) + + self.assertTrue(val1.write_date is not None) + self.assertTrue(val1.create_date is not None) + self.assertEqual(val1.numvalue, None) + + # update outdated records + with Transaction().set_context({ + 'maintenance_date': val1.write_date.date() + + timedelta(days=2)}): + ValueStore.maintenance_values() + + val1, = ValueStore.search([('field_name', '=', 'balance')]) + self.assertEqual(val1.numvalue, Decimal('15.0')) + + # delete book + self.assertEqual(Book.search_count([]), 1) + self.assertEqual( + ValueStore.search_count([]), + len(Book.valuestore_fields())) + query = tab_book.delete(where=tab_book.id == book.id) + cursor.execute(*query) + self.assertEqual(Book.search_count([]), 0) + self.assertEqual(ValueStore.search_count([]), 0) + +# end ValuestoreTestCase diff --git a/tryton.cfg b/tryton.cfg index ea57ba7..f649bb6 100644 --- a/tryton.cfg +++ b/tryton.cfg @@ -1,5 +1,5 @@ [tryton] -version=6.0.0 +version=6.8.33 depends: res currency @@ -21,3 +21,4 @@ xml: wizard_runreport.xml wizard_booking.xml menu.xml + cron.xml diff --git a/types.py b/types.py index 1217adf..11bcaf5 100644 --- a/types.py +++ b/types.py @@ -3,7 +3,7 @@ # The COPYRIGHT file at the top level of this repository contains the # full copyright notices and license terms. -from trytond.model import ModelView, ModelSQL, fields, Unique +from trytond.model import ModelView, ModelSQL, fields, Unique, Index from trytond.transaction import Transaction from trytond.i18n import gettext @@ -28,8 +28,15 @@ class Type(ModelSQL, ModelView): cls._order.insert(0, ('name', 'ASC')) t = cls.__table__() cls._sql_constraints.extend([ - ('code_uniq', Unique(t, t.short), 'cashbook.msg_type_short_unique'), + ('code_uniq', + Unique(t, t.short), + 'cashbook.msg_type_short_unique'), ]) + cls._sql_indexes.update({ + Index( + t, + (t.feature, Index.Equality())), + }) @classmethod def default_feature(cls): @@ -48,8 +55,7 @@ class Type(ModelSQL, ModelView): """ return '%(short)s - %(name)s' % { 'short': self.short or '-', - 'name': self.name or '-', - } + 'name': self.name or '-'} @classmethod def search_rec_name(cls, name, clause): @@ -58,8 +64,7 @@ class Type(ModelSQL, ModelView): return [ 'OR', ('name',) + tuple(clause[1:]), - ('short',) + tuple(clause[1:]), - ] + ('short',) + tuple(clause[1:])] @staticmethod def default_company(): diff --git a/valuestore.py b/valuestore.py new file mode 100644 index 0000000..1a4cabf --- /dev/null +++ b/valuestore.py @@ -0,0 +1,207 @@ +# -*- coding: utf-8 -*- +# This file is part of the cashbook-module from m-ds for Tryton. +# The COPYRIGHT file at the top level of this repository contains the +# full copyright notices and license terms. + + +from sql.functions import CurrentTimestamp, DateTrunc +from sql.aggregate import Count +from sql.conditionals import Coalesce +from trytond.model import ModelSQL, fields, Unique, Index +from trytond.pool import Pool +from trytond.transaction import Transaction +from trytond.pyson import Eval, PYSON, PYSONEncoder, PYSONDecoder +from trytond.model.modelstorage import EvalEnvironment +from trytond.report import Report + + +class ValueStore(ModelSQL): + 'Value Store' + __name__ = 'cashbook.values' + + cashbook = fields.Many2One( + string='Cashbook', required=True, model_name='cashbook.book', + ondelete='CASCADE') + field_name = fields.Char(string='Field Name', required=True) + numvalue = fields.Numeric( + string='Value', digits=(16, Eval('valuedigits', 6)), + depends=['valuedigits']) + valuedigits = fields.Function(fields.Integer( + string='Digits', readonly=True), + 'on_change_with_valuedigits') + + @classmethod + def __setup__(cls): + super(ValueStore, cls).__setup__() + t = cls.__table__() + cls._sql_constraints.extend([ + ('uniqu_field', + Unique(t, t.cashbook, t.field_name), + 'cashbook.msg_value_exists_in_store'), + ]) + cls._sql_indexes.update({ + Index( + t, + (t.field_name, Index.Equality())), + }) + + def get_rec_name(self, name): + """ name, balance, state + """ + return '|'.join([ + '[' + getattr(self.cashbook, 'rec_name', '-') + ']', + self.field_name or '-', + Report.format_number( + self.numvalue, + None, + digits=self.valuedigits or 2) + if self.numvalue is not None else '-', + str(self.valuedigits) if self.valuedigits is not None else '-']) + + @fields.depends('cashbook', 'field_name') + def on_change_with_valuedigits(self, name=None): + """ get digits by field name + """ + Cashbook = Pool().get('cashbook.book') + + if self.cashbook and self.field_name: + fieldobj = Cashbook._fields.get(self.field_name, None) + if fieldobj: + digit = getattr(fieldobj, 'digits', (16, 6))[1] + if isinstance(digit, PYSON): + # execute pyson on linked record + digit = PYSONDecoder( + EvalEnvironment(self.cashbook, Cashbook) + ).decode(PYSONEncoder().encode(digit)) + return digit + return 6 + + @classmethod + def _maintenance_fields(cls): + """ list of model and fieldnames, + to daily update + """ + return ['balance'] + + @classmethod + def maintenance_values(cls): + """ update values by cron + """ + pool = Pool() + Cashbook = pool.get('cashbook.book') + tab_val = cls.__table__() + tab_book = Cashbook.__table__() + cursor = Transaction().connection.cursor() + context = Transaction().context + + # select records older than 'check_dt' + check_dt = context.get('maintenance_date', None) + if not check_dt: + check_dt = DateTrunc('day', CurrentTimestamp()) + + # select records to update + query = tab_val.select( + tab_val.id, + where=tab_val.field_name.in_(cls._maintenance_fields()) & + (DateTrunc('day', Coalesce( + tab_val.write_date, + tab_val.create_date, + '1990-01-01 00:00:00')) < check_dt)) + cursor.execute(*query) + records = [] + for x in cursor.fetchall(): + cb = cls(x[0]).cashbook + if cb not in records: + records.append(cb) + + # add records with missing fields in value-store + num_fields = len(Cashbook.valuestore_fields()) + query = tab_book.join( + tab_val, + condition=tab_book.id == tab_val.cashbook, + type_='LEFT OUTER', + ).select( + tab_book.id, + Count(tab_val.id).as_('num'), + group_by=[tab_book.id], + having=Count(tab_val.id) < num_fields) + cursor.execute(*query) + records.extend([Cashbook(x[0]) for x in cursor.fetchall()]) + + if records: + Cashbook.valuestore_update_records([x for x in records]) + + @classmethod + def get_book_by_line(cls, records): + """ select books above current record to update + records: cashbook.line + """ + Book = Pool().get('cashbook.book') + + to_update = [] + if records: + books = Book.search([ + ('parent', 'parent_of', [x.cashbook.id for x in records]) + ]) + to_update.extend([ + x for x in books + if x not in to_update]) + return to_update + + @classmethod + def get_book_by_books(cls, records): + """ select books above current record to update + records: cashbook.book + """ + Book = Pool().get('cashbook.book') + + to_update = [] + if records: + books = Book.search([ + ('parent', 'parent_of', [x.id for x in records]) + ]) + to_update.extend([ + x for x in books + if x not in to_update]) + return to_update + + @classmethod + def update_books(cls, books): + """ get cashbooks to update, queue it + """ + Book = Pool().get('cashbook.book') + + if books: + Book.__queue__.valuestore_update_records(books) + + @classmethod + def update_values(cls, data): + """ data: {'fieldname': {id1: value, id2: value, ...}, ...} + """ + to_write = [] + to_create = [] + + for name in data.keys(): + for record_id in data[name].keys(): + # get existing record + records = cls.search([ + ('cashbook', '=', record_id), + ('field_name', '=', name)]) + + if records: + for record in records: + to_write.extend([ + [record], + {'numvalue': data[name][record_id]}]) + else: + to_create.append({ + 'cashbook': record_id, + 'field_name': name, + 'numvalue': data[name][record_id]}) + + if to_create: + cls.create(to_create) + if to_write: + cls.write(*to_write) + +# end ValueStore diff --git a/view/book_list.xml b/view/book_list.xml index 3ac0a86..3e3220b 100644 --- a/view/book_list.xml +++ b/view/book_list.xml @@ -5,5 +5,7 @@ full copyright notices and license terms. --> - + + + diff --git a/view/book_tree.xml b/view/book_tree.xml index f3cfcb1..da7adef 100644 --- a/view/book_tree.xml +++ b/view/book_tree.xml @@ -5,7 +5,9 @@ full copyright notices and license terms. --> - + + + diff --git a/view/line_list.xml b/view/line_list.xml index fffc0ac..4ffa594 100644 --- a/view/line_list.xml +++ b/view/line_list.xml @@ -4,14 +4,14 @@ The COPYRIGHT file at the top level of this repository contains the full copyright notices and license terms. --> - + - - - - + + + +