brightfunds: New importer.
This commit adds infrastructure to treat XLS files like CSV files, and import them using the base classes that already exist for that.
This commit is contained in:
parent
2502ca40dd
commit
37563ffae0
6 changed files with 188 additions and 2 deletions
28
README.rst
28
README.rst
|
@ -137,6 +137,34 @@ Benevity
|
||||||
transaction_id The ID of this specific donation
|
transaction_id The ID of this specific donation
|
||||||
================ ===========================================================
|
================ ===========================================================
|
||||||
|
|
||||||
|
BrightFunds
|
||||||
|
^^^^^^^^^^^
|
||||||
|
|
||||||
|
``brightfunds donorreport ledger entry``
|
||||||
|
Imports one transaction per row in donor report XLS files that BrightFunds mails each month to recipients.
|
||||||
|
|
||||||
|
This template can use these variables:
|
||||||
|
|
||||||
|
================ ===========================================================
|
||||||
|
Name Contents
|
||||||
|
================ ===========================================================
|
||||||
|
company_name The company name as reported in the spreadsheet
|
||||||
|
---------------- -----------------------------------------------------------
|
||||||
|
corporation The company name as detected by the importer (this is
|
||||||
|
usually what you want)
|
||||||
|
---------------- -----------------------------------------------------------
|
||||||
|
donor_name The donor name as reported in the spreadsheet (usually you
|
||||||
|
want to use ``payee`` instead)
|
||||||
|
---------------- -----------------------------------------------------------
|
||||||
|
donor_email The donor's e-mail address as reported in the spreadsheet
|
||||||
|
---------------- -----------------------------------------------------------
|
||||||
|
on_behalf_of From the corresponding spreadsheet column
|
||||||
|
---------------- -----------------------------------------------------------
|
||||||
|
fund From the corresponding spreadsheet column
|
||||||
|
---------------- -----------------------------------------------------------
|
||||||
|
type From the corresponding spreadsheet column
|
||||||
|
================ ===========================================================
|
||||||
|
|
||||||
Patreon
|
Patreon
|
||||||
^^^^^^^
|
^^^^^^^
|
||||||
|
|
||||||
|
|
|
@ -33,9 +33,15 @@ class CSVImporterBase:
|
||||||
included in the entry data returned by _read_header. If it returns
|
included in the entry data returned by _read_header. If it returns
|
||||||
None, _read_header expects this is the row with column names for the
|
None, _read_header expects this is the row with column names for the
|
||||||
real data, and uses it in its return value.
|
real data, and uses it in its return value.
|
||||||
|
* Reader: A class that accepts the input source and iterates over rows of
|
||||||
|
formatted data. Default csv.reader.
|
||||||
|
* DictReader: A class that accepts the input source and iterates over rows
|
||||||
|
of data organized into dictionaries. Default csv.DictReader.
|
||||||
"""
|
"""
|
||||||
ENTRY_SEED = {}
|
ENTRY_SEED = {}
|
||||||
COPIED_FIELDS = {}
|
COPIED_FIELDS = {}
|
||||||
|
Reader = csv.reader
|
||||||
|
DictReader = csv.DictReader
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _read_header_row(cls, row):
|
def _read_header_row(cls, row):
|
||||||
|
@ -47,7 +53,7 @@ class CSVImporterBase:
|
||||||
cls._HEADER_MAX_LEN = len(cls._NEEDED_KEYS)
|
cls._HEADER_MAX_LEN = len(cls._NEEDED_KEYS)
|
||||||
header = {}
|
header = {}
|
||||||
row = None
|
row = None
|
||||||
for row in csv.reader(input_file):
|
for row in cls.Reader(input_file):
|
||||||
row_data = cls._read_header_row(row)
|
row_data = cls._read_header_row(row)
|
||||||
if row_data is None:
|
if row_data is None:
|
||||||
break
|
break
|
||||||
|
@ -62,7 +68,7 @@ class CSVImporterBase:
|
||||||
|
|
||||||
def __init__(self, input_file):
|
def __init__(self, input_file):
|
||||||
self.entry_seed, fields = self._read_header(input_file)
|
self.entry_seed, fields = self._read_header(input_file)
|
||||||
self.in_csv = csv.DictReader(input_file, fields)
|
self.in_csv = self.DictReader(input_file, fields)
|
||||||
|
|
||||||
def __iter__(self):
|
def __iter__(self):
|
||||||
for row in self.in_csv:
|
for row in self.in_csv:
|
||||||
|
|
96
import2ledger/importers/_xls.py
Normal file
96
import2ledger/importers/_xls.py
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
import mmap
|
||||||
|
|
||||||
|
import xlrd
|
||||||
|
from . import _csv
|
||||||
|
|
||||||
|
class BookFromFile:
|
||||||
|
def __init__(self, xls_file, length=0, access=mmap.ACCESS_READ, **kwargs):
|
||||||
|
self.mmap = mmap.mmap(xls_file.fileno(), length, access=access)
|
||||||
|
self.book = xlrd.open_workbook(
|
||||||
|
xls_file.name,
|
||||||
|
file_contents=self.mmap,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_value, exc_tb):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.mmap.close()
|
||||||
|
del self.book
|
||||||
|
|
||||||
|
|
||||||
|
class RowReader:
|
||||||
|
def __init__(self, rows):
|
||||||
|
self._rows = iter(rows)
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __next__(self):
|
||||||
|
return self._format_row(next(self._rows))
|
||||||
|
|
||||||
|
def _format_row(self, row):
|
||||||
|
return [self._format_cell(cell) for cell in row]
|
||||||
|
|
||||||
|
def _format_cell(self, cell):
|
||||||
|
cell_type = cell.ctype
|
||||||
|
if cell_type is xlrd.XL_CELL_EMPTY:
|
||||||
|
return None
|
||||||
|
elif cell_type is xlrd.XL_CELL_BOOLEAN:
|
||||||
|
return bool(cell.value)
|
||||||
|
else:
|
||||||
|
return cell.value
|
||||||
|
|
||||||
|
|
||||||
|
class DictReader(RowReader):
|
||||||
|
def __init__(self, rows, fieldnames=None):
|
||||||
|
super().__init__(rows)
|
||||||
|
if fieldnames is None:
|
||||||
|
fieldnames = super()._format_row(next(self._rows))
|
||||||
|
self.fieldnames = fieldnames
|
||||||
|
|
||||||
|
def _format_row(self, row):
|
||||||
|
return {k: v for k, v in zip(self.fieldnames, super()._format_row(row))}
|
||||||
|
|
||||||
|
|
||||||
|
class XLSImporterBase(_csv.CSVImporterBase):
|
||||||
|
"""Base class for Excel spreadsheet importers.
|
||||||
|
|
||||||
|
Subclasses may define the following:
|
||||||
|
* _get_rows: A method that accepts an xlrd.Book object and returns an
|
||||||
|
iterator of rows from it. The default implementation yields each row
|
||||||
|
from each sheet in order.
|
||||||
|
"""
|
||||||
|
|
||||||
|
BOOK_KWARGS = {}
|
||||||
|
Reader = RowReader
|
||||||
|
DictReader = DictReader
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _open_book(cls, input_file):
|
||||||
|
return BookFromFile(input_file, **cls.BOOK_KWARGS)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_rows(cls, book):
|
||||||
|
for sheet_index in range(book.nsheets):
|
||||||
|
yield from book.sheet_by_index(sheet_index).get_rows()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def can_import(cls, input_file):
|
||||||
|
try:
|
||||||
|
with cls._open_book(input_file) as book_wrapper:
|
||||||
|
return super().can_import(cls._get_rows(book_wrapper.book))
|
||||||
|
except xlrd.biffh.XLRDError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def __init__(self, input_file):
|
||||||
|
self.wrapper = self._open_book(input_file)
|
||||||
|
return super().__init__(self._get_rows(self.wrapper.book))
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
yield from super().__iter__()
|
||||||
|
self.wrapper.close()
|
40
import2ledger/importers/brightfunds.py
Normal file
40
import2ledger/importers/brightfunds.py
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
from . import _xls
|
||||||
|
from .. import strparse
|
||||||
|
|
||||||
|
class DonorReportImporter(_xls.XLSImporterBase):
|
||||||
|
BOOK_KWARGS = {'encoding_override': 'utf-8'}
|
||||||
|
ENTRY_SEED = {'currency': 'USD'}
|
||||||
|
NEEDED_FIELDS = frozenset(['Created', 'Amount'])
|
||||||
|
COPIED_FIELDS = {
|
||||||
|
'Company Name': 'company_name',
|
||||||
|
'Donor Name': 'donor_name',
|
||||||
|
'Donor Email': 'donor_email',
|
||||||
|
'On Behalf Of': 'on_behalf_of',
|
||||||
|
'Designation': 'designation',
|
||||||
|
'Fund': 'fund',
|
||||||
|
'Type': 'type',
|
||||||
|
}
|
||||||
|
|
||||||
|
def _cell_is_blank(self, value):
|
||||||
|
return value == '-' or not value
|
||||||
|
|
||||||
|
def _read_row(self, row):
|
||||||
|
if any(self._cell_is_blank(row[key]) for key in self.NEEDED_FIELDS):
|
||||||
|
return None
|
||||||
|
names = [row[key] for key in ['Company Name', 'Donor Name', 'On Behalf Of']
|
||||||
|
if not self._cell_is_blank(row[key])]
|
||||||
|
try:
|
||||||
|
corporation, payee, *_ = names
|
||||||
|
except ValueError:
|
||||||
|
corporation = names[0]
|
||||||
|
payee = corporation
|
||||||
|
entry_data = {
|
||||||
|
'amount': '{:.2f}'.format(row['Amount']),
|
||||||
|
'corporation': corporation,
|
||||||
|
'date': strparse.date(row['Created'], '%m/%d/%Y'),
|
||||||
|
'payee': payee,
|
||||||
|
}
|
||||||
|
entry_data.update((entry_key, '')
|
||||||
|
for row_key, entry_key in self.COPIED_FIELDS.items()
|
||||||
|
if self._cell_is_blank(row[row_key]))
|
||||||
|
return entry_data
|
BIN
tests/data/BrightFunds.xls
Normal file
BIN
tests/data/BrightFunds.xls
Normal file
Binary file not shown.
|
@ -247,3 +247,19 @@
|
||||||
comment: ""
|
comment: ""
|
||||||
frequency: Recurring
|
frequency: Recurring
|
||||||
transaction_id: 67890TYUIO
|
transaction_id: 67890TYUIO
|
||||||
|
|
||||||
|
- source: BrightFunds.xls
|
||||||
|
importer: brightfunds.DonorReportImporter
|
||||||
|
expect:
|
||||||
|
- date: !!python/object/apply:datetime.date [2017, 10, 20]
|
||||||
|
currency: USD
|
||||||
|
amount: !!python/object/apply:decimal.Decimal [120]
|
||||||
|
payee: Dakota Smith
|
||||||
|
corporation: Company
|
||||||
|
company_name: ""
|
||||||
|
designation: ""
|
||||||
|
donor_name: Company
|
||||||
|
donor_email: ""
|
||||||
|
fund: ""
|
||||||
|
on_behalf_of: Dakota Smith
|
||||||
|
type: Matched Donation
|
||||||
|
|
Loading…
Reference in a new issue