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:
parent
6ab6441e14
commit
4d3b8b0673
2 changed files with 24 additions and 39 deletions
|
@ -40,17 +40,19 @@ def report_month(month):
|
|||
for name in Supporter.iter_entities(['Annual']))
|
||||
monthlies = collections.Counter(Supporter(name).status(month)
|
||||
for name in Supporter.iter_entities(['Monthly']))
|
||||
eannuals = collections.Counter(Supporter(name).months_expired(month)
|
||||
eannuals = collections.Counter(
|
||||
min((Supporter(name).months_expired_at_return(month) + 2) // 3, 5)
|
||||
for name in Supporter.iter_entities(['Annual']))
|
||||
emonthlies = collections.Counter(Supporter(name).months_expired(month)
|
||||
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),)
|
||||
+ ((annuals + monthlies)[Supporter.STATUS_NEW],)
|
||||
+ ((eannuals + emonthlies)[Supporter.RETURNING_0MO],)
|
||||
+ ((eannuals + emonthlies)[Supporter.RETURNING_3MO],)
|
||||
+ ((eannuals + emonthlies)[Supporter.RETURNING_6MO],)
|
||||
+ ((eannuals + emonthlies)[Supporter.RETURNING_9MO],)
|
||||
+ ((eannuals + emonthlies)[Supporter.RETURNING_12MO],))
|
||||
+ ((eannuals + emonthlies)[1],)
|
||||
+ ((eannuals + emonthlies)[2],)
|
||||
+ ((eannuals + emonthlies)[3],)
|
||||
+ ((eannuals + emonthlies)[4],)
|
||||
+ ((eannuals + emonthlies)[5],))
|
||||
|
||||
def main(arglist):
|
||||
args = parse_arguments(arglist)
|
||||
|
|
|
@ -75,13 +75,6 @@ class Supporter:
|
|||
STATUS_LAPSED = 'Lapsed'
|
||||
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)
|
||||
LAPSED_THRESHOLD = datetime.timedelta()
|
||||
|
||||
|
@ -161,46 +154,36 @@ class Supporter:
|
|||
else:
|
||||
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:
|
||||
as_of_date = Date.today()
|
||||
payments = self.payments(as_of_date)
|
||||
payments_count = payments.count()
|
||||
if payments_count == 0:
|
||||
return None
|
||||
return 0
|
||||
lapse_date = self._lapse_date(payments)
|
||||
days_past_due = as_of_date - lapse_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:
|
||||
# (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 lapse date for the last payment (i.e. it was "on-time") so
|
||||
# this is a normal active subscriber, not a "re-"subscriber
|
||||
return self.RETURNING_NOT
|
||||
return 0
|
||||
else:
|
||||
# the most recent payment was this month, and it was after the lapse
|
||||
# date for the last payment (so this is a "re-"subscriber)
|
||||
|
||||
# let's see how far past due the payment was, and return accordingly
|
||||
last_past_due = (payments.last().date
|
||||
- self._second_last_lapse_date(payments))
|
||||
|
||||
# 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
|
||||
# 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
|
||||
# result because paying in the same month still means they lapsed -
|
||||
# this effectively means the result is the ceiling of months lapsed
|
||||
return ((12 * payments.last().date.year + payments.last().date.month)
|
||||
- (12 * past_lapse_date.year + past_lapse_date.month)) + 1
|
||||
else:
|
||||
# supporter lapsed/lost or an annual supporter who paid 2-12 months ago
|
||||
return self.RETURNING_NOT
|
||||
return 0
|
||||
|
|
Loading…
Reference in a new issue