client: iter_transactions() supports date ranges longer than a month.

This works by extracting the date-crawling code from get_transaction() to a
separate method. Now iter_transactions() will similarly make API requests
with different month-long date ranges until covers the entire date range the
user requested.
This commit is contained in:
Brett Smith 2020-11-19 15:38:13 -05:00
parent e7342c582e
commit efaeb53e91
3 changed files with 101 additions and 19 deletions

View file

@ -19,6 +19,7 @@ import datetime
import enum import enum
import logging import logging
import math import math
import operator
import urllib.parse as urlparse import urllib.parse as urlparse
import requests import requests
@ -285,6 +286,47 @@ class PayPalAPIClient:
] ]
self.logger.error("".join(parts)) self.logger.error("".join(parts))
def _iter_date_params(
self,
start_date: datetime.datetime,
end_date: datetime.datetime,
params: Optional[Params]=None,
) -> Iterator[Params]:
"""Generate parameters with date windows to cover a wider span
The PayPal transaction search API only allows the ``start_date`` and
``end_date`` to be a month apart. Given a user's desired date range and
other API parameters, this method generates parameters with different
date pairs to cover the entire date range.
Normally the method keeps incrementing ``start_date`` until the user's
desired ``end_date`` is reached. If the ``start_date`` if later than the
``end_date``, instead it works backwards: it uses the ``start_date``
argument as the first ``end_date`` parameter, and keeps decrementing it
until the ``start_date`` parameter reaches the other end of the range.
"""
if start_date > end_date:
key1 = 'end_date'
key2 = 'start_date'
days_sign = operator.neg
pred = operator.gt
limit_func = max
else:
key1 = 'start_date'
key2 = 'end_date'
days_sign = operator.pos
pred = operator.lt
limit_func = min
retval = collections.ChainMap(params or {}).new_child()
retval[key1] = start_date.isoformat(timespec='seconds')
next_date = start_date
date_diff = datetime.timedelta(days=days_sign(30))
while pred(next_date, end_date):
next_date = limit_func(next_date + date_diff, end_date)
retval[key2] = next_date.isoformat(timespec='seconds')
yield retval
retval[key1] = retval[key2]
def get_subscription( def get_subscription(
self, self,
subscription_id: str, subscription_id: str,
@ -323,20 +365,14 @@ class PayPalAPIClient:
if start_date is None: if start_date is None:
# The API only goes back three years # The API only goes back three years
start_date = now - datetime.timedelta(days=365 * 3) start_date = now - datetime.timedelta(days=365 * 3)
date_diff = datetime.timedelta(days=30)
response: APIResponse = {'transaction_details': None} response: APIResponse = {'transaction_details': None}
while end_date > start_date and not response['transaction_details']: for params in self._iter_date_params(end_date, start_date, {
search_start = max(end_date - date_diff, start_date)
response = self._get_json('/v1/reporting/transactions', {
'transaction_id': transaction_id, 'transaction_id': transaction_id,
'fields': fields.param_value(), 'fields': fields.param_value(),
'start_date': search_start.isoformat(timespec='seconds'), }):
'end_date': end_date.isoformat(timespec='seconds'), response = self._get_json('/v1/reporting/transactions', params)
})
end_date = search_start
if response['transaction_details']: if response['transaction_details']:
return Transaction(response['transaction_details'][0]) return Transaction(response['transaction_details'][0])
else:
raise ValueError(f"transaction {transaction_id!r} not found") raise ValueError(f"transaction {transaction_id!r} not found")
def iter_transactions( def iter_transactions(
@ -351,10 +387,9 @@ class PayPalAPIClient:
``fields`` is a TransactionsFields object that flags the information to ``fields`` is a TransactionsFields object that flags the information to
include in returned Transactions. include in returned Transactions.
""" """
for page in self._iter_pages('/v1/reporting/transactions', { for params in self._iter_date_params(start_date, end_date, {
'fields': fields.param_value(), 'fields': fields.param_value(),
'start_date': start_date.isoformat(timespec='seconds'),
'end_date': end_date.isoformat(timespec='seconds'),
}): }):
for page in self._iter_pages('/v1/reporting/transactions', params):
for txn_source in page['transaction_details']: for txn_source in page['transaction_details']:
yield Transaction(txn_source) yield Transaction(txn_source)

View file

@ -25,7 +25,7 @@ with README_PATH.open() as readme_file:
setup( setup(
name='paypal_rest', name='paypal_rest',
version='1.0.0', version='1.0.1',
author='Software Freedom Conservancy', author='Software Freedom Conservancy',
author_email='info@sfconservancy.org', author_email='info@sfconservancy.org',
license='GNU AGPLv3+', license='GNU AGPLv3+',

View file

@ -26,7 +26,7 @@ from paypal_rest import client as client_mod
from paypal_rest import transaction as txn_mod from paypal_rest import transaction as txn_mod
START_DATE = datetime.datetime(2020, 10, 1, 12, tzinfo=datetime.timezone.utc) START_DATE = datetime.datetime(2020, 10, 1, 12, tzinfo=datetime.timezone.utc)
END_DATE = START_DATE.replace(month=START_DATE.month + 1) END_DATE = START_DATE.replace(day=25)
def test_transaction_type(): def test_transaction_type():
txn_id = 'TYPETEST123456789' txn_id = 'TYPETEST123456789'
@ -87,6 +87,53 @@ def test_transaction_fields_formatting(fields):
for name in session._requests[0].params.get('fields', '').split(',') for name in session._requests[0].params.get('fields', '').split(',')
} }
@pytest.mark.parametrize('days_diff', [10, 45, 89])
def test_iter_transactions_date_window(days_diff):
start_date = START_DATE
end_date = start_date + datetime.timedelta(days=days_diff)
session = MockSession(
MockResponse({'page': 1, 'total_pages': 1, 'transaction_details': []}),
infinite=True,
)
paypal = client_mod.PayPalAPIClient(session)
assert not any(paypal.iter_transactions(start_date, end_date))
req_count = len(session._requests)
assert req_count == ((days_diff // 30) + 1)
start_str = start_date.isoformat(timespec='seconds')
end_str = end_date.isoformat(timespec='seconds')
prev_end = start_str
for number, request in enumerate(session._requests, 1):
assert request.params['start_date'] == prev_end
if number == req_count:
assert request.params['end_date'] == end_str
else:
assert prev_end < request.params['end_date'] < end_str
prev_end = request.params['end_date']
@pytest.mark.parametrize('days_diff', [10, 45, 89])
def test_get_transactions_date_window(days_diff):
end_date = END_DATE
start_date = end_date - datetime.timedelta(days=days_diff)
session = MockSession(
MockResponse({'page': 1, 'total_pages': 1, 'transaction_details': []}),
infinite=True,
)
paypal = client_mod.PayPalAPIClient(session)
with pytest.raises(ValueError):
paypal.get_transaction('DATEWINDOW1234567', end_date, start_date)
req_count = len(session._requests)
assert req_count == ((days_diff // 30) + 1)
start_str = start_date.isoformat(timespec='seconds')
end_str = end_date.isoformat(timespec='seconds')
prev_start = end_str
for number, request in enumerate(session._requests, 1):
assert request.params['end_date'] == prev_start
if number == req_count:
assert request.params['start_date'] == start_str
else:
assert prev_start > request.params['start_date'] > start_str
prev_start = request.params['start_date']
@pytest.mark.parametrize('name', [ @pytest.mark.parametrize('name', [
'BAD_REQUEST', 'BAD_REQUEST',
'UNAUTHORIZED', 'UNAUTHORIZED',