794 lines
		
	
	
	
		
			24 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable file
		
	
	
	
	
			
		
		
	
	
			794 lines
		
	
	
	
		
			24 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable file
		
	
	
	
	
#!/usr/bin/env python3
 | 
						|
"""donors2csv: Generate a spreadsheet of donor information.
 | 
						|
 | 
						|
This reads a file of contact information from PayPal and cross-references it
 | 
						|
against a Ledger to generate a spreadsheet of donor information.
 | 
						|
 | 
						|
The reason it uses the PayPal contact information file and not the supporters
 | 
						|
database is because the contact information is updated with every new donation,
 | 
						|
while addresses in the supporters database are not (as of May 2018).
 | 
						|
"""
 | 
						|
#
 | 
						|
# Copyright 2018 Brett Smith <brettcsmith@brettcsmith.org>
 | 
						|
# Licensed under the GNU Affero General Public License, version 3,
 | 
						|
# or (at your option) any later version.
 | 
						|
 | 
						|
import argparse
 | 
						|
import collections
 | 
						|
import contextlib
 | 
						|
import csv
 | 
						|
import datetime
 | 
						|
import decimal
 | 
						|
import itertools
 | 
						|
import logging
 | 
						|
import operator
 | 
						|
import pathlib
 | 
						|
import re
 | 
						|
import subprocess
 | 
						|
import sys
 | 
						|
 | 
						|
try:
 | 
						|
    import babel.numbers
 | 
						|
    from import2ledger import strparse
 | 
						|
    from import2ledger.hooks import add_entity
 | 
						|
except ImportError:
 | 
						|
    ENTITY_HOOK = None
 | 
						|
else:
 | 
						|
    ENTITY_HOOK = add_entity.AddEntityHook(None)
 | 
						|
 | 
						|
logger = logging.getLogger('conservancy.contacts')
 | 
						|
 | 
						|
ISO_DATE_FMT = '%Y-%m-%d'
 | 
						|
REPORT_ATTRIBUTES = (
 | 
						|
    'entity',
 | 
						|
    'display_name',
 | 
						|
    'first_name',
 | 
						|
    'last_name',
 | 
						|
    'email',
 | 
						|
    'address_name',
 | 
						|
    'address',
 | 
						|
    'city',
 | 
						|
    'region',
 | 
						|
    'postcode',
 | 
						|
    'country_code',
 | 
						|
    'country_name',
 | 
						|
    'payer_country',
 | 
						|
    'payment_count',
 | 
						|
    'payment_total',
 | 
						|
    'payment_programs',
 | 
						|
    'first_payment_date',
 | 
						|
    'first_payment_amount',
 | 
						|
    'first_payment_program',
 | 
						|
    'last_payment_date',
 | 
						|
    'last_payment_amount',
 | 
						|
    'last_payment_program',
 | 
						|
)
 | 
						|
 | 
						|
class Payee:
 | 
						|
    PAYPAL_FIELDS = {
 | 
						|
        'PayerDisplayName': 'display_name',
 | 
						|
        'ADD_Name': 'address_name',
 | 
						|
        'LastName': 'last_name',
 | 
						|
        'FirstName': 'first_name',
 | 
						|
        'Payer': 'email',
 | 
						|
        'CityName': 'city',
 | 
						|
        'StateOrProvince': 'region',
 | 
						|
        'PostalCode': 'postcode',
 | 
						|
        'Country': 'country_code',
 | 
						|
        'CountryName': 'country_name',
 | 
						|
        'PayerCountry': 'payer_country',
 | 
						|
    }
 | 
						|
 | 
						|
    def __init__(self, entity):
 | 
						|
        self.entity = entity
 | 
						|
        self.display_name = None
 | 
						|
        self.address_name = None
 | 
						|
        self.last_name = None
 | 
						|
        self.first_name = None
 | 
						|
        self.email = None
 | 
						|
        self.address = None
 | 
						|
        self.city = None
 | 
						|
        self.region = None
 | 
						|
        self.postcode = None
 | 
						|
        self.country_code = None
 | 
						|
        self.country_name = None
 | 
						|
        self.payer_country = None
 | 
						|
        self.payment_count = 0
 | 
						|
        self.payment_total = decimal.Decimal()
 | 
						|
        self.payment_programs = set()
 | 
						|
        self.first_payment_date = None
 | 
						|
        self.first_payment_amount = None
 | 
						|
        self.first_payment_program = None
 | 
						|
        self.last_payment_date = None
 | 
						|
        self.last_payment_amount = None
 | 
						|
        self.last_payment_program = None
 | 
						|
 | 
						|
    def update_from_paypal_contact(self, contact_data):
 | 
						|
        for data_key, attr_name in self.PAYPAL_FIELDS.items():
 | 
						|
            try:
 | 
						|
                setattr(self, attr_name, contact_data[data_key])
 | 
						|
            except KeyError:
 | 
						|
                pass
 | 
						|
        address = []
 | 
						|
        for n in itertools.count(1):
 | 
						|
            try:
 | 
						|
                address.append(contact_data['Street{}'.format(n)].strip())
 | 
						|
            except KeyError:
 | 
						|
                break
 | 
						|
        if address:
 | 
						|
            self.address = '\n'.join(address)
 | 
						|
 | 
						|
    def add_payment(self, date, amount, program):
 | 
						|
        self.payment_count += 1
 | 
						|
        self.payment_total += amount
 | 
						|
        self.payment_programs.add(program)
 | 
						|
        if (self.first_payment_date is None) or (self.first_payment_date > date):
 | 
						|
            self.first_payment_date = date
 | 
						|
            self.first_payment_amount = amount
 | 
						|
            self.first_payment_program = program
 | 
						|
        if (self.last_payment_date is None) or (self.last_payment_date < date):
 | 
						|
            self.last_payment_date = date
 | 
						|
            self.last_payment_amount = amount
 | 
						|
            self.last_payment_program = program
 | 
						|
 | 
						|
 | 
						|
class PayeeCache:
 | 
						|
    def __init__(self):
 | 
						|
        self._cache = {}
 | 
						|
 | 
						|
    @staticmethod
 | 
						|
    def _entity_from(thing):
 | 
						|
        return getattr(thing, 'entity', thing)
 | 
						|
 | 
						|
    def __contains__(self, elem):
 | 
						|
        return self._entity_from(elem) in self._cache
 | 
						|
 | 
						|
    def __iter__(self):
 | 
						|
        return iter(self._cache.values())
 | 
						|
 | 
						|
    def get_or_create(self, entity, factory=Payee):
 | 
						|
        try:
 | 
						|
            return self._cache[entity]
 | 
						|
        except KeyError:
 | 
						|
            self._cache[entity] = factory(entity)
 | 
						|
            return self._cache[entity]
 | 
						|
 | 
						|
 | 
						|
class CSVReport:
 | 
						|
    _format_date = operator.methodcaller('strftime', '%Y-%m-%d')
 | 
						|
    _format_unsorted = lambda seq: '\n'.join(sorted(seq))
 | 
						|
    _format_usd = lambda d: babel.numbers.format_currency(abs(d), 'USD')
 | 
						|
 | 
						|
    FORMATTERS = {
 | 
						|
        'payment_programs': _format_unsorted,
 | 
						|
        'payment_total': _format_usd,
 | 
						|
        'first_payment_date': _format_date,
 | 
						|
        'first_payment_amount': _format_usd,
 | 
						|
        'last_payment_date': _format_date,
 | 
						|
        'last_payment_amount': _format_usd,
 | 
						|
    }
 | 
						|
 | 
						|
    def __init__(self, attr_names, column_names=None):
 | 
						|
        if column_names is None:
 | 
						|
            column_names = [name.replace('_', ' ').title()
 | 
						|
                            for name in attr_names]
 | 
						|
        self.attr_names = attr_names
 | 
						|
        self.column_names = column_names
 | 
						|
 | 
						|
    def _format_cell(self, payee, attr_name):
 | 
						|
        value = getattr(payee, attr_name)
 | 
						|
        if value is None:
 | 
						|
            return value
 | 
						|
        try:
 | 
						|
            formatter = self.FORMATTERS[attr_name]
 | 
						|
        except KeyError:
 | 
						|
            return value
 | 
						|
        else:
 | 
						|
            return formatter(value)
 | 
						|
 | 
						|
    def write(self, out_file, payees):
 | 
						|
        writer = csv.writer(out_file)
 | 
						|
        writer.writerow(self.column_names)
 | 
						|
        for payee in payees:
 | 
						|
            writer.writerow(tuple(self._format_cell(payee, attr_name)
 | 
						|
                                  for attr_name in self.attr_names))
 | 
						|
 | 
						|
 | 
						|
Country = collections.namedtuple(
 | 
						|
    'Country', ('code_un', 'code2', 'code3', 'name', 'capital'),
 | 
						|
)
 | 
						|
 | 
						|
NAPostcode = collections.namedtuple(
 | 
						|
    'NAPostcode', ('code2', 'name'),
 | 
						|
)
 | 
						|
 | 
						|
def _read_misc_data(factory, lines):
 | 
						|
    return [factory(line.rstrip('\n').split(':')) for line in lines if
 | 
						|
            line and (not line.isspace()) and (not line.startswith('#'))]
 | 
						|
 | 
						|
# The big strings below are the contents of the corresponding GNU miscfiles.
 | 
						|
# Note that they're under GPLv2+.
 | 
						|
COUNTRIES = _read_misc_data(Country._make, """
 | 
						|
# UN Code number: 2 letter ISO abbrev : 3 letter ISO abbrev : name : capital
 | 
						|
004:AF:AFG:Afghanistan:Kabul
 | 
						|
248:AX:ALA:Åland Islands:Mariehamn
 | 
						|
008:AL:ALB:Albania:Tirana
 | 
						|
012:DZ:DZA:Algeria:Algiers
 | 
						|
016:AS:ASM:American Samoa:Pago Pago
 | 
						|
020:AD:AND:Andorra:Andorra la Vella
 | 
						|
024:AO:AGO:Angola:Luanda
 | 
						|
660:AI:AIA:Anguilla:The Valley
 | 
						|
010:AQ:ATA:Antarctica:
 | 
						|
028:AG:ATG:Antigua and Barbuda:St. John's
 | 
						|
032:AR:ARG:Argentina:Buenos Aires
 | 
						|
051:AM:ARM:Armenia:Yerevan
 | 
						|
533:AW:ABW:Aruba:Oranjestad
 | 
						|
036:AU:AUS:Australia:Canberra
 | 
						|
040:AT:AUT:Austria:Vienna
 | 
						|
031:AZ:AZE:Azerbaijan:Baku
 | 
						|
044:BS:BHS:Bahamas:Nassau
 | 
						|
048:BH:BHR:Bahrain:Manama
 | 
						|
050:BD:BGD:Bangladesh:Dhaka
 | 
						|
052:BB:BRB:Barbados:Bridgetown
 | 
						|
112:BY:BLR:Belarus:Minsk
 | 
						|
056:BE:BEL:Belgium:Brussels
 | 
						|
084:BZ:BLZ:Belize:Belmopan
 | 
						|
204:BJ:BEN:Benin:Porto-Novo (official capital), Cotonou (seat of government)
 | 
						|
060:BM:BMU:Bermuda:Hamilton
 | 
						|
064:BT:BTN:Bhutan:Thimphu
 | 
						|
068:BO:BOL:Bolivia:La Paz (seat of government), Sucre (legal capital and seat of judiciary)
 | 
						|
070:BA:BIH:Bosnia and Herzegovina:Sarajevo
 | 
						|
072:BW:BWA:Botswana:Gaborone
 | 
						|
074:BV:BVT:Bouvet Island:
 | 
						|
076:BR:BRA:Brazil:Brasilia
 | 
						|
086:IO:IOT:British Indian Ocean Territory:Diego Garcia
 | 
						|
092:VG:VGB:British Virgin Islands:Road Town
 | 
						|
096:BN:BRN:Brunei Darussalam:Bandar Seri Begawan
 | 
						|
100:BG:BGR:Bulgaria:Sofia
 | 
						|
854:BF:BFA:Burkina Faso:Ouagadougou
 | 
						|
108:BI:BDI:Burundi:Bujumbura
 | 
						|
116:KH:KHM:Cambodia:Phnom Penh
 | 
						|
120:CM:CMR:Cameroon:Yaoundé
 | 
						|
124:CA:CAN:Canada:Ottawa
 | 
						|
132:CV:CPV:Cape Verde:Praia
 | 
						|
136:KY:CYM:Cayman Islands:George Town
 | 
						|
140:CF:CAF:Central African Republic:Bangui
 | 
						|
148:TD:TCD:Chad:N'Djamena
 | 
						|
830:::Channel Islands:
 | 
						|
152:CL:CHL:Chile:Santiago
 | 
						|
156:CN:CHN:China:Beijing
 | 
						|
162:CX:CXR:Christmas Island:The Settlement
 | 
						|
166:CC:CCK:Cocos (Keeling) Islands:West Island
 | 
						|
170:CO:COL:Colombia:Bogota
 | 
						|
174:KM:COM:Comoros:Moroni
 | 
						|
178:CG:COG:Congo:Brazzaville
 | 
						|
184:CK:COK:Cook Islands:Avarua
 | 
						|
188:CR:CRI:Costa Rica:San José
 | 
						|
384:CI:CIV:Côte d'Ivoire:Yamoussoukro (official capital), Abidjan (administrative center)
 | 
						|
191:HR:HRV:Croatia:Zagreb
 | 
						|
192:CU:CUB:Cuba:Havana
 | 
						|
196:CY:CYP:Cyprus:Nicosia
 | 
						|
203:CZ:CZE:Czech Republic:Prague
 | 
						|
408:KP:PRK:Democratic People's Republic of Korea:Pyongyang
 | 
						|
180:CD:COD:Democratic Republic of the Congo:Kinshasa
 | 
						|
208:DK:DNK:Denmark:Copenhagen
 | 
						|
262:DJ:DJI:Djibouti:Djibouti
 | 
						|
212:DM:DMA:Dominica:Roseau
 | 
						|
214:DO:DOM:Dominican Republic:Santo Domingo
 | 
						|
218:EC:ECU:Ecuador:Quito
 | 
						|
818:EG:EGY:Egypt:Cairo
 | 
						|
222:SV:SLV:El Salvador:San Salvador
 | 
						|
226:GQ:GNQ:Equatorial Guinea:Malabo
 | 
						|
232:ER:ERI:Eritrea:Asmara
 | 
						|
233:EE:EST:Estonia:Tallinn
 | 
						|
231:ET:ETH:Ethiopia:Addis Ababa
 | 
						|
234:FO:FRO:Faeroe Islands:Tórshavn
 | 
						|
238:FK:FLK:Falkland Islands (Malvinas):Stanley
 | 
						|
583:FM:FSM:Federated States of Micronesia:Palikir
 | 
						|
242:FJ:FJI:Fiji:Suva
 | 
						|
246:FI:FIN:Finland:Helsinki
 | 
						|
250:FR:FRA:France:Paris
 | 
						|
254:GF:GUF:French Guiana:Cayenne
 | 
						|
258:PF:PYF:French Polynesia:Papeete
 | 
						|
260:TF:ATF:French Southern Territories:
 | 
						|
266:GA:GAB:Gabon:Libreville
 | 
						|
270:GM:GMB:Gambia:Banjul
 | 
						|
268:GE:GEO:Georgia:T'bilisi
 | 
						|
276:DE:DEU:Germany:Berlin
 | 
						|
288:GH:GHA:Ghana:Accra
 | 
						|
292:GI:GIB:Gibraltar:Gibraltar
 | 
						|
300:GR:GRC:Greece:Athens
 | 
						|
304:GL:GRL:Greenland:Nuuk
 | 
						|
308:GD:GRD:Grenada:St. George's
 | 
						|
312:GP:GLP:Guadeloupe:Basse-Terre
 | 
						|
316:GU:GUM:Guam:Hagåtña
 | 
						|
320:GT:GTM:Guatemala:Guatemala
 | 
						|
324:GN:GIN:Guinea:Conakry
 | 
						|
624:GW:GNB:Guinea-Bissau:Bissau
 | 
						|
328:GY:GUY:Guyana:Georgetown
 | 
						|
332:HT:HTI:Haiti:Port-au-Prince
 | 
						|
334:HM:HMD:Heard Island and McDonald Islands:
 | 
						|
336:VA:VAT:Holy See:Vatican City
 | 
						|
340:HN:HND:Honduras:Tegucigalpa
 | 
						|
344:HK:HKG:Hong Kong Special Administrative Region of China:
 | 
						|
348:HU:HUN:Hungary:Budapest
 | 
						|
352:IS:ISL:Iceland:Reykjavik
 | 
						|
356:IN:IND:India:New Delhi
 | 
						|
360:ID:IDN:Indonesia:Jakarta
 | 
						|
364:IR:IRN:Iran:Tehran
 | 
						|
368:IQ:IRQ:Iraq:Baghdad
 | 
						|
372:IE:IRL:Ireland:Dublin
 | 
						|
833:::Isle of Man:Douglas
 | 
						|
376:IL:ISR:Israel:Jerusalem
 | 
						|
380:IT:ITA:Italy:Rome
 | 
						|
388:JM:JAM:Jamaica:Kingston
 | 
						|
392:JP:JPN:Japan:Tokyo
 | 
						|
400:JO:JOR:Jordan:'Amman
 | 
						|
398:KZ:KAZ:Kazakhstan:Astana
 | 
						|
404:KE:KEN:Kenya:Nairobi
 | 
						|
296:KI:KIR:Kiribati:Tarawa
 | 
						|
414:KW:KWT:Kuwait:Kuwait
 | 
						|
417:KG:KGZ:Kyrgyzstan:Bishkek
 | 
						|
418:LA:LAO:Lao People's Democratic Republic:Vientiane
 | 
						|
428:LV:LVA:Latvia:Riga
 | 
						|
422:LB:LBN:Lebanon:Beirut
 | 
						|
426:LS:LSO:Lesotho:Maseru
 | 
						|
430:LR:LBR:Liberia:Monrovia
 | 
						|
434:LY:LBY:Libyan Arab Jamahiriya:Tripoli
 | 
						|
438:LI:LIE:Liechtenstein:Vaduz
 | 
						|
440:LT:LTU:Lithuania:Vilnius
 | 
						|
442:LU:LUX:Luxembourg:Luxembourg
 | 
						|
446:MO:MAC:Macau Special Administrative Region of China:
 | 
						|
450:MG:MDG:Madagascar:Antananarivo
 | 
						|
454:MW:MWI:Malawi:Lilongwe
 | 
						|
458:MY:MYS:Malaysia:Kuala Lumpur (official), Putrajaya (administrative)
 | 
						|
462:MV:MDV:Maldives:Malé
 | 
						|
466:ML:MLI:Mali:Bamako
 | 
						|
470:MT:MLT:Malta:Valletta
 | 
						|
584:MH:MHL:Marshall Islands:Majuro
 | 
						|
474:MQ:MTQ:Martinique:Fort-de-France
 | 
						|
478:MR:MRT:Mauritania:Nouakchott
 | 
						|
480:MU:MUS:Mauritius:Port Louis
 | 
						|
175:YT:MYT:Mayotte:Mamoutzou
 | 
						|
484:MX:MEX:Mexico:Mexico
 | 
						|
492:MC:MCO:Monaco:Monaco
 | 
						|
496:MN:MNG:Mongolia:Ulaanbaatar
 | 
						|
500:MS:MSR:Montserrat:Plymouth (abandoned), Brades Estate (interim)
 | 
						|
504:MA:MAR:Morocco:Rabat
 | 
						|
508:MZ:MOZ:Mozambique:Maputo
 | 
						|
104:MM:MMR:Myanmar:Rangoon
 | 
						|
516:NA:NAM:Namibia:Windhoek
 | 
						|
520:NR:NRU:Nauru:
 | 
						|
524:NP:NPL:Nepal:Kathmandu
 | 
						|
528:NL:NLD:Netherlands:Amsterdam (official), The Hague (seat of government)
 | 
						|
530:AN:ANT:Netherlands Antilles:Willemstad
 | 
						|
540:NC:NCL:New Caledonia:Noumea
 | 
						|
554:NZ:NZL:New Zealand:Wellington
 | 
						|
558:NI:NIC:Nicaragua:Managua
 | 
						|
562:NE:NER:Niger:Niamey
 | 
						|
566:NG:NGA:Nigeria:Abuja
 | 
						|
570:NU:NIU:Niue:Alofi
 | 
						|
574:NF:NFK:Norfolk Island:Kingston
 | 
						|
580:MP:MNP:Northern Mariana Islands:Saipan
 | 
						|
578:NO:NOR:Norway:Oslo
 | 
						|
275:PS:PSE:Occupied Palestinian Territory:
 | 
						|
512:OM:OMN:Oman:Muscat
 | 
						|
586:PK:PAK:Pakistan:Islamabad
 | 
						|
585:PW:PLW:Palau:Koror
 | 
						|
591:PA:PAN:Panama:Panama
 | 
						|
598:PG:PNG:Papua New Guinea:Port Moresby
 | 
						|
600:PY:PRY:Paraguay:Asunción
 | 
						|
604:PE:PER:Peru:Lima
 | 
						|
608:PH:PHL:Philippines:Manila
 | 
						|
612:PN:PCN:Pitcairn:Adamstown
 | 
						|
616:PL:POL:Poland:Warsaw
 | 
						|
620:PT:PRT:Portugal:Lisbon
 | 
						|
630:PR:PRI:Puerto Rico:San Juan
 | 
						|
634:QA:QAT:Qatar:Doha
 | 
						|
410:KR:KOR:Republic of Korea:Seoul
 | 
						|
498:MD:MDA:Republic of Moldova:Chișinău
 | 
						|
638:RE:REU:Réunion:Saint-Denis
 | 
						|
642:RO:ROU:Romania:Bucharest
 | 
						|
643:RU:RUS:Russian Federation:Moscow
 | 
						|
646:RW:RWA:Rwanda:Kigali
 | 
						|
654:SH:SHN:Saint Helena:Jamestown
 | 
						|
659:KN:KNA:Saint Kitts and Nevis:Basseterre
 | 
						|
662:LC:LCA:Saint Lucia:Castries
 | 
						|
666:PM:SPM:Saint Pierre and Miquelon:Saint-Pierre
 | 
						|
670:VC:VCT:Saint Vincent and the Grenadines:Kingstown
 | 
						|
882:WS:WSM:Samoa:Apia
 | 
						|
674:SM:SMR:San Marino:San Marino
 | 
						|
678:ST:STP:São Tomé and Príncipe:São Tomé
 | 
						|
682:SA:SAU:Saudi Arabia:Riyadh
 | 
						|
686:SN:SEN:Senegal:Dakar
 | 
						|
891:CS:SCG:Serbia and Montenegro:Belgrade
 | 
						|
690:SC:SYC:Seychelles:Victoria
 | 
						|
694:SL:SLE:Sierra Leone:Freetown
 | 
						|
702:SG:SGP:Singapore:Singapore
 | 
						|
703:SK:SVK:Slovakia:Bratislava
 | 
						|
705:SI:SVN:Slovenia:Ljubljana
 | 
						|
090:SB:SLB:Solomon Islands:Honiara
 | 
						|
706:SO:SOM:Somalia:Mogadishu
 | 
						|
710:ZA:ZAF:South Africa:Pretoria (official), Cape Town (legislative), Bloemfontein (judicial)
 | 
						|
239:GS:SGS:South Georgia and the South Sandwich Islands:
 | 
						|
724:ES:ESP:Spain:Madrid
 | 
						|
144:LK:LKA:Sri Lanka:Colombo (official), Sri Jayewardenepura Kotte (legislative)
 | 
						|
736:SD:SDN:Sudan:Khartoum
 | 
						|
740:SR:SUR:Suriname:Paramaribo
 | 
						|
744:SJ:SJM:Svalbard and Jan Mayen Islands:Longyearbyen
 | 
						|
748:SZ:SWZ:Swaziland:Mbabane (official), Lobamba (royal and legislative)
 | 
						|
752:SE:SWE:Sweden:Stockholm
 | 
						|
756:CH:CHE:Switzerland:Bern
 | 
						|
760:SY:SYR:Syrian Arab Republic:Damascus
 | 
						|
158:TW:TWN:Taiwan, Province of China:Taipei
 | 
						|
762:TJ:TJK:Tajikistan:Dushanbe
 | 
						|
764:TH:THA:Thailand:Bangkok
 | 
						|
807:MK:MKD:The former Yugoslav Republic of Macedonia:Skopje
 | 
						|
626:TL:TLS:Timor-Leste:Dili
 | 
						|
768:TG:TGO:Togo:Lome
 | 
						|
772:TK:TKL:Tokelau:
 | 
						|
776:TO:TON:Tonga:Nuku'alofa
 | 
						|
780:TT:TTO:Trinidad and Tobago:Port-of-Spain
 | 
						|
788:TN:TUN:Tunisia:Tunis
 | 
						|
792:TR:TUR:Turkey:Ankara
 | 
						|
795:TM:TKM:Turkmenistan:Ashgabat
 | 
						|
796:TC:TCA:Turks and Caicos Islands:Cockburn Town
 | 
						|
798:TV:TUV:Tuvalu:Funafuti (offcial), Vaiaku Village (administrative)
 | 
						|
800:UG:UGA:Uganda:Kampala
 | 
						|
804:UA:UKR:Ukraine:Kiev
 | 
						|
784:AE:ARE:United Arab Emirates:Abu Dhabi
 | 
						|
826:GB:GBR:United Kingdom of Great Britain and Northern Ireland:London
 | 
						|
834:TZ:TZA:United Republic of Tanzania:Dodoma
 | 
						|
581:UM:UMI:United States Minor Outlying Islands:
 | 
						|
840:US:USA:United States of America:Washington, DC
 | 
						|
850:VI:VIR:United States Virgin Islands:Charlotte Amalie
 | 
						|
858:UY:URY:Uruguay:Montevideo
 | 
						|
860:UZ:UZB:Uzbekistan:Tashkent
 | 
						|
548:VU:VUT:Vanuatu:Port Vila
 | 
						|
862:VE:VEN:Venezuela:Caracas
 | 
						|
704:VN:VNM:Viet Nam:Hanoi
 | 
						|
876:WF:WLF:Wallis and Futuna Islands:Mata-Utu
 | 
						|
732:EH:ESH:Western Sahara:Laâyoune
 | 
						|
887:YE:YEM:Yemen:Sanaa
 | 
						|
894:ZM:ZMB:Zambia:Lusaka
 | 
						|
716:ZW:ZWE:Zimbabwe:Harare
 | 
						|
""".splitlines())
 | 
						|
 | 
						|
NA_POSTCODES = _read_misc_data(NAPostcode._make, """
 | 
						|
# Postal codes
 | 
						|
#
 | 
						|
# Code : Region
 | 
						|
 | 
						|
# American States
 | 
						|
AL:Alabama
 | 
						|
AK:Alaska
 | 
						|
AZ:Arizona
 | 
						|
AR:Arkansas
 | 
						|
CA:California
 | 
						|
CO:Colorado
 | 
						|
CT:Connecticut
 | 
						|
DE:Delaware
 | 
						|
FL:Florida
 | 
						|
GA:Georgia
 | 
						|
HI:Hawaii
 | 
						|
ID:Idaho
 | 
						|
IL:Illinois
 | 
						|
IN:Indiana
 | 
						|
IA:Iowa
 | 
						|
KS:Kansas
 | 
						|
KY:Kentucky
 | 
						|
LA:Louisiana
 | 
						|
ME:Maine
 | 
						|
MD:Maryland
 | 
						|
MA:Massachusetts
 | 
						|
MI:Michigan
 | 
						|
MN:Minnesota
 | 
						|
MS:Mississippi
 | 
						|
MO:Missouri
 | 
						|
MT:Montana
 | 
						|
NE:Nebraska
 | 
						|
NV:Nevada
 | 
						|
NH:New Hampshire
 | 
						|
NJ:New Jersey
 | 
						|
NM:New Mexico
 | 
						|
NY:New York
 | 
						|
NC:North Carolina
 | 
						|
ND:North Dakota
 | 
						|
OH:Ohio
 | 
						|
OK:Oklahoma
 | 
						|
OR:Oregon
 | 
						|
PA:Pennsylvania
 | 
						|
RI:Rhode Island
 | 
						|
SC:South Carolina
 | 
						|
SD:South Dakota
 | 
						|
TN:Tennessee
 | 
						|
TX:Texas
 | 
						|
UT:Utah
 | 
						|
VT:Vermont
 | 
						|
VA:Virginia
 | 
						|
WA:Washington
 | 
						|
WV:West Virginia
 | 
						|
WI:Wisconsin
 | 
						|
WY:Wyoming
 | 
						|
 | 
						|
# American Possessions
 | 
						|
AS:American Samoa
 | 
						|
DC:District of Columbia
 | 
						|
FM:Federated States of Micronesia
 | 
						|
GU:Guam
 | 
						|
MH:Marshall Islands
 | 
						|
MP:Northern Mariana Islands
 | 
						|
PW:Palau
 | 
						|
PR:Puerto Rico
 | 
						|
VI:Virgin Islands
 | 
						|
 | 
						|
# American military
 | 
						|
AE:Armed Forces Africa
 | 
						|
AA:Armed Forces Americas
 | 
						|
AE:Armed Forces Canada
 | 
						|
AE:Armed Forces Europe
 | 
						|
AE:Armed Forces Middle East
 | 
						|
AP:Armed Forces Pacific
 | 
						|
 | 
						|
# Canadian Provinces
 | 
						|
AB:Alberta
 | 
						|
BC:British Columbia
 | 
						|
MB:Manitoba
 | 
						|
NB:New Brunswick
 | 
						|
NL:Newfoundland
 | 
						|
NS:Nova Scotia
 | 
						|
NT:Northwest Territories
 | 
						|
NU:Nunavut
 | 
						|
ON:Ontario
 | 
						|
PE:Prince Edward Island
 | 
						|
QC:Quebec
 | 
						|
SK:Saskatchewan
 | 
						|
YT:Yukon
 | 
						|
""".splitlines())
 | 
						|
 | 
						|
class Filters:
 | 
						|
    SEARCH_ALIASES = {
 | 
						|
        'state': 'region',
 | 
						|
        'province': 'region',
 | 
						|
    }
 | 
						|
 | 
						|
    def __init__(self):
 | 
						|
        self.tests = {
 | 
						|
            'entity_defined': lambda payee: payee.entity is not None,
 | 
						|
        }
 | 
						|
        self.regions = set()
 | 
						|
        self.country_names = set()
 | 
						|
        self.country_codes = set()
 | 
						|
        self.since_date = None
 | 
						|
 | 
						|
    def parse_and_add(self, s):
 | 
						|
        key, _, value = s.partition('=')
 | 
						|
        if (not key) or (not value):
 | 
						|
            raise ValueError("malformed match criteria {!r}".format(s))
 | 
						|
        key = key.lower()
 | 
						|
        key = self.SEARCH_ALIASES.get(key, key)
 | 
						|
        try:
 | 
						|
            self.tests[key] = getattr(self, '{}_test'.format(key))(value)
 | 
						|
        except AttributeError:
 | 
						|
            raise ValueError("unknown match criteria {!r}".format(key))
 | 
						|
 | 
						|
    def _normalize_s(self, s):
 | 
						|
        if s is None:
 | 
						|
            return None
 | 
						|
        else:
 | 
						|
            return re.sub(r'\s+', ' ', s.strip().lower())
 | 
						|
 | 
						|
    def _matching_locations(self, needle, haystack):
 | 
						|
        return [loc for loc in haystack if any(s.lower() == needle for s in loc)]
 | 
						|
 | 
						|
    def region_test(self, value):
 | 
						|
        needle = self._normalize_s(value)
 | 
						|
        regions = self._matching_locations(needle, NA_POSTCODES)
 | 
						|
        if regions:
 | 
						|
            self.regions.update(region.code2.lower() for region in regions)
 | 
						|
            self.regions.update(region.name.lower() for region in regions)
 | 
						|
        else:
 | 
						|
            self.regions.add(needle)
 | 
						|
        return self.filter_on_region
 | 
						|
 | 
						|
    def filter_on_region(self, payee):
 | 
						|
        return self._normalize_s(payee.region) in self.regions
 | 
						|
 | 
						|
    def country_test(self, value):
 | 
						|
        needle = self._normalize_s(value)
 | 
						|
        countries = self._matching_locations(needle, COUNTRIES)
 | 
						|
        if countries:
 | 
						|
            self.country_codes.update(country.code2.lower() for country in countries)
 | 
						|
            self.country_names.update(country.name.lower() for country in countries)
 | 
						|
        else:
 | 
						|
            self.country_codes.add(needle)
 | 
						|
            self.country_names.add(needle)
 | 
						|
        return self.filter_on_country
 | 
						|
 | 
						|
    def filter_on_country(self, payee):
 | 
						|
        return (
 | 
						|
            self._normalize_s(payee.country_code) in self.country_codes
 | 
						|
            or self._normalize_s(payee.country_name) in self.country_names
 | 
						|
        )
 | 
						|
 | 
						|
    def since_test(self, s):
 | 
						|
        since_date = strparse.date(s, ISO_DATE_FMT)
 | 
						|
        self.since_date = s
 | 
						|
        return lambda payee: (
 | 
						|
            payee.last_payment_date is not None
 | 
						|
            and payee.last_payment_date >= since_date
 | 
						|
        )
 | 
						|
 | 
						|
    def payee_passes(self, payee):
 | 
						|
        return all(test(payee) for test in self.tests.values())
 | 
						|
 | 
						|
    def filter_payees(self, payees):
 | 
						|
        for payee in payees:
 | 
						|
            if self.payee_passes(payee):
 | 
						|
                yield payee
 | 
						|
 | 
						|
 | 
						|
def read_paypal_contacts(in_file, payees):
 | 
						|
    contact_data = {}
 | 
						|
    for line in in_file:
 | 
						|
        if re.match(r'^#+$', line):
 | 
						|
            try:
 | 
						|
                contact_data['payee'] = '{FirstName} {LastName}'.format_map(contact_data)
 | 
						|
            except KeyError:
 | 
						|
                pass
 | 
						|
            else:
 | 
						|
                ENTITY_HOOK.run(contact_data)
 | 
						|
                payee = payees.get_or_create(contact_data['entity'])
 | 
						|
                payee.update_from_paypal_contact(contact_data)
 | 
						|
            contact_data = {}
 | 
						|
        else:
 | 
						|
            key, _, value = line.partition(':')
 | 
						|
            contact_data[key] = value.strip()
 | 
						|
 | 
						|
def read_ledger_payments(in_file, payees):
 | 
						|
    for row in csv.reader(in_file):
 | 
						|
        date = strparse.date(row[1], ISO_DATE_FMT)
 | 
						|
        amount = decimal.Decimal(row[2].lstrip('$ ').replace(',', ''))
 | 
						|
        payee = payees.get_or_create(row[0])
 | 
						|
        payee.add_payment(date, amount, row[3])
 | 
						|
 | 
						|
def parse_arguments(arglist):
 | 
						|
    parser = argparse.ArgumentParser(
 | 
						|
        formatter_class=argparse.RawDescriptionHelpFormatter,
 | 
						|
        epilog="""You can use the following search criteria (omit the brackets):
 | 
						|
 | 
						|
  country=[2-letter or 3-letter country code, or name]
 | 
						|
  state=[2-letter North American postal code, or name]
 | 
						|
  province=[name]
 | 
						|
  since=[YYYY-MM-DD]
 | 
						|
 | 
						|
For example, to find all donors in Kentucky who donated since mid-2017:
 | 
						|
 | 
						|
  donors2csv paypal-contacts.txt state=ky since=2017-07-01
 | 
						|
 | 
						|
Only donors that match all given criteria are included in the output.
 | 
						|
""",
 | 
						|
    )
 | 
						|
    parser.add_argument(
 | 
						|
        '--loglevel',
 | 
						|
        choices=['debug', 'info', 'warning', 'error', 'critical'],
 | 
						|
        default='warning',
 | 
						|
        help=argparse.SUPPRESS,  # Loggingi not implemented yet
 | 
						|
        # help="Show log messages from this level (default %(default)s)",
 | 
						|
    )
 | 
						|
    parser.add_argument(
 | 
						|
        '--ledger-file',
 | 
						|
        type=pathlib.Path,
 | 
						|
        help="Path to the Ledger file to cross-reference against."
 | 
						|
        " Default guessed from the contacts file path.",
 | 
						|
    )
 | 
						|
    parser.add_argument(
 | 
						|
        '--output-file', '-O',
 | 
						|
        type=pathlib.Path,
 | 
						|
        help="Path to write the CSV spreadsheet."
 | 
						|
        " Default determined from match criteria.",
 | 
						|
    )
 | 
						|
    parser.add_argument(
 | 
						|
        '--overwrite',
 | 
						|
        action='store_const',
 | 
						|
        dest='output_mode',
 | 
						|
        const='w',
 | 
						|
        default='x',
 | 
						|
        help="Overwrite the --output-file if it already exists",
 | 
						|
    )
 | 
						|
    parser.add_argument(
 | 
						|
        'contacts_file',
 | 
						|
        type=pathlib.Path,
 | 
						|
        help="Path to a paypal-contacts.txt file",
 | 
						|
    )
 | 
						|
    parser.add_argument(
 | 
						|
        'conditions',
 | 
						|
        nargs=argparse.REMAINDER,
 | 
						|
        help="Only include contacts that match these criteria",
 | 
						|
    )
 | 
						|
    args = parser.parse_args(arglist)
 | 
						|
    args.filters = Filters()
 | 
						|
    for filter_s in args.conditions:
 | 
						|
        try:
 | 
						|
            args.filters.parse_and_add(filter_s)
 | 
						|
        except ValueError as error:
 | 
						|
            parser.error("{}: {}".format(filter_s, error.args[0]))
 | 
						|
    args.loglevel = getattr(logging, args.loglevel.upper())
 | 
						|
    if args.ledger_file is None:
 | 
						|
        ledger_name = args.contacts_file.name.replace('-paypal-contacts', '')
 | 
						|
        args.ledger_file = args.contacts_file.with_name(ledger_name).with_suffix('.ledger')
 | 
						|
    if args.output_file is None:
 | 
						|
        output_parts = []
 | 
						|
        regions_count = len(args.filters.regions)
 | 
						|
        if regions_count == 1:
 | 
						|
            output_parts.append(next(iter(args.filters.regions)))
 | 
						|
        elif regions_count > 1:
 | 
						|
            output_parts.append('-'.join(s.upper() for s in sorted(args.filters.regions) if len(s) == 2))
 | 
						|
        if args.filters.country_codes:
 | 
						|
            output_parts.append('-'.join(s.upper() for s in sorted(args.filters.country_codes)))
 | 
						|
        output_parts.append('Conservancy-contacts')
 | 
						|
        if args.filters.since_date:
 | 
						|
            output_parts.append('since-' + args.filters.since_date)
 | 
						|
        args.output_file = pathlib.Path('_'.join(output_parts)).with_suffix('.csv')
 | 
						|
    return args
 | 
						|
 | 
						|
def setup_logger(logger, loglevel, stream):
 | 
						|
    formatter = logging.Formatter('%(name)s: %(levelname)s: %(message)s')
 | 
						|
    handler = logging.StreamHandler(stream)
 | 
						|
    handler.setFormatter(formatter)
 | 
						|
    logger.addHandler(handler)
 | 
						|
    logger.setLevel(loglevel)
 | 
						|
 | 
						|
def main(arglist=None, stdout=sys.stdout, stderr=sys.stderr):
 | 
						|
    if ENTITY_HOOK is None:
 | 
						|
        print(
 | 
						|
            "Error: Failed to find the import2ledger module for supporting code.",
 | 
						|
            "Please download it from",
 | 
						|
            "<https://k.sfconservancy.org/NPO-Accounting/import2ledger>",
 | 
						|
            "and follow the instructions in the README to install it.",
 | 
						|
            sep='\n', file=stderr,
 | 
						|
        )
 | 
						|
        return 4
 | 
						|
 | 
						|
    args = parse_arguments(arglist)
 | 
						|
    setup_logger(logger, args.loglevel, stderr)
 | 
						|
    payees = PayeeCache()
 | 
						|
 | 
						|
    with args.contacts_file.open() as contacts_file:
 | 
						|
        read_paypal_contacts(contacts_file, payees)
 | 
						|
 | 
						|
    with subprocess.Popen(
 | 
						|
            ['ledger', '--args-only', 'csv',
 | 
						|
             '-V',
 | 
						|
             '--file', str(args.ledger_file),
 | 
						|
             '--csv-format', "%(quoted(meta('Entity'))),%(format_date(date, '%Y-%m-%d')),%(quoted(display_amount)),%(quoted(meta('Program')))\n",
 | 
						|
             'Income:Conservancy:Donations',
 | 
						|
            ],
 | 
						|
            stdout=subprocess.PIPE,
 | 
						|
            universal_newlines=True,
 | 
						|
    ) as proc:
 | 
						|
        read_ledger_payments(proc.stdout, payees)
 | 
						|
    if proc.returncode != 0:
 | 
						|
        logger.error("couldn't read payment data: ledger exited %s", proc.returncode)
 | 
						|
        return 5
 | 
						|
 | 
						|
    report = CSVReport(REPORT_ATTRIBUTES)
 | 
						|
    with contextlib.ExitStack() as stack:
 | 
						|
        if str(args.output_file) == '-':
 | 
						|
            out_file = stdout
 | 
						|
        else:
 | 
						|
            out_file = stack.enter_context(args.output_file.open(args.output_mode))
 | 
						|
        report.write(out_file, args.filters.filter_payees(payees))
 | 
						|
    print("Output saved to", str(args.output_file), file=stderr)
 | 
						|
    return 0
 | 
						|
 | 
						|
if __name__ == '__main__':
 | 
						|
    try:
 | 
						|
        exit(main())
 | 
						|
    except OSError as error:
 | 
						|
        error_parts = ["Error"]
 | 
						|
        if error.filename:
 | 
						|
            error_parts.append("Could not open {}".format(error.filename))
 | 
						|
        error_parts.append(error.strerror)
 | 
						|
        print(": ".join(error_parts), file=sys.stderr)
 | 
						|
        exit(3)
 |