python: Add Supporter pseudo-model.
This commit is contained in:
parent
b32e72b1e8
commit
bc21b83951
1 changed files with 131 additions and 1 deletions
|
@ -1,10 +1,140 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import operator
|
||||||
|
|
||||||
from django.db import models
|
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)
|
||||||
|
|
||||||
|
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, metaclass=models.SubfieldBase):
|
||||||
|
def to_python(self, value):
|
||||||
|
retval = super().to_python(value)
|
||||||
|
if retval is not None:
|
||||||
|
retval = Date.from_pydate(retval)
|
||||||
|
return retval
|
||||||
|
|
||||||
|
|
||||||
class Payment(models.Model):
|
class Payment(models.Model):
|
||||||
date = models.DateField()
|
date = DateField()
|
||||||
entity = models.TextField()
|
entity = models.TextField()
|
||||||
payee = models.TextField()
|
payee = models.TextField()
|
||||||
program = models.TextField()
|
program = models.TextField()
|
||||||
amount = 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):
|
||||||
|
return payments.last().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 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
|
||||||
|
|
Loading…
Reference in a new issue