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
 | 
			
		||||
  ================ ===========================================================
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
^^^^^^^
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -33,9 +33,15 @@ class CSVImporterBase:
 | 
			
		|||
      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
 | 
			
		||||
      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 = {}
 | 
			
		||||
    COPIED_FIELDS = {}
 | 
			
		||||
    Reader = csv.reader
 | 
			
		||||
    DictReader = csv.DictReader
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def _read_header_row(cls, row):
 | 
			
		||||
| 
						 | 
				
			
			@ -47,7 +53,7 @@ class CSVImporterBase:
 | 
			
		|||
        cls._HEADER_MAX_LEN = len(cls._NEEDED_KEYS)
 | 
			
		||||
        header = {}
 | 
			
		||||
        row = None
 | 
			
		||||
        for row in csv.reader(input_file):
 | 
			
		||||
        for row in cls.Reader(input_file):
 | 
			
		||||
            row_data = cls._read_header_row(row)
 | 
			
		||||
            if row_data is None:
 | 
			
		||||
                break
 | 
			
		||||
| 
						 | 
				
			
			@ -62,7 +68,7 @@ class CSVImporterBase:
 | 
			
		|||
 | 
			
		||||
    def __init__(self, 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):
 | 
			
		||||
        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: ""
 | 
			
		||||
      frequency: Recurring
 | 
			
		||||
      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…
	
	Add table
		
		Reference in a new issue