796 lines
24 KiB
Text
796 lines
24 KiB
Text
|
#!/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):
|
||
|
args = parse_arguments(arglist)
|
||
|
|
||
|
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
|
||
|
|
||
|
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)
|