From e7e252c89eb115f3eed81d444ba9a509410491c0 Mon Sep 17 00:00:00 2001 From: piyush-kr Date: Fri, 13 Oct 2017 20:43:56 +0530 Subject: [PATCH] Base Code for: * Subscriber validity/blocking * Balance check and update --- client/core/apps/sms_credit_transfer.py | 109 +++++++++++-- client/core/checkin.py | 1 + client/core/interconnect.py | 4 +- client/core/subscriber/base.py | 197 +++++++++++++++++++++++- cloud/endagaweb/checkin.py | 28 +++- 5 files changed, 320 insertions(+), 19 deletions(-) diff --git a/client/core/apps/sms_credit_transfer.py b/client/core/apps/sms_credit_transfer.py index b107439a..159349a4 100644 --- a/client/core/apps/sms_credit_transfer.py +++ b/client/core/apps/sms_credit_transfer.py @@ -22,12 +22,12 @@ from core.sms import sms from core.subscriber import subscriber from core.exceptions import SubscriberNotFound - +from core.denomination_store import DenominationStore config_db = config_database.ConfigDB() gt = gettext.translation("endaga", config_db['localedir'], [config_db['locale'], "en_US"]).gettext - +ERROR_TRX = " error_transfer" def _init_pending_transfer_db(): """Create the pending transfers table if it doesn't already exist.""" @@ -45,6 +45,13 @@ def _init_pending_transfer_db(): # Make the DB world-writable. os.chmod(config_db['pending_transfer_db_path'], 0o777) +def get_validity_days(amount): + denomination = DenominationStore() + validity_days = denomination.get_validity_days(amount) + if validity_days is None: + return None + else: + return validity_days[0] def process_transfer(from_imsi, to_imsi, amount): """Process a transfer request. @@ -58,15 +65,77 @@ def process_transfer(from_imsi, to_imsi, amount): boolean indicating success """ from_balance = int(subscriber.get_account_balance(from_imsi)) + # Error when blocked or expired user tries to transfer credit + from_imsi_status = subscriber.status().get_account_status( + from_imsi) + to_imsi_status = subscriber.status().get_account_status( + from_imsi) + if from_imsi_status != 'active': + if from_imsi_status == 'active*': + status = 'is blocked' + else: + status = 'has no validity' + return False, ("Your account %s!" % status) + # rare scenario + if to_imsi_status in ['recycle', 'recycle*']: + return False, ("%s doesn't exists" % to_imsi) # Error when user tries to transfer more credit than they have. if not from_balance or from_balance < amount: - return False, gt("Your account doesn't have sufficient funds for" - " the transfer.") + return False, ("Your account doesn't have sufficient funds for the " + "transfer.") # Error when user tries to transfer to a non-existent user. # Could be 0! Need to check if doesn't exist. if not to_imsi or (subscriber.get_account_balance(to_imsi) == None): - return False, gt("The number you're sending to doesn't exist." - " Try again.") + return False, ("The number you're sending to doesn't exist. Try again." + ) + # Error when user tries to transfer more credit than network max balance + network_max_balance = int(config_db['network_max_balance']) + credit_limit = freeswitch_strings.humanize_credits(network_max_balance) + to_balance = int(subscriber.get_account_balance(to_imsi)) + max_transfer = network_max_balance - to_balance + max_transfer_str = freeswitch_strings.humanize_credits(max_transfer) + from_num = subscriber.get_numbers_from_imsi(from_imsi)[0] + to_num = subscriber.get_numbers_from_imsi(to_imsi)[0] + max_attempts = config_db['network_mput'] + if to_balance > network_max_balance: + attempts = subscriber.status().get_invalid_count(from_imsi) + block_info = " Attempts left %(left)s !" % { + 'left': int(max_attempts) - (int(attempts) + 1) + } + reason = ("Top-up not allowed. Maximum balance " + "limit crossed %(credit)s." + ) % {'credit': credit_limit} + # For cloud + events.create_transfer_event(from_imsi, from_balance, from_balance, + reason + ERROR_TRX , from_num, to_num) + + return False, reason + block_info + ERROR_TRX + elif (amount + to_balance) > network_max_balance: + # Mark this event for blocking + attempts = subscriber.status().get_invalid_count(from_imsi) + block_info = " Attempts left %(left)s !" % { + 'left': int(max_attempts) - (int(attempts) + 1) + } + reason = ("Top-up not allowed. Maximum balance " + "limit crossed %(credit)s. You can transfer upto " + "%(transfer)s." + ) % {'credit': credit_limit, 'transfer': max_transfer_str} + # For cloud + events.create_transfer_event(from_imsi, from_balance, from_balance, + reason + ERROR_TRX , from_num, to_num) + return False, reason + block_info + ERROR_TRX + # check top-up amount in denomination bracket + validity_days = get_validity_days(amount) + if validity_days is None: + attempts = subscriber.status().get_invalid_count(from_imsi) + block_info = " Attempts left %(left)s !" % { + 'left': int(max_attempts) - (int(attempts) + 1) + } + reason = "Top-up not under denomination range." + # For cloud + events.create_transfer_event(from_imsi, from_balance, from_balance, + reason + ERROR_TRX , from_num, to_num) + return False, reason + block_info + ERROR_TRX # Add the pending transfer. code = '' for _ in range(int(config_db['code_length'])): @@ -78,10 +147,11 @@ def process_transfer(from_imsi, to_imsi, amount): db.close() to_num = subscriber.get_numbers_from_imsi(to_imsi)[0] amount_str = freeswitch_strings.humanize_credits(amount) - response = gt("Reply to this message with %(code)s to confirm your" - " transfer of %(amount)s to %(to_num)s. Code expires in ten" - " minutes.") % {'code': code, 'amount': amount_str, - 'to_num': to_num} + response = ("Reply to this message with %(code)s to confirm your transfer" + " of %(amount)s to %(to_num)s. Code expires in ten minutes." + ) % { + 'code': code, 'amount': amount_str, 'to_num': to_num + } return True, response @@ -120,6 +190,8 @@ def process_confirm(from_imsi, code): events.create_transfer_event(to_imsi, to_imsi_old_credit, to_imsi_new_credit, reason, from_number=from_num, to_number=to_num) + top_up_validity = subscriber.status().get_subscriber_validity( + to_imsi, get_validity_days(amount)) subscriber.add_credit(to_imsi, str(int(amount))) # Humanize credit strings amount_str = freeswitch_strings.humanize_credits(amount) @@ -129,9 +201,10 @@ def process_confirm(from_imsi, code): from_imsi_new_credit) # Let the recipient know they got credit. message = gt("You've received %(amount)s credits from %(from_num)s!" - " Your new balance is %(new_balance)s.") % { + " Your new balance is %(new_balance)s.Your top-up " + "validity is %(validity)s days.") % { 'amount': amount_str, 'from_num': from_num, - 'new_balance': to_balance_str} + 'new_balance': to_balance_str, 'validity': top_up_validity} sms.send(str(to_num), str(config_db['app_number']), str(message)) # Remove this particular the transfer as it's no longer pending. db.execute("DELETE FROM pending_transfers WHERE code=?" @@ -168,15 +241,23 @@ def handle_incoming(from_imsi, request): int(config_db['code_length'])) confirm = confirm_command.match(request) _init_pending_transfer_db() + max_attempts = config_db['network_mput'] if transfer: to_number, amount = transfer.groups() amount = freeswitch_strings.parse_credits(amount).amount_raw # Translate everything into IMSIs. try: to_imsi = subscriber.get_imsi_from_number(to_number) - _, resp = process_transfer(from_imsi, to_imsi, amount) + _, resp, = process_transfer(from_imsi, to_imsi, amount) + if not _ and ERROR_TRX in resp: + subscriber.status().set_invalid_count(from_imsi, max_attempts) + resp = resp.replace(ERROR_TRX, '') + else: + subscriber.status().reset_invalid_count(from_imsi) + resp = gt(resp) except SubscriberNotFound: - resp = gt("Invalid phone number: %(number)s" % {'number': to_number}) + resp = gt( + "Invalid phone number: %(number)s" % {'number': to_number}) elif confirm: # The code is the whole request, so no need for groups. code = request.strip() diff --git a/client/core/checkin.py b/client/core/checkin.py index dddfedf0..b3e2e23c 100644 --- a/client/core/checkin.py +++ b/client/core/checkin.py @@ -92,6 +92,7 @@ def process_config(self, config_dict): @delta.DeltaCapable(section_ctx['subscribers'], True) def process_subscribers(self, data_dict): subscriber.process_update(data_dict) + subscriber.status(update=data_dict) def process_events(self, data_dict): """Process information about events. diff --git a/client/core/interconnect.py b/client/core/interconnect.py index 33b1c022..01e4c707 100644 --- a/client/core/interconnect.py +++ b/client/core/interconnect.py @@ -171,7 +171,9 @@ def checkin(self, timeout=11): # Add balance sync data status['subscribers'] = subscriber.get_subscriber_states( imsis=events.EventStore().modified_subs()) - + # Add subscriber status and validity sync data + status['subscriber_status'] = subscriber.status().get_subscriber_status( + imsis=events.EventStore().modified_subs()) # Add delta protocol context (if available) to let server know, # client supports delta optimization & has a prior delta state if delta.DeltaProtocol.CTX_KEY not in status: # just a precaution diff --git a/client/core/subscriber/base.py b/client/core/subscriber/base.py index 562c2543..52cbe2c6 100644 --- a/client/core/subscriber/base.py +++ b/client/core/subscriber/base.py @@ -14,10 +14,11 @@ of patent rights can be found in the PATENTS file in the same directory. """ +import json +from datetime import datetime +from datetime import timedelta - - - +import dateutil.parser as dateparser from ccm.common import crdt, logger from core.db.kvstore import KVStore @@ -485,3 +486,193 @@ def process_update(self, net_subs): logger.error("Balance sync fail! IMSI: %s, %s Error: %s" % (imsi, sub['balance'], e)) + def status(self, update=None): + status = BaseSubscriberStatus() + if update is not None: + status.process_update(update) + return + return status + + +class BaseSubscriberStatus(KVStore): + """ + Sets and Updates Subscriber Status similar to balance updates + Current Status can be (i.e States of subscriber): + Blocked : Subscriber blocked for some reason (no call/sms) for some period. + Active: Subscriber is active + First Expire : Subscriber has no validity (no call/sms) + Expired: Grace period also expired after First Expire (no call/sms) + """ + + def __init__(self, connector=None): + super(BaseSubscriberStatus, self).__init__('subscribers_status', + connector, key_name='imsi', + val_name='status') + + def get_subscriber_status(self, imsis=None): + """ + Return a dictionary containing all the subscriber status. Format is: + + { 'IMSIxxx...' : {'state' : 'active' , 'valid_through': '01-09-2100'} + ... + } + + Args: + imsis: A list of IMSIs to get state for. If None, returns + everything. + Returns: if imsis is None => return ALL subscribers + imsis is an empty list [] => return an empty dictionary + otherwise => return information about the subscribers listed + in imsis + """ + if imsis: # non-empty list, return requested subscribers + subs = self.get_multiple(imsis) + elif imsis is None: # empty list, return all subscribers + subs = list(self.items()) + else: + return {} # empty list - return an empty dict + + res = {} + for (imsi, state) in subs: + res[imsi] = {} + # state = {'state': 'active', 'valid_through': '01-09-2100'} + res[imsi]['state'] = state + return res + + def create_subscriber_status(self, imsi, status): + def _add_if_absent(cur): + if self._get_option(cur, imsi): + raise ValueError(imsi) + self._insert(cur, imsi, status) + + self._connector.with_cursor(_add_if_absent) + + def delete_subscriber(self, imsi): + del self[imsi] + + def _set_status(self, cur, imsi, status): + try: + self._update(cur, imsi, status) + except KeyError: + raise SubscriberNotFound(imsi) + + def update_status(self, imsi, status): + def _update(cur): + self._set_status(cur, imsi, status) + + self._connector.with_cursor(_update) + + def get_subscriber_imsis(self): + return {key for key in self.get_subscriber_status().keys()} + + def process_update(self, net_subs): + from core import events + bts_imsis = self.get_subscriber_imsis() + net_imsis = set(net_subs.keys()) + subs_to_add = net_imsis.difference(bts_imsis) + subs_to_delete = bts_imsis.difference(net_imsis) + subs_to_update = bts_imsis.intersection(net_imsis) + subscriber = BaseSubscriber() + + for imsi in subs_to_delete: + self.delete_subscriber(imsi) + + for imsi in subs_to_update: + sub = net_subs[imsi] + sub_state = sub['state'] + sub_validity = sub['validity'] + sub_info = {"state": sub_state, "validity": sub_validity} + # Error Transfer Count this won't sync to cloud + if 'ie_count' not in sub: + sub['ie_count'] = 0 + + sub_info = {"state": sub_state, "validity": sub_validity, + "ie_count": sub['ie_count']} + try: + if str(sub_state).lower() not in ['active', 'active*']: + old_balance = subscriber.get_account_balance(imsi) + if old_balance > 0: + subscriber.subtract_credit(imsi, str(old_balance)) + reason = 'Subscriber expired: Setting balance zero' \ + ' (deduct_money)' + events.create_add_money_event(imsi, old_balance, 0, + reason) + self.update_status(imsi, json.dumps(sub_info)) + except SubscriberNotFound as e: + logger.warning( + "State sync fail! IMSI: %s is not found Error: %s" % + (imsi, e)) + except ValueError as e: + logger.error("State sync fail! IMSI: %s, %s Error: %s" % + (imsi, sub_info, e)) + subs_to_add.add(imsi) # try to add it (again) + + for imsi in subs_to_add: + sub = net_subs[imsi] + sub_state = sub['state'] + sub_validity = sub['validity'] + sub_info = {"state": sub_state, "validity": sub_validity} + try: + if str(sub_state).lower() not in ['active', 'active*']: + old_balance = subscriber.get_account_balance(imsi) + if old_balance > 0: + subscriber.subtract_credit(imsi, str(old_balance)) + reason = 'Subscriber expired:setting balance zero' \ + ' (deduct_money)' + events.create_add_money_event(imsi, old_balance, 0, + reason) + self.create_subscriber_status(imsi, json.dumps(sub_info)) + except (SubscriberNotFound, ValueError) as e: + logger.error("State sync fail! IMSI: %s, %s Error: %s" % + (imsi, sub_info, e)) + + def get_account_status(self, imsi): + status = json.loads(self.get(imsi)) + return str(status['state']) + + def get_subscriber_validity(self, imsi, days): + sub_info = json.loads(self.get(imsi)) + validity = str(sub_info['validity']) + delta_validity = datetime.utcnow() + timedelta(days=days) + if validity is None: + sub_info["validity"] = str(delta_validity.date()) + date = delta_validity + else: + validity_date = dateparser.parse(validity).date() + if validity_date < delta_validity.date(): + sub_info["validity"] = str(delta_validity.date()) + date = delta_validity + else: + sub_info["validity"] = str(validity_date) + date = validity_date + sub_info['state'] = 'active' + # '*' represents block, keep it blocked if already blocked. + if '*' in self.get_account_status(imsi): + sub_info['state'] += '*' + self.update_status(imsi, json.dumps(sub_info)) + return str(datetime.combine(date, datetime.min.time())) + + def get_invalid_count(self, imsi): + subscriber = json.loads(self.get(imsi)) + try: + return int(subscriber['ie_count']) + except: + return 0 # doesn't exist + + def reset_invalid_count(self, imsi): + subscriber = json.loads(self.get(imsi)) + subscriber['ie_count'] = 0 + self.update_status(imsi, json.dumps(subscriber)) + + def set_invalid_count(self, imsi, max_transactions): + subscriber = json.loads(self.get(imsi)) + if 'ie_count' in subscriber: + subscriber['ie_count'] = int(subscriber['ie_count']) + 1 + else: + subscriber['ie_count'] = 1 + + if subscriber['ie_count'] >= max_transactions: + # If transaction has happened means it's in active state + subscriber['state'] = 'active*' + subscriber['ie_count'] = 0 + self.update_status(imsi, json.dumps(subscriber)) diff --git a/cloud/endagaweb/checkin.py b/cloud/endagaweb/checkin.py index efa19311..417693cc 100644 --- a/cloud/endagaweb/checkin.py +++ b/cloud/endagaweb/checkin.py @@ -26,7 +26,7 @@ from endagaweb.models import TimeseriesStat from endagaweb.models import UsageEvent from endagaweb.util.parse_destination import parse_destination - +import dateutil.parser as dateparser class CheckinResponder(object): @@ -56,6 +56,7 @@ def __init__(self, bts): 'system_utilization': self.timeseries_handler, 'subscribers': self.subscribers_handler, 'radio': self.radio_handler, # needs location_handler -kurtis + 'subscriber_status': self.subscriber_status_handler, # TODO: (kheimerl) T13270418 Add location update information } @@ -264,6 +265,31 @@ def subscribers_handler(self, subscribers): (imsi, )) continue + def subscriber_status_handler(self, subscriber_status): + """ + Update the subscribers' state and validity info based on + what the client submits. + """ + for imsi in subscriber_status: + sub_info = json.loads(subscriber_status[imsi]['state']) + validity_now = str(sub_info['validity']) + state = str(sub_info['state']) + try: + sub = Subscriber.objects.get(imsi=imsi) + if sub.valid_through.date() < dateparser.parse(validity_now).date(): + sub.state = 'active' + sub.valid_through = validity_now + if state == 'active*': + sub.is_blocked = True + evt_gen = UsageEvent.objects.filter( + kind='error_transfer').order_by('-date')[0] + sub.last_blocked = evt_gen.date + sub.save() + except Subscriber.DoesNotExist: + logging.warn('[subscriber_status_handler] subscriber %s does not' + ' exist.' % imsi) + + def radio_handler(self, radio): if 'band' in radio and 'c0' in radio: self.bts.update_band_and_channel(radio['band'], radio['c0'])