Changeset - b142e8fc31fb
[Not reviewed]
0 2 0
Brett Smith - 4 years ago 2020-05-18 19:31:00
brettcsmith@brettcsmith.org
accrual: Bugfix last commit.
2 files changed with 2 insertions and 2 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/reports/accrual.py
Show inline comments
...
 
@@ -129,193 +129,193 @@ class SearchTerm(NamedTuple):
 
                key = 'rt-id' if attachment_id is None else 'invoice'
 
            pattern = rtutil.RT.metadata_regexp(
 
                ticket_id,
 
                attachment_id,
 
                first_link_only=key == 'rt-id' and attachment_id is None,
 
            )
 
        return cls(key, pattern)
 

	
 

	
 
def consistency_check(groups: PostGroups) -> Iterable[Error]:
 
    for key, related in groups.items():
 
        for checked_meta in ['contract', 'entity', 'purchase-order']:
 
            meta_values = related.meta_values(checked_meta)
 
            if len(meta_values) != 1:
 
                errmsg = f'inconsistent {checked_meta} for invoice {key}'
 
                for post in related:
 
                    yield Error(
 
                        post.meta,
 
                        f'{errmsg}: {post.meta.get(checked_meta)}',
 
                        post.meta.txn,
 
                    )
 

	
 
def _since_last_nonzero(posts: core.RelatedPostings) -> core.RelatedPostings:
 
    retval = core.RelatedPostings()
 
    for post in posts:
 
        if retval.balance().is_zero():
 
            retval.clear()
 
        retval.add(post)
 
    return retval
 

	
 
@ReportType.register('balance', 'bal')
 
def balance_report(groups: PostGroups,
 
                   out_file: TextIO,
 
                   err_file: TextIO=sys.stderr,
 
                   rt_client: Optional[rt.Rt]=None,
 
                   rt_wrapper: Optional[rtutil.RT]=None,
 
) -> None:
 
    prefix = ''
 
    for invoice, related in groups.items():
 
        related = _since_last_nonzero(related)
 
        balance = related.balance()
 
        date_s = related[0].meta.date.strftime('%Y-%m-%d')
 
        print(
 
            f"{prefix}{invoice}:",
 
            f"  {balance} outstanding since {date_s}",
 
            sep='\n', file=out_file,
 
        )
 
        prefix = '\n'
 

	
 
def _primary_rt_id(related: core.RelatedPostings) -> rtutil.TicketAttachmentIds:
 
    rt_ids = related.all_meta_links('rt-id')
 
    rt_ids_count = len(rt_ids)
 
    if rt_ids_count != 1:
 
        raise ValueError(f"{rt_ids_count} rt-id links found")
 
    parsed = rtutil.RT.parse(rt_ids.pop())
 
    if parsed is None:
 
        raise ValueError("rt-id is not a valid RT reference")
 
    else:
 
        return parsed
 

	
 
@ReportType.register('outgoing', 'outgoings', 'out')
 
def outgoing_report(groups: PostGroups,
 
                    out_file: TextIO,
 
                    err_file: TextIO=sys.stderr,
 
                    rt_client: Optional[rt.Rt]=None,
 
                    rt_wrapper: Optional[rtutil.RT]=None,
 
) -> None:
 
    if rt_client is None or rt_wrapper is None:
 
        raise ValueError("RT client is required but not configured")
 
    for invoice, related in groups.items():
 
        related = _since_last_nonzero(related)
 
        try:
 
            ticket_id, _ = _primary_rt_id(related)
 
            ticket = rt_client.get_ticket(ticket_id)
 
            # Note we only use this when ticket is None.
 
            errmsg = f"ticket {ticket_id} not found"
 
        except (ValueError, rt.RtError) as error:
 
            ticket = None
 
            errmsg = error.args[0]
 
        if ticket is None:
 
            print("error: can't generate outgoings report for {}"
 
                  " because no RT ticket available: {}".format(
 
                      invoice, errmsg,
 
                  ), file=err_file)
 
            continue
 

	
 
        try:
 
            rt_requestor = rt_client.get_user(ticket['Requestors'][0])
 
        except (IndexError, rt.RtError):
 
            rt_requestor = None
 
        if rt_requestor is None:
 
            requestor = ''
 
            requestor_name = ''
 
        else:
 
            requestor_name = (
 
                rt_requestor.get('RealName')
 
                or rt_requestor.get('CF.{payment-to}')
 
                or ticket.get('CF.{payment-to}')
 
                or ''
 
            )
 
            requestor = f'{requestor_name} <{rt_requestor["EmailAddress"]}>'.strip()
 

	
 
        contract_links = related.all_meta_links('contract')
 
        if contract_links:
 
            contract_s = ' , '.join(rt_wrapper.iter_urls(
 
                contract_links, '<{}>', '{}', '<BROKEN RT LINK: {}>',
 
            ))
 
        else:
 
            contract_s = "NO CONTRACT GOVERNS THIS TRANSACTION"
 
        projects = [v for v in related.meta_values('project')
 
                    if isinstance(v, str)]
 

	
 
        print(
 
            "PAYMENT FOR APPROVAL:",
 
            f"REQUESTOR: {requestor}",
 
            f"TOTAL TO PAY: {-related.balance()}",
 
            f"AGREEMENT: {contract_s}",
 
            f"PAYMENT TO: {ticket.get('CF.{payment-to}') or requestor_name}",
 
            f"PAYMENT METHOD: {ticket.get('CF.{payment-method}', '')}",
 
            f"PROJECT: {', '.join(projects)}",
 
            "\nBEANCOUNT ENTRIES:\n",
 
            sep='\n', file=out_file,
 
        )
 

	
 
        last_txn: Optional[Transaction] = None
 
        for post in related:
 
            txn = post.meta.txn
 
            if txn is not last_txn:
 
                last_txn = txn
 
                txn = rt_wrapper.txn_with_urls(txn)
 
                bc_printer.print_entry(txn, file=out_file)
 

	
 
def filter_search(postings: Iterable[data.Posting],
 
                  search_terms: Iterable[SearchTerm],
 
) -> Iterable[data.Posting]:
 
    postings = (post for post in postings if post.account.is_under(
 
        'Assets:Receivable', 'Liabilities:Payable',
 
    ))
 
    for meta_key, pattern in search_terms:
 
        postings = filters.filter_meta_match(postings, meta_key, re.compile(pattern))
 
    return postings
 

	
 
def parse_arguments(arglist: Optional[Sequence[str]]=None) -> argparse.Namespace:
 
    parser = argparse.ArgumentParser()
 
    parser.add_argument(
 
        '--report-type', '-t',
 
        metavar='NAME',
 
        type=ReportType.by_name,
 
        help="""The type of report to generate, either `balance` or `outgoing`.
 
If not specified, the default is `outgoing` for search criteria that return a
 
single outstanding payable, and `balance` any other time.
 
""")
 
    parser.add_argument(
 
        '--since',
 
        metavar='YEAR',
 
        type=int,
 
        default=-1,
 
        help="""How far back to search the books for related transactions.
 
You can either specify a fiscal year, or a negative offset from the current
 
fiscal year, to start loading entries from. The default is -1 (start from the
 
previous fiscal year).
 
""")
 
    parser.add_argument(
 
        'search',
 
        nargs=argparse.ZERO_OR_MORE,
 
        help="""Report on accruals 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 link, including an RT attachment link in
 
`TIK/ATT` format, is a shortcut for `invoice=LINK`.
 
""")
 
    args = parser.parse_args(arglist)
 
    args.search_terms = [SearchTerm.parse(s) for s in args.search]
 
    return args
 

	
 
def main(arglist: Optional[Sequence[str]]=None,
 
         stdout: TextIO=sys.stdout,
 
         stderr: TextIO=sys.stderr,
 
         config: Optional[configmod.Config]=None,
 
) -> int:
 
    returncode = 0
 
    args = parse_arguments(arglist)
 
    if config is None:
 
        config = configmod.Config()
 
        config.load_file()
 
    books_loader = config.books_loader()
 
    if books_loader is not None:
 
        entries, load_errors, _ = books_loader.load_fy_range(args.since)
 
    else:
 
        entries = []
 
        source = {
 
            'filename': str(config.config_file_path()),
 
            'lineno': 1,
 
        }
setup.py
Show inline comments
 
#!/usr/bin/env python3
 

	
 
from setuptools import setup
 

	
 
setup(
 
    name='conservancy_beancount',
 
    description="Plugin, library, and reports for reading Conservancy's books",
 
    version='1.0.3',
 
    version='1.0.4',
 
    author='Software Freedom Conservancy',
 
    author_email='info@sfconservancy.org',
 
    license='GNU AGPLv3+',
 

	
 
    install_requires=[
 
        'beancount>=2.2',
 
        'PyYAML>=3.0',
 
        'regex',
 
        'rt>=2.0',
 
    ],
 
    setup_requires=[
 
        'pytest-mypy',
 
        'pytest-runner',
 
    ],
 
    tests_require=[
 
        'mypy>=0.770',
 
        'pytest',
 
    ],
 

	
 
    packages=[
 
        'conservancy_beancount',
 
        'conservancy_beancount.plugin',
 
        'conservancy_beancount.reports',
 
    ],
 
    entry_points={
 
        'console_scripts': [
 
            'accrual-report = conservancy_beancount.reports.accrual:main',
 
        ],
 
    },
 
)
0 comments (0 inline, 0 general)