4d3b8b0673
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).
189 lines
6.4 KiB
Python
189 lines
6.4 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import datetime
|
|
import operator
|
|
import time
|
|
|
|
from django.db import models
|
|
|
|
class Date(datetime.date):
|
|
MONTH_MAXDAY = {
|
|
1: 31,
|
|
2: 28,
|
|
3: 31,
|
|
4: 30,
|
|
5: 31,
|
|
6: 30,
|
|
7: 31,
|
|
8: 31,
|
|
9: 30,
|
|
10: 31,
|
|
11: 30,
|
|
12: 31,
|
|
}
|
|
|
|
@classmethod
|
|
def from_pydate(cls, date):
|
|
return cls(date.year, date.month, date.day)
|
|
|
|
@classmethod
|
|
def strptime(cls, s, fmt):
|
|
time_tuple = time.strptime(s, fmt)
|
|
return cls(*time_tuple[:3])
|
|
|
|
def adjust_month(self, delta, day=None):
|
|
if day is None:
|
|
day = self.day
|
|
year_delta, month_delta = divmod(abs(delta), 12)
|
|
op_func = operator.sub if delta < 0 else operator.add
|
|
month = op_func(self.month, month_delta)
|
|
if (month < 1) or (month > 12):
|
|
year_delta += 1
|
|
month = op_func(month, -12)
|
|
year = op_func(self.year, year_delta)
|
|
day = min(day, self.MONTH_MAXDAY[month])
|
|
return type(self)(year, month, day)
|
|
|
|
def next_month(self, day=None):
|
|
return self.adjust_month(1, day)
|
|
|
|
def next_year(self):
|
|
return self.adjust_month(12)
|
|
|
|
def round_month_up(self):
|
|
return self.adjust_month(1, day=1)
|
|
|
|
|
|
class DateField(models.DateField):
|
|
def from_db_value(self, value, expression, connection, context):
|
|
if value is not None:
|
|
value = Date.from_pydate(value)
|
|
return value
|
|
|
|
|
|
class Payment(models.Model):
|
|
date = DateField()
|
|
entity = models.TextField()
|
|
payee = models.TextField()
|
|
program = models.TextField()
|
|
amount = models.TextField()
|
|
|
|
|
|
class Supporter:
|
|
STATUS_NEW = 'New'
|
|
STATUS_ACTIVE = 'Active'
|
|
STATUS_LAPSED = 'Lapsed'
|
|
STATUS_LOST = 'Lost'
|
|
|
|
LOST_THRESHOLD = datetime.timedelta(days=365)
|
|
LAPSED_THRESHOLD = datetime.timedelta()
|
|
|
|
def __init__(self, entity):
|
|
self.entity = entity
|
|
|
|
@classmethod
|
|
def iter_entities(cls, supporter_types=['Annual', 'Monthly']):
|
|
qset = Payment.objects.only('entity')
|
|
if supporter_types is None:
|
|
pass
|
|
elif not supporter_types:
|
|
qset = qset.none()
|
|
else:
|
|
condition = models.Q()
|
|
for suffix in supporter_types:
|
|
condition |= models.Q(program__endswith=':' + suffix)
|
|
qset = qset.filter(condition)
|
|
seen = set()
|
|
for payment in qset:
|
|
if payment.entity not in seen:
|
|
seen.add(payment.entity)
|
|
yield payment.entity
|
|
|
|
def payments(self, as_of_date=None):
|
|
pset = Payment.objects.order_by('date').filter(entity=self.entity)
|
|
if as_of_date is not None:
|
|
pset = pset.filter(date__lte=as_of_date)
|
|
return pset
|
|
|
|
def _expose(internal_method):
|
|
def expose_wrapper(self, as_of_date=None, *args, **kwargs):
|
|
return internal_method(self, self.payments(as_of_date), *args, **kwargs)
|
|
return expose_wrapper
|
|
|
|
def _supporter_type(self, payments):
|
|
try:
|
|
program = payments.filter(program__isnull=False).reverse()[0].program
|
|
except IndexError:
|
|
return None
|
|
else:
|
|
return program.rsplit(':', 1)[-1]
|
|
supporter_type = _expose(_supporter_type)
|
|
|
|
def _calculate_lapse_date(self, last_payment_date, supporter_type):
|
|
if supporter_type == 'Monthly':
|
|
lapse_date = last_payment_date.next_month()
|
|
else:
|
|
lapse_date = last_payment_date.next_year()
|
|
return lapse_date.round_month_up()
|
|
|
|
def _lapse_date(self, payments):
|
|
return self._calculate_lapse_date(payments.last().date,
|
|
self._supporter_type(payments))
|
|
lapse_date = _expose(_lapse_date)
|
|
|
|
def _second_last_lapse_date(self, payments):
|
|
# TODO: find a way without listification - needed due to indexing
|
|
return self._calculate_lapse_date(list(payments)[-2].date,
|
|
self._supporter_type(payments))
|
|
|
|
def status(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
|
|
lapse_date = self._lapse_date(payments)
|
|
days_past_due = as_of_date - lapse_date
|
|
if days_past_due >= self.LOST_THRESHOLD:
|
|
return self.STATUS_LOST
|
|
elif days_past_due >= self.LAPSED_THRESHOLD:
|
|
return self.STATUS_LAPSED
|
|
elif as_of_date.adjust_month(-1, 1) < payments.first().date <= as_of_date:
|
|
return self.STATUS_NEW
|
|
else:
|
|
return self.STATUS_ACTIVE
|
|
|
|
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 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 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)
|
|
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 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); 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 0
|