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…
	
	Add table
		
		Reference in a new issue