models: flexible returning supporter mth count API

Rename months_expired() to months_expired_at_return() and have the
method return an integer representing the month count rather than an
enum-like so that the API user has more flexibility with respect to
what they then do with the result.  Thanks to Brett for the review
that suggested this, and for much of the clever math in this change.

The results produced by returning_report.py (which is updated here to
use the new API) are almost identical to the results produced before
the change.  In rare cases (about 2-5% of the time) a supporter's
lapsed month count will fall into an adjacent bucket instead of the
one it fell into before, usually because the previous result was wrong
to begin with (due to the ugly days-per-3-months table that we used
previously, which this change thankfully eliminates).
This commit is contained in:
Denver Gingerich 2018-01-25 15:53:19 -05:00 committed by Brett Smith
parent 6ab6441e14
commit 4d3b8b0673
2 changed files with 24 additions and 39 deletions

View file

@ -40,17 +40,19 @@ def report_month(month):
for name in Supporter.iter_entities(['Annual'])) for name in Supporter.iter_entities(['Annual']))
monthlies = collections.Counter(Supporter(name).status(month) monthlies = collections.Counter(Supporter(name).status(month)
for name in Supporter.iter_entities(['Monthly'])) for name in Supporter.iter_entities(['Monthly']))
eannuals = collections.Counter(Supporter(name).months_expired(month) eannuals = collections.Counter(
for name in Supporter.iter_entities(['Annual'])) min((Supporter(name).months_expired_at_return(month) + 2) // 3, 5)
emonthlies = collections.Counter(Supporter(name).months_expired(month) for name in Supporter.iter_entities(['Annual']))
for name in Supporter.iter_entities(['Monthly'])) emonthlies = collections.Counter(
min((Supporter(name).months_expired_at_return(month) + 2) // 3, 5)
for name in Supporter.iter_entities(['Monthly']))
return ((month.strftime(MONTH_FMT),) return ((month.strftime(MONTH_FMT),)
+ ((annuals + monthlies)[Supporter.STATUS_NEW],) + ((annuals + monthlies)[Supporter.STATUS_NEW],)
+ ((eannuals + emonthlies)[Supporter.RETURNING_0MO],) + ((eannuals + emonthlies)[1],)
+ ((eannuals + emonthlies)[Supporter.RETURNING_3MO],) + ((eannuals + emonthlies)[2],)
+ ((eannuals + emonthlies)[Supporter.RETURNING_6MO],) + ((eannuals + emonthlies)[3],)
+ ((eannuals + emonthlies)[Supporter.RETURNING_9MO],) + ((eannuals + emonthlies)[4],)
+ ((eannuals + emonthlies)[Supporter.RETURNING_12MO],)) + ((eannuals + emonthlies)[5],))
def main(arglist): def main(arglist):
args = parse_arguments(arglist) args = parse_arguments(arglist)

View file

@ -75,13 +75,6 @@ class Supporter:
STATUS_LAPSED = 'Lapsed' STATUS_LAPSED = 'Lapsed'
STATUS_LOST = 'Lost' STATUS_LOST = 'Lost'
RETURNING_0MO = 'ReturningAfter0-3monthLapse'
RETURNING_3MO = 'ReturningAfter3-6monthLapse'
RETURNING_6MO = 'ReturningAfter6-9monthLapse'
RETURNING_9MO = 'ReturningAfter9-12monthLapse'
RETURNING_12MO = 'ReturningAfter>12monthLapse'
RETURNING_NOT = 'NotReturning'
LOST_THRESHOLD = datetime.timedelta(days=365) LOST_THRESHOLD = datetime.timedelta(days=365)
LAPSED_THRESHOLD = datetime.timedelta() LAPSED_THRESHOLD = datetime.timedelta()
@ -161,46 +154,36 @@ class Supporter:
else: else:
return self.STATUS_ACTIVE return self.STATUS_ACTIVE
def months_expired(self, as_of_date=None): def months_expired_at_return(self, as_of_date=None):
if as_of_date is None: if as_of_date is None:
as_of_date = Date.today() as_of_date = Date.today()
payments = self.payments(as_of_date) payments = self.payments(as_of_date)
payments_count = payments.count() payments_count = payments.count()
if payments_count == 0: if payments_count == 0:
return None return 0
lapse_date = self._lapse_date(payments) lapse_date = self._lapse_date(payments)
days_past_due = as_of_date - lapse_date days_past_due = as_of_date - lapse_date
if as_of_date.adjust_month(-1, 1) < payments.first().date <= as_of_date: if as_of_date.adjust_month(-1, 1) < payments.first().date <= as_of_date:
return self.RETURNING_NOT # started paying this month so not "returning" return 0 # started paying this month so not "returning"
elif as_of_date.adjust_month(-1, 1) < payments.last().date <= as_of_date: elif as_of_date.adjust_month(-1, 1) < payments.last().date <= as_of_date:
# (there are at least 2 payments because first().date != last().date) # (there are at least 2 payments because first().date != last().date)
if payments.last().date <= self._second_last_lapse_date(payments): past_lapse_date = self._second_last_lapse_date(payments)
if payments.last().date <= past_lapse_date:
# the most recent payment was this month, and it was before or on # the most recent payment was this month, and it was before or on
# the lapse date for the last payment (i.e. it was "on-time") so # the lapse date for the last payment (i.e. it was "on-time") so
# this is a normal active subscriber, not a "re-"subscriber # this is a normal active subscriber, not a "re-"subscriber
return self.RETURNING_NOT return 0
else: else:
# the most recent payment was this month, and it was after the lapse # the most recent payment was this month, and it was after the lapse
# date for the last payment (so this is a "re-"subscriber) # date for the last payment (so this is a "re-"subscriber); since we
# know the supporter paid after the lapse date, add one to the
# let's see how far past due the payment was, and return accordingly # result because paying in the same month still means they lapsed -
last_past_due = (payments.last().date # this effectively means the result is the ceiling of months lapsed
- self._second_last_lapse_date(payments)) return ((12 * payments.last().date.year + payments.last().date.month)
- (12 * past_lapse_date.year + past_lapse_date.month)) + 1
# TODO: use real month boundaries (approximating ok, but not great)
if last_past_due < datetime.timedelta(days=91):
return self.RETURNING_0MO
elif last_past_due < datetime.timedelta(days=183):
return self.RETURNING_3MO
elif last_past_due < datetime.timedelta(days=274):
return self.RETURNING_6MO
elif last_past_due < datetime.timedelta(days=365):
return self.RETURNING_9MO
else:
return self.RETURNING_12MO
else: else:
# supporter lapsed/lost or an annual supporter who paid 2-12 months ago # supporter lapsed/lost or an annual supporter who paid 2-12 months ago
return self.RETURNING_NOT return 0