Changeset - 770b22f2f0e7
[Not reviewed]
0 1 1
Brett Smith - 4 years ago 2020-10-26 18:57:15
brettcsmith@brettcsmith.org
reports: Initial budget variance skeleton. RT#12680

This is a *very* rough initial draft of a report. As the docstring mentions,
it's basically counting on the user to provide rewrite rules to provide the
desired representation.

Long-term I'm hoping maybe we can standardize the program metadata enough,
or plan its replacement well enough, that this report can be written against
that directly. But that will take more planning about books structure, and
support from the plugin, before the report can be written that way.
2 files changed with 281 insertions and 1 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/reports/budget.py
Show inline comments
 
new file 100644
 
"""budget.py - Budget variance report skeleton
 

	
 
This report sums income and expenses based on postings' ``budget-line``
 
metadata. Usually this metadata is set by rewrite rules, rather than entered in
 
the books directly. If there is no ``budget-line`` metadata, it falls back to
 
using account classifications in the account definitions.
 
"""
 
# 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 argparse
 
import collections
 
import datetime
 
import enum
 
import logging
 
import operator
 
import sys
 

	
 
from decimal import Decimal
 
from pathlib import Path
 

	
 
from typing import (
 
    Any,
 
    Callable,
 
    Collection,
 
    Dict,
 
    Hashable,
 
    Iterable,
 
    Iterator,
 
    List,
 
    Mapping,
 
    NamedTuple,
 
    Optional,
 
    Sequence,
 
    TextIO,
 
    Tuple,
 
    Union,
 
)
 

	
 
import odf.style  # type:ignore[import]
 
import odf.table  # type:ignore[import]
 

	
 
from beancount.parser import printer as bc_printer
 

	
 
from . import core
 
from . import rewrite
 
from .. import books
 
from .. import cliutil
 
from .. import config as configmod
 
from .. import data
 
from .. import ranges
 

	
 
PROGNAME = 'budget-report'
 
logger = logging.getLogger('conservancy_beancount.reports.budget')
 

	
 
Fund = core.Fund
 
KWArgs = Mapping[str, Any]
 
Period = core.Period
 

	
 
class Balances(core.Balances):
 
    def _get_classification(self, post: data.Posting) -> data.Account:
 
        try:
 
            return self._get_meta_account(post.meta, 'budget-line')
 
        except (KeyError, TypeError):
 
            return super()._get_classification(post)
 

	
 

	
 
class Report(core.BaseODS[Sequence[None], None]):
 
    def __init__(self, balances: core.Balances) -> None:
 
        super().__init__()
 
        self.balances = balances
 
        self.last_totals_row = odf.table.TableRow()
 

	
 
    def section_key(self, row: Sequence[None]) -> None:
 
        raise NotImplementedError("balance_sheet.Report.section_key")
 

	
 
    def init_styles(self) -> None:
 
        super().init_styles()
 
        self.style_header = self.merge_styles(self.style_bold, self.style_centertext)
 
        self.style_huline = self.merge_styles(
 
            self.style_header,
 
            self.border_style(core.Border.BOTTOM, '1pt'),
 
        )
 

	
 
    def write_all(self) -> None:
 
        headers = [self.string_cell(text, stylename=self.style_huline) for text in [
 
            "",
 
            "Budgeted",
 
            "Actual",
 
            "% Above/Below Budget",
 
        ]]
 
        for header_count, cell in enumerate(headers):
 
            col_style = self.column_style(2 if header_count else 4)
 
            self.sheet.addElement(odf.table.TableColumn(stylename=col_style))
 
        header_count += 1
 
        self.add_row(*headers)
 
        self.lock_first_row()
 
        self.add_row()
 
        date_range = self.balances.period_range
 
        self.add_row(self.multiline_cell(
 
            ["DRAFT Budget Variance Report",
 
             f"from {date_range.start} to {date_range.stop}"],
 
            stylename=self.style_header,
 
            numbercolumnsspanned=header_count,
 
        ))
 
        self.write_section("Support", 'Income', 'Equity', 'Liabilities:UnearnedIncome')
 
        self.write_section("Program Activity", 'Expenses', post_meta='program')
 
        self.write_section("Fundraising Activity", 'Expenses', post_meta='fundraising')
 
        self.write_section("Management & Administration", 'Expenses', post_meta='management')
 

	
 
    def write_section(self,
 
                      name: str,
 
                      *accounts: str,
 
                      post_meta: Optional[str]=None,
 
    ) -> None:
 
        self.add_row(self.string_cell(name, stylename=self.style_bold))
 
        self.add_row()
 
        norm_func = core.normalize_amount_func(f'{accounts[0]}:RootsOK')
 
        for classification in self.balances.classifications(accounts[0]):
 
            balance = norm_func(self.balances.total(
 
                accounts,
 
                classification,
 
                period=Period.PERIOD,
 
                post_meta=post_meta,
 
            ))
 
            self.add_row(
 
                self.string_cell(classification),
 
                odf.table.TableCell(),  # TODO: Budgeted amount
 
                self.balance_cell(balance),
 
                odf.table.TableCell(),  # TODO: Variance formula
 
            )
 
        self.add_row()
 
        balance = norm_func(self.balances.total(
 
            accounts,
 
            period=Period.PERIOD,
 
            post_meta=post_meta,
 
        ))
 
        self.add_row(
 
            self.string_cell("Totals", stylename=self.style_bold),
 
            odf.table.TableCell(stylename=self.style_endtotal),  # TODO: Budgeted amount
 
            self.balance_cell(balance, stylename=self.style_endtotal),
 
            odf.table.TableCell(stylename=self.style_endtotal),  # TODO: Variance formula
 
        )
 
        self.add_row()
 

	
 

	
 
def parse_arguments(arglist: Optional[Sequence[str]]=None) -> argparse.Namespace:
 
    parser = argparse.ArgumentParser(prog=PROGNAME)
 
    cliutil.add_version_argument(parser)
 
    parser.add_argument(
 
        '--begin', '--start', '-b',
 
        dest='start_date',
 
        metavar='DATE',
 
        required=True,
 
        type=cliutil.date_arg,
 
        help="""Date to start reporting entries, inclusive, in YYYY-MM-DD format.
 
""")
 
    parser.add_argument(
 
        '--end', '--stop', '-e',
 
        dest='stop_date',
 
        metavar='DATE',
 
        required=True,
 
        type=cliutil.date_arg,
 
        help="""Date to stop reporting entries, exclusive, in YYYY-MM-DD format.
 
""")
 
    cliutil.add_rewrite_rules_argument(parser)
 
    parser.add_argument(
 
        '--fund-metadata-key', '-m',
 
        metavar='KEY',
 
        default='project',
 
        help="""Name of the fund metadata key. Default %(default)s.
 
""")
 
    parser.add_argument(
 
        '--unrestricted-fund', '-u',
 
        metavar='PROJECT',
 
        default='Conservancy',
 
        help="""Name of the unrestricted fund. Default %(default)s.
 
""")
 
    parser.add_argument(
 
        '--output-file', '-O',
 
        metavar='PATH',
 
        type=Path,
 
        help="""Write the report to this file, or stdout when PATH is `-`.
 
""")
 
    cliutil.add_loglevel_argument(parser)
 
    parser.add_argument(
 
        'search_terms',
 
        metavar='FILTER',
 
        type=cliutil.SearchTerm.arg_parser('project', 'rt-id'),
 
        nargs=argparse.ZERO_OR_MORE,
 
        help="""Report on postings that match this criteria. The format is
 
NAME=TERM. TERM is a link or word that must exist in a posting's NAME
 
metadata to match. A single ticket number is a shortcut for
 
`rt-id=rt:NUMBER`. Any other word is a shortcut for `project=TERM`.
 
If you specify no search terms, defaults to generating a budget for the
 
unrestricted fund.
 
""")
 
    args = parser.parse_args(arglist)
 
    if not args.search_terms:
 
        args.search_terms = [cliutil.SearchTerm(args.fund_metadata_key, args.unrestricted_fund)]
 
    return args
 

	
 
def main(arglist: Optional[Sequence[str]]=None,
 
         stdout: TextIO=sys.stdout,
 
         stderr: TextIO=sys.stderr,
 
         config: Optional[configmod.Config]=None,
 
) -> int:
 
    args = parse_arguments(arglist)
 
    cliutil.set_loglevel(logger, args.loglevel)
 
    if config is None:
 
        config = configmod.Config()
 
        config.load_file()
 

	
 
    returncode = 0
 
    books_loader = config.books_loader()
 
    if books_loader is None:
 
        entries, load_errors, options_map = books.Loader.load_none(config.config_file_path())
 
        returncode = cliutil.ExitCode.NoConfiguration
 
    else:
 
        entries, load_errors, options_map = books_loader.load_fy_range(
 
            args.start_date, args.stop_date,
 
        )
 
        if load_errors:
 
            returncode = cliutil.ExitCode.BeancountErrors
 
        elif not entries:
 
            returncode = cliutil.ExitCode.NoDataLoaded
 
    for error in load_errors:
 
        bc_printer.print_error(error, file=stderr)
 

	
 
    data.Account.load_from_books(entries, options_map)
 
    postings = data.Posting.from_entries(entries)
 
    for search_term in args.search_terms:
 
        postings = search_term.filter_postings(postings)
 
    for rewrite_path in args.rewrite_rules:
 
        try:
 
            ruleset = rewrite.RewriteRuleset.from_yaml(rewrite_path)
 
        except ValueError as error:
 
            logger.critical("failed loading rewrite rules from %s: %s",
 
                            rewrite_path, error.args[0])
 
            return cliutil.ExitCode.RewriteRulesError
 
        postings = ruleset.rewrite(postings)
 

	
 
    balances = Balances(
 
        postings,
 
        args.start_date,
 
        args.stop_date,
 
        'expense-type',
 
        args.fund_metadata_key,
 
        args.unrestricted_fund,
 
    )
 
    report = Report(balances)
 
    report.set_common_properties(config.books_repo())
 
    report.write_all()
 
    if args.output_file is None:
 
        out_dir_path = config.repository_path() or Path()
 
        args.output_file = out_dir_path / 'BudgetReport_{}_{}.ods'.format(
 
            args.start_date.isoformat(), args.stop_date.isoformat(),
 
        )
 
        logger.info("Writing report to %s", args.output_file)
 
    ods_file = cliutil.bytes_output(args.output_file, stdout)
 
    report.save_file(ods_file)
 
    return returncode
 

	
 
entry_point = cliutil.make_entry_point(__name__, PROGNAME)
 

	
 
if __name__ == '__main__':
 
    exit(entry_point())
setup.py
Show inline comments
...
 
@@ -5,7 +5,7 @@ from setuptools import setup
 
setup(
 
    name='conservancy_beancount',
 
    description="Plugin, library, and reports for reading Conservancy's books",
 
    version='1.12.5',
 
    version='1.13.0',
 
    author='Software Freedom Conservancy',
 
    author_email='info@sfconservancy.org',
 
    license='GNU AGPLv3+',
...
 
@@ -40,6 +40,7 @@ setup(
 
            'accrual-report = conservancy_beancount.reports.accrual:entry_point',
 
            'assemble-audit-reports = conservancy_beancount.tools.audit_report:entry_point',
 
            'balance-sheet-report = conservancy_beancount.reports.balance_sheet:entry_point',
 
            'budget-report = conservancy_beancount.reports.budget:entry_point',
 
            'bean-sort = conservancy_beancount.tools.sort_entries:entry_point',
 
            'extract-odf-links = conservancy_beancount.tools.extract_odf_links:entry_point',
 
            'fund-report = conservancy_beancount.reports.fund:entry_point',
0 comments (0 inline, 0 general)