diff --git a/cloud/endagaweb/celery.py b/cloud/endagaweb/celery.py index 8125f76f..bd8bd5f9 100644 --- a/cloud/endagaweb/celery.py +++ b/cloud/endagaweb/celery.py @@ -42,5 +42,13 @@ 'task': 'endagaweb.tasks.usageevents_to_sftp', # Run this at 15:00 UTC (10:00 PDT, 02:00 Papua time) 'schedule': crontab(minute=0, hour=17), + },'validity-expiry-sms': { + 'task': 'endagaweb.tasks.validity_expiry_sms', + # Run this at 14:00 UTC (09:00 PDT, 01:00 Papua time). + 'schedule': crontab(minute=0, hour=15), + },'subscriber-validity-state': { + 'task': 'endagaweb.tasks.subscriber_validity_state', + # Run this at 14:00 UTC (09:00 PDT, 01:00 Papua time). + 'schedule': crontab(minute=0, hour=07), } }) diff --git a/cloud/endagaweb/forms/dashboard_forms.py b/cloud/endagaweb/forms/dashboard_forms.py index 2f9a6ddc..ae8d2ada 100644 --- a/cloud/endagaweb/forms/dashboard_forms.py +++ b/cloud/endagaweb/forms/dashboard_forms.py @@ -208,8 +208,20 @@ class SubVacuumForm(forms.Form): label='Automatically delete inactive subscribers', help_text=inactive_help_text, choices=enabled_choices, widget=forms.RadioSelect()) - inactive_days = forms.CharField( - required=False, label='Outbound inactivity threshold (days)') + inactive_days = forms.IntegerField( + required=False, label='Outbound inactivity threshold (days)', + min_value=0, max_value=10000, widget= + forms.TextInput(attrs={'class': 'form-control', 'pattern':'[0-9]+', + 'oninvalid':"setCustomValidity('Enter days only!')", + 'onchange':"try{" + "setCustomValidity('')}catch(e){}"})) + grace_days = forms.IntegerField( + required=False, label='Grace Period (days)', min_value=0, + max_value=1000, widget= + forms.TextInput(attrs={'class': 'form-control', 'pattern':'[0-9]+', + 'oninvalid':"setCustomValidity('Enter days only!')", + 'onchange':"try{" + "setCustomValidity('')}catch(e){}"})) def __init__(self, *args, **kwargs): super(SubVacuumForm, self).__init__(*args, **kwargs) @@ -221,11 +233,14 @@ def __init__(self, *args, **kwargs): # not this feature is active. if args[0]['sub_vacuum_enabled']: days_field = Field('inactive_days') + grace_field = Field('grace_days') else: days_field = Field('inactive_days', disabled=True) + grace_field = Field('grace_days', disabled=True) self.helper.layout = Layout( 'sub_vacuum_enabled', days_field, + grace_field, Submit('submit', 'Save', css_class='pull-right'), ) diff --git a/cloud/endagaweb/models.py b/cloud/endagaweb/models.py index d2429f9d..72ed6a50 100644 --- a/cloud/endagaweb/models.py +++ b/cloud/endagaweb/models.py @@ -514,7 +514,7 @@ class Subscriber(models.Model): imsi = models.CharField(max_length=50, unique=True) name = models.TextField() crdt_balance = models.TextField(default=crdt.PNCounter("default").serialize()) - state = models.CharField(max_length=10) + state = models.CharField(max_length=15) # Time of the last received UsageEvent that's not in NON_ACTIVITIES. last_active = models.DateTimeField(null=True, blank=True) # Time of the last received UsageEvent that is in OUTBOUND_ACTIVITIES. We @@ -940,7 +940,8 @@ class Network(models.Model): # Whether or not to automatically delete inactive subscribers, and # associated parameters. sub_vacuum_enabled = models.BooleanField(default=False) - sub_vacuum_inactive_days = models.IntegerField(default=180) + sub_vacuum_inactive_days = models.PositiveIntegerField(default=180) + sub_vacuum_grace_days = models.PositiveIntegerField(default=30) # csv of endpoints to notify for downtime notify_emails = models.TextField(blank=True, default='') diff --git a/cloud/endagaweb/tasks.py b/cloud/endagaweb/tasks.py index 405ca698..d7aa01f3 100644 --- a/cloud/endagaweb/tasks.py +++ b/cloud/endagaweb/tasks.py @@ -246,11 +246,11 @@ def vacuum_inactive_subscribers(self): # Do nothing if subscriber vacuuming is disabled for the network. if not network.sub_vacuum_enabled: continue - inactives = network.get_outbound_inactive_subscribers( - network.sub_vacuum_inactive_days) + inactives = Subscriber.objects.filter( + state='recycle', network_id=network.id, + prevent_automatic_deactivation=False + ) for subscriber in inactives: - if subscriber.prevent_automatic_deactivation: - continue print 'vacuuming %s from network %s' % (subscriber.imsi, network) subscriber.deactivate() # Sleep a bit in between each deactivation so we don't flood the @@ -439,3 +439,124 @@ def req_bts_log(self, obj, retry_delay=60*10, max_retries=432): raise finally: obj.save() + + +@app.task(bind=True) +def subscriber_validity_state(self): + """ Updates the subscribers state to inactive/active/""" + + today = django.utils.timezone.now() + subscribers = Subscriber.objects.filter( + number__valid_through__lte=today) + today = today.date() + for subscriber in subscribers: + try: + number = subscriber.number_set.all()[0] + if number.valid_through is None: + continue + except IndexError: + continue + subscriber_validity = number.valid_through.date() + first_expire = subscriber_validity + datetime.timedelta( + days=subscriber.network.sub_vacuum_inactive_days) + recycle = first_expire + datetime.timedelta( + days=subscriber.network.sub_vacuum_grace_days) + current_state = str(subscriber.state) + + if subscriber_validity < today: + if today <= first_expire: + # Do nothing if it's already first expired + if current_state != 'first_expired': + subscriber.state = 'first_expired' + subscriber.save() + print "Updating subscriber(%s) state to 'First Expired'" % ( + subscriber.imsi,) + elif today > recycle: + # Let deactivation of subscriber be handled by + # vacuum_inactive_subscribers + # Do nothing if it's already recycle + if current_state != 'recycle': + subscriber.state = 'recycle' + subscriber.save() + print "Updating subscriber(%s) state to 'Recycle'" % ( + subscriber.imsi,) + else: + if current_state != 'expired': + subscriber.state = 'expired' + subscriber.save() + print "Updating subscriber(%s) state to 'Expired'" % ( + subscriber.imsi,) + + +@app.task(bind=True) +def validity_expiry_sms(self, days=7): + """Sends SMS to the number whose validity is: + about to get expire, + if expired (i.e 1st expired), or + if the number is in grace period and is about to recycle. + + Args: + days: Days prior (state change) which the SMS is sent to Subscriber. + Runs as everyday task managed by celerybeat. + """ + today = django.utils.timezone.datetime.now().date() + for subscriber in Subscriber.objects.iterator(): + # Do nothing if subscriber vacuuming is disabled for the network. + if not subscriber.network.sub_vacuum_enabled: + continue + try: + number = subscriber.number_set.all()[0] + subscriber_validity = number.valid_through + # In case where number has no validity + if subscriber_validity is None: + print '%s has no validity' % (subscriber.imsi,) + continue + except IndexError: + print 'No number attached to subscriber %s' % (subscriber.imsi,) + continue + + subscriber_validity = subscriber_validity.date() + inactive_period = subscriber.network.sub_vacuum_inactive_days + grace_period = subscriber.network.sub_vacuum_grace_days + + prior_first_expire = subscriber_validity + datetime.timedelta( + days=inactive_period) - datetime.timedelta(days=days) + + prior_recycle = prior_first_expire + datetime.timedelta( + days=grace_period) + + # Prior to expiry state (one on last day and before defined days) + if subscriber_validity > today and ( + (subscriber_validity - datetime.timedelta( + days=days) + ) == today or today == ( + subscriber_validity - datetime.timedelta( + days=1)) or today == subscriber_validity): + body = 'Your validity is about to get expired on %s , Please ' \ + 'recharge to continue the service. Please ignore if ' \ + 'already done! ' % (subscriber_validity,) + sms_notification(body=body, to=number) + # Prior 1st_expired state + elif subscriber_validity < today: + if prior_first_expire == today or today == ( + prior_first_expire + datetime.timedelta( + days=days - 1)): + body = 'Your validity has expired on %s, Please recharge ' \ + 'immediately to activate your services again! ' % ( + subscriber_validity,) + sms_notification(body=body, to=number) + # Prior to recycle state + elif prior_recycle == today or today == ( + prior_recycle + datetime.timedelta(days=days - 1)): + body = 'Warning: Your validity has expired on %s , Please ' \ + 'recharge immediately to avoid deactivation of your ' \ + 'connection! ' % (subscriber_validity,) + sms_notification(body=body, to=number) + # SMS on same day of expiry + elif subscriber_validity == today: + body = 'Your validity expiring today %s, Please recharge ' \ + 'immediately to continue your services again!, ' \ + 'Ignore if already done! ' % (subscriber_validity,) + sms_notification(body=body, to=number) + else: + return # Do nothing diff --git a/cloud/endagaweb/templates/dashboard/network_detail/inactive-subscribers.html b/cloud/endagaweb/templates/dashboard/network_detail/inactive-subscribers.html index eb786070..89f9f3a1 100644 --- a/cloud/endagaweb/templates/dashboard/network_detail/inactive-subscribers.html +++ b/cloud/endagaweb/templates/dashboard/network_detail/inactive-subscribers.html @@ -144,8 +144,10 @@ var sub_vacuum_enabled = 'True' == $('form input[name=sub_vacuum_enabled]:checked').val(); if (sub_vacuum_enabled) { $('#id_inactive_days').prop('disabled', false); + $('#id_grace_days').prop('disabled', false); } else { $('#id_inactive_days').prop('disabled', true); + $('#id_grace_days').prop('disabled', true); } }); }); diff --git a/cloud/endagaweb/tests/test_subscribers.py b/cloud/endagaweb/tests/test_subscribers.py index 68a95978..2082276a 100644 --- a/cloud/endagaweb/tests/test_subscribers.py +++ b/cloud/endagaweb/tests/test_subscribers.py @@ -13,16 +13,19 @@ from __future__ import print_function from __future__ import unicode_literals +import datetime as datetime2 +import json +import uuid from datetime import datetime from random import randrange -import uuid import pytz - +from django import test from django.test import TestCase from ccm.common import crdt from endagaweb import models +from endagaweb import tasks class TestBase(TestCase): @@ -36,9 +39,9 @@ def setUpTestData(cls): @classmethod def add_sub(cls, imsi, ev_kind=None, ev_reason=None, ev_date=None, - balance=0): + balance=0, state='active'): sub = models.Subscriber.objects.create( - imsi=imsi, network=cls.network, balance=balance) + imsi=imsi, network=cls.network, balance=balance, state=state) if ev_kind: if ev_date is None: ev_date = datetime.now(pytz.utc) @@ -147,3 +150,54 @@ def test_sub_with_activity(self): outbound_inactives = self.network.get_outbound_inactive_subscribers( days) self.assertFalse(sub in outbound_inactives) + + +class SubscriberValidityTests(TestBase): + """ + We can change subscriber state depending on its validity and can deactivate + after completion of threshold + """ + + def setup_the_env(self, days=7): + imsi = self.gen_imsi() + self.subscriber = self.add_sub(imsi, balance=100, state='active') + # Set expired validity for the number + validity = datetime.now(pytz.utc) - datetime2.timedelta(days=days) + self.bts = models.BTS(uuid="133222", nickname="test-bts-name!", + inbound_url="http://localhost/133222/test", + network=self.network) + self.bts.save() + self.number = models.Number( + number='5559234', state="inuse", network=self.bts.network, + kind="number.nexmo.monthly", subscriber=self.subscriber, + valid_through=validity) + net = models.Network.objects.get(id=self.bts.network.id) + net.sub_vacuum_enabled = True + net.sub_vacuum_inactive_days = 180 + net.sub_vacuum_grace_days = 30 + self.number.save() + net.save() + + def test_subscriber_inactive(self): + # Set subscriber's validity 7 days earlier then current date + self.setup_the_env(days=7) + tasks.subscriber_validity_state() + subscriber = models.Subscriber.objects.get(id=self.subscriber.id) + self.assertEqual(subscriber.state, 'inactive') + + def test_subscriber_expired(self): + # Set subscriber's validity more than threshold days + days = self.network.sub_vacuum_inactive_days + self.setup_the_env(days=days + 1) + tasks.subscriber_validity_state() + subscriber = models.Subscriber.objects.get(id=self.subscriber.id) + self.assertEqual(subscriber.state, 'first_expire') + + def test_subscriber_recycle(self): + # Set subscriber's validity days more than grace period and + # threshold days + days = self.network.sub_vacuum_inactive_days + self.network.sub_vacuum_grace_days + self.setup_the_env(days=days + 1) + tasks.subscriber_validity_state() + subscriber = models.Subscriber.objects.get(id=self.subscriber.id) + self.assertEqual(subscriber.state, 'recycle') \ No newline at end of file diff --git a/cloud/endagaweb/views/network.py b/cloud/endagaweb/views/network.py index bf0db230..9ba8be8b 100644 --- a/cloud/endagaweb/views/network.py +++ b/cloud/endagaweb/views/network.py @@ -143,6 +143,7 @@ def get(self, request): 'sub_vacuum_form': dashboard_forms.SubVacuumForm({ 'sub_vacuum_enabled': network.sub_vacuum_enabled, 'inactive_days': network.sub_vacuum_inactive_days, + 'grace_days': network.sub_vacuum_grace_days, }), 'protected_subs': protected_subs, 'unprotected_subs': unprotected_subs, @@ -167,17 +168,21 @@ def post(self, request): if 'inactive_days' in request.POST: try: inactive_days = int(request.POST['inactive_days']) + grace_days = int(request.POST['grace_days']) if inactive_days > 10000: inactive_days = 10000 + if grace_days > 1000: + grace_days = 1000 network.sub_vacuum_inactive_days = inactive_days + network.sub_vacuum_grace_days = grace_days network.save() + messages.success( + request, 'Subscriber auto-deletion settings saved.', + extra_tags='alert alert-success') except ValueError: text = 'The "inactive days" parameter must be an integer.' messages.error(request, text, extra_tags="alert alert-danger") - messages.success( - request, 'Subscriber auto-deletion settings saved.', - extra_tags='alert alert-success') return redirect(urlresolvers.reverse('network-inactive-subscribers'))