expenseAllocation: Start checker.

This is the simplest version of a common validation we're going to do:
make sure that a particular piece of metadata has one of a set of
values.

This checker needs some bounds checking but I wanted to err on the
side of committing this early because it introduces so much base
infrastructure.
This commit is contained in:
Brett Smith 2020-03-05 11:55:54 -05:00
parent c5dd7984bc
commit 7862919022
6 changed files with 237 additions and 0 deletions

View file

View file

@ -0,0 +1,54 @@
"""Base classes for plugin checks"""
# Copyright © 2020 Brett Smith
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from . import errors as errormod
class PostingChecker:
VALUES_ENUM = {}
def _meta_get(self, txn, post, key, default=None):
try:
return post.meta[key]
except (KeyError, TypeError):
return txn.meta.get(key, default)
def _meta_set(self, post, key, value):
if post.meta is None:
post.meta = {}
post.meta[key] = value
def _default_value(self, txn, post):
raise errormod.InvalidMetadataError(txn, post, self.METADATA_KEY)
def check(self, txn, post):
errors = []
source_value = self._meta_get(txn, post, self.METADATA_KEY)
set_value = source_value
if source_value is None:
try:
set_value = self._default_value(txn, post)
except errormod._BaseError as error:
errors.append(error)
else:
try:
set_value = self.VALUES_ENUM[source_value].value
except KeyError:
errors.append(errormod.InvalidMetadataError(
txn, post, self.METADATA_KEY, source_value,
))
if not errors:
self._meta_set(post, self.METADATA_KEY, set_value)
return errors

View file

@ -0,0 +1,34 @@
"""Error classes for plugins to report problems in the books"""
# Copyright © 2020 Brett Smith
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
class _BaseError(Exception):
def __init__(self, message, entry, source=None):
self.message = message
self.entry = entry
self.source = entry.meta if source is None else source
class InvalidMetadataError(_BaseError):
def __init__(self, txn, post, key, value=None, source=None):
if value is None:
msg_fmt = "{post.account} missing {key}"
else:
msg_fmt = "{post.account} has invalid {key}: {value}"
super().__init__(
msg_fmt.format(post=post, key=key, value=value),
txn,
source,
)

View file

@ -0,0 +1,29 @@
"""meta_expense_allocation - Validate expenseAllocation metadata"""
# Copyright © 2020 Brett Smith
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import enum
from . import core
class ExpenseAllocations(enum.Enum):
administration = 'administration'
fundraising = 'fundraising'
program = 'program'
class MetaExpenseAllocation(core.PostingChecker):
METADATA_KEY = 'expenseAllocation'
VALUES_ENUM = ExpenseAllocations

View file

@ -0,0 +1,40 @@
"""Test handling of expenseAllocation metadata"""
# Copyright © 2020 Brett Smith
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import pytest
from . import testutil
from conservancy_beancount.plugin import meta_expense_allocation
@pytest.mark.parametrize('value,value_ok', [
('program', True),
('administration', True),
('fundraising', True),
('invalid', False),
('', False),
])
def test_validity_on_postings(value, value_ok):
txn = testutil.Transaction(postings=[
('Assets:Cash', -25),
('Expenses:General', 25, {'expenseAllocation': value}),
])
checker = meta_expense_allocation.MetaExpenseAllocation()
errors = checker.check(txn, txn.postings[-1])
if value_ok:
assert not errors
else:
assert errors

80
tests/testutil.py Normal file
View file

@ -0,0 +1,80 @@
"""Mock Beancount objects for testing"""
# Copyright © 2020 Brett Smith
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import datetime
import beancount.core.amount as bc_amount
import beancount.core.data as bc_data
from decimal import Decimal
FY_START_DATE = datetime.date(2020, 3, 1)
FY_MID_DATE = datetime.date(2020, 9, 1)
def parse_date(s, fmt='%Y-%m-%d'):
return datetime.datetime.strptime(s, fmt).date()
def Posting(account, number,
currency='USD', cost=None, price=None, flag=None,
**meta):
return bc_data.Posting(
account,
bc_amount.Amount(Decimal(number), currency),
cost,
price,
flag,
meta,
)
class Transaction:
def __init__(self,
date=FY_MID_DATE, flag='*', payee=None,
narration='', tags=None, links=None, postings=None,
**meta):
if isinstance(date, str):
date = parse_date(date)
self.date = date
self.flag = flag
self.payee = payee
self.narration = narration
self.tags = set(tags or '')
self.links = set(links or '')
self.postings = []
self.meta = {
'filename': '<test>',
'lineno': 0,
}
self.meta.update(meta)
for posting in postings:
self.add_posting(*posting)
def add_posting(self, arg, *args, **kwargs):
"""Add a posting to this transaction. Use any of these forms:
txn.add_posting(account, number, , kwarg=value, )
txn.add_posting(account, number, , posting_kwargs_dict)
txn.add_posting(posting_object)
"""
if kwargs:
posting = Posting(arg, *args, **kwargs)
elif args:
if isinstance(args[-1], dict):
kwargs = args[-1]
args = args[:-1]
posting = Posting(arg, *args, **kwargs)
else:
posting = arg
self.postings.append(posting)