diff --git a/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/00_vbts-chat.xml b/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/00_vbts-chat.xml index 8f8dcec8..42c29151 100644 --- a/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/00_vbts-chat.xml +++ b/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/00_vbts-chat.xml @@ -33,10 +33,18 @@ of patent rights can be found in the PATENTS file in the same directory. + + + + + + + + diff --git a/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/01_provisioning.xml b/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/01_provisioning.xml index 4052dd07..83aa4643 100644 --- a/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/01_provisioning.xml +++ b/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/01_provisioning.xml @@ -15,7 +15,7 @@ of patent rights can be found in the PATENTS file in the same directory. - + diff --git a/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/02_unprovisioned.xml b/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/02_unprovisioned.xml index 7bf93417..7c4901af 100644 --- a/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/02_unprovisioned.xml +++ b/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/02_unprovisioned.xml @@ -10,7 +10,7 @@ of patent rights can be found in the PATENTS file in the same directory. - + diff --git a/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/12_credit_check.xml b/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/12_credit_check.xml index b856cebb..7d9622b5 100644 --- a/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/12_credit_check.xml +++ b/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/12_credit_check.xml @@ -10,7 +10,7 @@ of patent rights can be found in the PATENTS file in the same directory. - + diff --git a/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/13_number_check.xml b/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/13_number_check.xml index 1d21c8ea..8b80f0ea 100644 --- a/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/13_number_check.xml +++ b/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/13_number_check.xml @@ -9,7 +9,7 @@ of patent rights can be found in the PATENTS file in the same directory. - + diff --git a/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/15_endaga_route.xml b/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/15_endaga_route.xml new file mode 100644 index 00000000..d6abc3da --- /dev/null +++ b/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/15_endaga_route.xml @@ -0,0 +1,17 @@ + + + + + + + + + + \ No newline at end of file diff --git a/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/20_error.xml b/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/20_error.xml index 22b55631..3d02bbfb 100644 --- a/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/20_error.xml +++ b/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/20_error.xml @@ -10,7 +10,7 @@ of patent rights can be found in the PATENTS file in the same directory. - + diff --git a/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/22_no_money.xml b/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/22_no_money.xml index 14adf6e8..fdf9b70e 100644 --- a/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/22_no_money.xml +++ b/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/22_no_money.xml @@ -10,7 +10,7 @@ of patent rights can be found in the PATENTS file in the same directory. - + diff --git a/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/23_number_status.xml b/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/23_number_status.xml new file mode 100644 index 00000000..07096d74 --- /dev/null +++ b/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/23_number_status.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/99_invalid.xml b/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/99_invalid.xml index 5c164635..7f37f95a 100644 --- a/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/99_invalid.xml +++ b/client/conf/freeswitch-conf-endaga/freeswitch/chatplan/default/99_invalid.xml @@ -10,7 +10,7 @@ of patent rights can be found in the PATENTS file in the same directory. - + diff --git a/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/00_vbts-call-internal.xml b/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/00_vbts-call-internal.xml index 74b5901c..ce7e3216 100644 --- a/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/00_vbts-call-internal.xml +++ b/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/00_vbts-call-internal.xml @@ -24,6 +24,9 @@ of patent rights can be found in the PATENTS file in the same directory. + + + diff --git a/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/010_provisioning.xml b/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/010_provisioning.xml index 36d28774..af030650 100644 --- a/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/010_provisioning.xml +++ b/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/010_provisioning.xml @@ -15,7 +15,7 @@ of patent rights can be found in the PATENTS file in the same directory. - + diff --git a/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/01_provisioned.xml b/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/01_provisioned.xml index a76bd1ca..c6ad6630 100644 --- a/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/01_provisioned.xml +++ b/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/01_provisioned.xml @@ -7,11 +7,16 @@ LICENSE file in the root directory of this source tree. An additional grant of patent rights can be found in the PATENTS file in the same directory. --> - + + + + - - - - + + + + + + diff --git a/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/10_credit_check.xml b/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/10_credit_check.xml index bf72efb9..803e4890 100644 --- a/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/10_credit_check.xml +++ b/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/10_credit_check.xml @@ -10,7 +10,7 @@ of patent rights can be found in the PATENTS file in the same directory. - + diff --git a/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/11_number_check.xml b/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/11_number_check.xml index 2051575a..b1eb7241 100644 --- a/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/11_number_check.xml +++ b/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/11_number_check.xml @@ -9,7 +9,7 @@ of patent rights can be found in the PATENTS file in the same directory. - + diff --git a/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/25_no_money.xml b/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/25_no_money.xml index f30b44ec..62788a3a 100644 --- a/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/25_no_money.xml +++ b/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/25_no_money.xml @@ -13,7 +13,7 @@ of patent rights can be found in the PATENTS file in the same directory. - + diff --git a/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/26_number_status.xml b/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/26_number_status.xml new file mode 100644 index 00000000..c44c3f7e --- /dev/null +++ b/client/conf/freeswitch-conf-endaga/freeswitch/dialplan/default/26_number_status.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/client/core/apps/sms_credit_transfer.py b/client/core/apps/sms_credit_transfer.py index 4978a620..4338abe1 100644 --- a/client/core/apps/sms_credit_transfer.py +++ b/client/core/apps/sms_credit_transfer.py @@ -19,14 +19,16 @@ from core import config_database from core import events from core import freeswitch_strings +from core.denomination_store import DenominationStore from core.sms import sms from core.subscriber import subscriber from core.exceptions import SubscriberNotFound - +from core.freeswitch_strings import BASE_MESSAGES +from core.subscriber.base import BaseBTSNotification config_db = config_database.ConfigDB() -gt = gettext.translation("endaga", config_db['localedir'], - [config_db['locale'], "en_US"]).gettext +ERROR_TRX = " error_transfer" +notification = BaseBTSNotification() def _init_pending_transfer_db(): @@ -46,6 +48,21 @@ def _init_pending_transfer_db(): 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 get_event(event): + if notification.get_notification(event): + return notification.get_notification(event) + return BASE_MESSAGES[event] + + def process_transfer(from_imsi, to_imsi, amount): """Process a transfer request. @@ -59,18 +76,84 @@ def process_transfer(from_imsi, to_imsi, amount): """ # Error when user tries to transfer to his own account if from_imsi == to_imsi: - return False, gt("Transaction Failed. Sharing load to " - "your own account is not allowed.") + return False, get_event('transfer_self_fail') 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 = get_event('account_blocked') + else: + status = get_event('no_validity') + return False, status + # rare scenario + if to_imsi_status in ['recycle', 'recycle*']: + return False, ("%s does not 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, (get_event('low_credit')) # 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, (get_event('sender_dont_exists')) + # 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 = (get_event('top_up_not_allowed')) % { + 'credit': credit_limit} + + # For cloud + event_msg = (BASE_MESSAGES['top_up_not_allowed']) % { + 'credit': credit_limit} + events.create_transfer_event(from_imsi, from_balance, from_balance, + event_msg + 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 = get_event( + 'transfer_attempts_left') % { + 'attempts': int(max_attempts) - (int(attempts) + 1) + } + reason = get_event('top_up_not_allowed_detail') % { + 'credit': credit_limit, 'transfer': max_transfer_str} + + # For cloud + event_msg = (BASE_MESSAGES['top_up_not_allowed_detail']) % { + 'credit': credit_limit, 'transfer': max_transfer_str} + events.create_transfer_event(from_imsi, from_balance, from_balance, + event_msg + 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 = get_event( + 'transfer_attempts_left') % { + 'attempts': int(max_attempts) - (int(attempts) + 1)} + reason = get_event('transfer_denomination_error') + + # For cloud + event_msg = (BASE_MESSAGES['transfer_denomination_error']) + events.create_transfer_event(from_imsi, from_balance, from_balance, + event_msg + 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'])): @@ -82,10 +165,9 @@ 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 = (get_event('transfer_confirm')) % { + 'code': code, 'amount': amount_str, 'to_number': to_num + } return True, response @@ -110,44 +192,53 @@ def process_confirm(from_imsi, code): from_imsi, to_imsi, amount = res from_num = subscriber.get_numbers_from_imsi(from_imsi)[0] to_num = subscriber.get_numbers_from_imsi(to_imsi)[0] - reason = "SMS transfer from %s to %s" % (from_num, to_num) + reason = (get_event('transfer_from_to')) % { + 'from_number': from_num, 'to_number': to_num} + + event_msg = (BASE_MESSAGES['transfer_from_to']) % { + 'from_number': from_num, 'to_number': to_num} # Deduct credit from the sender. from_imsi_old_credit = subscriber.get_account_balance(from_imsi) from_imsi_new_credit = int(from_imsi_old_credit) - int(amount) events.create_transfer_event(from_imsi, from_imsi_old_credit, - from_imsi_new_credit, reason, + from_imsi_new_credit, event_msg, from_number=from_num, to_number=to_num) subscriber.subtract_credit(from_imsi, str(int(amount))) + # Add credit to the recipient. to_imsi_old_credit = subscriber.get_account_balance(to_imsi) to_imsi_new_credit = int(to_imsi_old_credit) + int(amount) events.create_transfer_event(to_imsi, to_imsi_old_credit, - to_imsi_new_credit, reason, + to_imsi_new_credit, event_msg, 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) to_balance_str = freeswitch_strings.humanize_credits( - to_imsi_new_credit) + to_imsi_new_credit) from_balance_str = freeswitch_strings.humanize_credits( - from_imsi_new_credit) + 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.") % { - 'amount': amount_str, 'from_num': from_num, - 'new_balance': to_balance_str} + message = (get_event( + 'transfer_details_recipient')) % { + 'amount': amount_str, 'from_number': from_num, + '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=?" " AND from_acct=?", (code, from_imsi)) db.commit() # Tell the sender that the operation succeeded. - return True, gt("You've transferred %(amount)s to %(to_num)s. " - "Your new balance is %(new_balance)s.") % { - 'amount': amount_str, 'to_num': to_num, - 'new_balance': from_balance_str} - return False, gt("That transfer confirmation code doesn't exist" - " or has expired.") + return True, get_event( + 'transfer_details_sender') % { + 'amount': amount_str, 'to_number': to_num, + 'account_bal': from_balance_str} + + return False, get_event('transfer_expired') def handle_incoming(from_imsi, request): @@ -172,6 +263,7 @@ 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 @@ -179,8 +271,14 @@ def handle_incoming(from_imsi, request): try: to_imsi = subscriber.get_imsi_from_number(to_number) _, 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) except SubscriberNotFound: - resp = gt("Invalid phone number: %(number)s" % {'number': to_number}) + resp = get_event('invalid_number') % { + 'to_number': to_number} elif confirm: # The code is the whole request, so no need for groups. code = request.strip() @@ -188,7 +286,6 @@ def handle_incoming(from_imsi, request): else: # NOTE: Sent when the user tries to transfer credit with the wrong # format message. - resp = gt("To transfer credit, reply with a message in the" - " format 'NUMBER*AMOUNT'.") + resp = get_event('transfer_help') from_number = subscriber.get_numbers_from_imsi(from_imsi)[0] sms.send(str(from_number), str(config_db['app_number']), str(resp)) diff --git a/client/core/checkin.py b/client/core/checkin.py index dddfedf0..26a9b812 100644 --- a/client/core/checkin.py +++ b/client/core/checkin.py @@ -21,6 +21,7 @@ from core.bts import bts from core.config_database import ConfigDB from core.event_store import EventStore +from core.denomination_store import DenominationStore from core.registration import reset_registration from core.subscriber import subscriber @@ -30,6 +31,8 @@ class CheckinHandler(object): CONFIG_SECTION = "config" EVENTS_SECTION = "events" SUBSCRIBERS_SECTION = "subscribers" + NETWORK_DENOMINATION = "network_denomination" + NOTIFICATION = "notification" # NOTE: Keys in section_ctx dictionary below must match the keys of # optimized checkin sections: "config", "events", "subscribers", etc. @@ -42,6 +45,7 @@ class CheckinHandler(object): def __init__(self, response): self.conf = ConfigDB() self.eventstore = EventStore() + self.denominationstore = DenominationStore() r = self.validate(response) self.process(r) @@ -59,6 +63,10 @@ def process(self, resp_dict): self.process_events(resp_dict[section]) elif section == CheckinHandler.SUBSCRIBERS_SECTION: self.process_subscribers(resp_dict[section]) + elif section == CheckinHandler.NETWORK_DENOMINATION: + self.process_denomination(resp_dict[section]) + elif section == CheckinHandler.NOTIFICATION: + self.process_notification(resp_dict[section]) elif section != 'status': logger.error("Unexpected checkin section: %s" % section) @@ -92,6 +100,20 @@ 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_notification(self, data_dict): + subscriber.notif_status(update=data_dict) + + def process_denomination(self, data_dict): + for data in data_dict: + if self.denominationstore.get_record(data['id']) == None: + self.denominationstore.add_record(data['id'],data['start_amount'], + data['end_amount'],data['validity']) + id_list = self.denominationstore.get_all_id() + for id in id_list: + if id not in [d['id'] for d in data_dict]: + self.denominationstore.delete_record(id) def process_events(self, data_dict): """Process information about events. diff --git a/client/core/config_database.py b/client/core/config_database.py index ae82dbe6..1292da56 100644 --- a/client/core/config_database.py +++ b/client/core/config_database.py @@ -154,8 +154,11 @@ def set_defaults(force_replace=False): 'external_interface': 'tun0', # The internal interface is the NIC used by the BSC/BTS to address this # system - 'internal_interface': 'lo' - + 'internal_interface': 'lo', + # network balance limit + 'network_max_balance': 100000, + # Network Max Permissible Transaction + 'network_mput': 3 } config = ConfigDB() diff --git a/client/core/denomination_store.py b/client/core/denomination_store.py new file mode 100644 index 00000000..85ec7056 --- /dev/null +++ b/client/core/denomination_store.py @@ -0,0 +1,122 @@ +""" +store denomination bracket in the backend database. + +Copyright (c) 2017-present, Facebook, Inc. +All rights reserved. + +This source code is licensed under the BSD-style license found in the +LICENSE file in the root directory of this source tree. An additional grant +of patent rights can be found in the PATENTS file in the same directory. +""" + + + + + + +import os + +import psycopg2 + + +# In our CI system, Postgres credentials are stored in env vars. +PG_USER = os.environ.get('PG_USER', 'endaga') +PG_PASSWORD = os.environ.get('PG_PASSWORD', 'endaga') + + +class DenominationStore(object): + """Keeps track of all system events that need to be sent to the server.""" + + def __init__(self): + self.connection = psycopg2.connect(host='localhost', database='endaga', + user=PG_USER, password=PG_PASSWORD) + self.table_name = 'denomination_store' + # Create the table if it doesn't yet exist. + with self.connection.cursor() as cursor: + command = ("CREATE TABLE IF NOT EXISTS %s(" + " id integer NOT NULL," + " start_amount bigint NOT NULL," + " end_amount bigint NOT NULL," + " validity_days integer NOT NULL" + ");") + cursor.execute(command % self.table_name) + self.connection.commit() + + def empty(self): + """Drops all records from the table.""" + with self.connection.cursor() as cursor: + command = 'truncate %s' % self.table_name + cursor.execute(command) + self.connection.commit() + + def get_records(self): + res = [] + template = ('select * from %s ') + command = template % (self.table_name) + with self.connection.cursor() as cursor: + + cursor.execute(command) + self.connection.commit() + r = cursor.fetchall() + for item in r: + data = {'start_amount': item[1], + 'end_amount': item[2], 'validity': item[3]} + + return res + + def get_all_id(self): + res =[] + + template = 'select * from %s ' + command = template % (self.table_name) + with self.connection.cursor() as cursor: + cursor.execute(command) + self.connection.commit() + r = cursor.fetchall() + for item in r: + res.append(item[0]) + return res + + def get_record(self, id): + """Gets the most recent record for an Id. + + Returns None if no record was found. + """ + command = "select * from %s where id='%s'" + with self.connection.cursor() as cursor: + cursor.execute(command % (self.table_name, id)) + self.connection.commit() + if not cursor.rowcount: + return None + else: + return cursor.fetchall()[0] + + def delete_record(self, id): + template = 'delete from %s where id = %s' + command = template % (self.table_name, id) + with self.connection.cursor() as cursor: + cursor.execute(command) + self.connection.commit() + + def get_validity_days(self,top_up): + + template = "select validity_days from %s where start_amount <=%s and end_amount >=%s order by -end_amount" + command = template %(self.table_name, top_up, top_up) + with self.connection.cursor() as cursor: + cursor.execute(command) + self.connection.commit() + r = cursor.fetchall() + if len(r): + return r[0] + else: + return None + + def add_record(self,id, start_amount, end_amount, validity): + schema = ('id, start_amount, end_amount, validity_days') + values = "'%s','%s', '%s', %s" % ( + id, start_amount, end_amount, validity) + command = 'insert into %s (%s) values(%s)' % ( + self.table_name, schema, values) + with self.connection.cursor() as cursor: + cursor.execute(command) + self.connection.commit() diff --git a/client/core/events.py b/client/core/events.py index 3892f43b..4a68b464 100644 --- a/client/core/events.py +++ b/client/core/events.py @@ -28,7 +28,7 @@ def usage(num=100): def kind_from_reason(reason_str): types = ["local_call", "local_sms", "outside_call", "outside_sms", "free_call", "free_sms", "incoming_sms", "error_sms", - "error_call", "transfer", "add_money", "deduct_money", + "error_call", "error_transfer", "transfer", "add_money", "deduct_money", "set_balance", "unknown", "Provisioned", "local_recv_call", "local_recv_sms", "incoming_call", "gprs"] for t in types: diff --git a/client/core/exceptions.py b/client/core/exceptions.py index 3d019856..14f5827a 100644 --- a/client/core/exceptions.py +++ b/client/core/exceptions.py @@ -15,3 +15,5 @@ class BSSError(Exception): class SubscriberNotFound(Exception): pass +class EventNotFound(Exception): + pass \ No newline at end of file diff --git a/client/core/federer.py b/client/core/federer.py index 40532cc9..81b64eec 100644 --- a/client/core/federer.py +++ b/client/core/federer.py @@ -16,7 +16,6 @@ import web import traceback - from ccm.common import logger diff --git a/client/core/federer_handlers/registration.py b/client/core/federer_handlers/registration.py index 244ac2ae..45b1e748 100644 --- a/client/core/federer_handlers/registration.py +++ b/client/core/federer_handlers/registration.py @@ -17,10 +17,13 @@ from core import events from core import freeswitch_interconnect from core import interconnect -from core.subscriber import subscriber from core.config_database import ConfigDB -from core.federer_handlers.common import gt +from core.subscriber import subscriber +from core.subscriber.base import BaseBTSNotification +import core.config_database +configdb = core.config_database.ConfigDB() +notification = BaseBTSNotification() class registration: """Class for doing registration. @@ -40,12 +43,16 @@ def registration_worker(self, from_name, ip, port, ret_num): number = self.ic.register_subscriber(imsi=from_name)['number'] subscriber.create_subscriber(from_name, number, ip, port) self.fs_ic.send_to_number(number, ret_num, - gt("Your number is %(number)s.") % {'number': number}) + notification.get_notification( + str(configdb.get( + 'number_check_number'))) % + {'from_number': number}) reason = 'Provisioned user %s number %s' % (from_name, number) events.create_provision_event(from_name, reason) except Exception as e: self.fs_ic.send_to_imsi(from_name, ip, port, ret_num, - gt("Failed to register your handset.")) + notification.get_notification( + 'reg_failed')) logger.error("Failed to provision %s: %s" % (from_name, traceback.format_exc(e))) diff --git a/client/core/freeswitch_interconnect.py b/client/core/freeswitch_interconnect.py index 3f21f98a..dfd40648 100644 --- a/client/core/freeswitch_interconnect.py +++ b/client/core/freeswitch_interconnect.py @@ -12,7 +12,6 @@ """ from ESL import ESLconnection - from core import number_utilities diff --git a/client/core/freeswitch_strings.py b/client/core/freeswitch_strings.py index e574fa91..aba4fe34 100644 --- a/client/core/freeswitch_strings.py +++ b/client/core/freeswitch_strings.py @@ -1,17 +1,10 @@ """ -We have a few strings that are defined in our FS chatplan and dialplan which -need internationalization support. - -The way we handle this is via the endaga_i18n script. It's used in a +The way we handle this is via the endaga_notification script. It's used in a dialplan/chatplan like so: - - -The result is saved into the $endaga_i18n FS variable for later use. - -Note what this does -- it's passing in a string to the script, which in turn -needs to look it up and return some sensible result. This file is where we -actually keep track of the various strings used in the dialplan/chatplan. + + ex: + Copyright (c) 2016-present, Facebook, Inc. All rights reserved. @@ -23,15 +16,70 @@ import gettext -from ccm.common import currency import core.config_database +from ccm.common import currency configdb = core.config_database.ConfigDB() - -gt = gettext.translation("endaga", configdb['localedir'], [configdb['locale'], "en"]).gettext +number_check_number = configdb.get('number_check_number') or 104 +credit_check_number = configdb.get('credit_check_number') or 103 + +BASE_MESSAGES = { + # Mapped + str(credit_check_number): "Your balance is %(account_bal)s.", + str(number_check_number): "Your number is %(from_number)s.", + + 'block_expired': "Your account is blocked or expired, Please contact " + "your service provider.", + 'receiver_expired': "Failed to deliver message, as the receiver number " + "has expired.", + 'provisioning': "Already registered with number %(from_number)s", + 'unprovisioned': "Your phone is not provisioned.", + 'sms_error': "Message not sent to %(to_number)s", + 'no_money': "Your account doesn't have sufficient funds.", + 'no_money_sms': "Your account doesn't have sufficient funds to send an " + "SMS.", + 'invalid_address': "Invalid Address", + 'reg_failed': "Failed to register your handset.", + + # SMS CREDIT TRANSFER (CT_MESSAGES) + 'transfer_self_fail': "Transaction Failed. Sharing load to your own " + "account is not allowed.", + 'transfer_attempts_left': "Your left attempts %(attempts)s", + 'transfer_denomination_error': "Top-up not under denomination range.", + 'transfer_confirm': "Reply to this message with %(code)s to confirm your " + "transfer of %(amount)s to %(to_number)s Code expires " + "in ten minutes.", + 'transfer_from_to': "SMS transfer from %(from_number)s to %(to_number)s", + 'transfer_details_recipient': "You've received %(amount)s. credits from " + "%(from_number)s Now your balance " + "%(new_balance)s and validity is " + "%(validity)s.", + 'transfer_details_sender': "You've transferred %(amount)s to " + "%(to_number)s Your new balance is " + "%(account_bal)s.", + 'transfer_expired': "That transfer confirmation code doesn't exist or has " + "expired .", + 'transfer_help': "To transfer credit, reply with a message in the format " + "'NUMBER*AMOUNT'.", + 'invalid_number': "Invalid phone number: %(to_number)s", + 'low_credit': "Your account doesn't have sufficient funds for the " + "transfer.", + 'sender_dont_exists': "The number you're sending to doesn't exist. " + "Try again.", + 'account_blocked': "Your account is blocked", + 'no_validity': "Your account has no validity", + 'top_up_not_allowed': "Top-up not allowed. Maximum balance limit crossed " + "%(credit)s.", + 'top_up_not_allowed_detail': "Top-up not allowed. Maximum balance limit " + "crossed %(credit)s You can transfer upto" + " %(transfer)s.", +} +# TODO(sharma-sagar): After above below will be dead code below (remove later) +gt = gettext.translation("endaga", configdb['localedir'], + [configdb['locale'], "en"]).gettext # NOTE: (chatplan/01_provisioning) This message is sent when a user tries to register an already registered SIM card. -gt("Already registered with number %(number)s.") +gt("Already registered with number %(from_number)s.") # NOTE: (chatplan/02_unprovisioned) This message is sent when an unprovisioned phone tries to use the network. gt("Your phone is not provisioned.") @@ -40,10 +88,10 @@ gt("Your balance is %(account_bal)s.") # NOTE: (chatplan/13_number_check, dialplan/11_number_check) This message is sent when the user checks their phone number. -gt("Your number is %(number)s.") +gt("Your number is %(from_number)s.") # NOTE: (chatplan/20_error) Sent when the SMS contains bad characters. -gt("Message not sent to %(dest_number)s.") +gt("Message not sent to %(to_number)s.") # NOTE: (dialplan/25_no_money) This message is sent when the user has insufficient funds. gt("Your account doesn't have sufficient funds.") @@ -54,6 +102,7 @@ # NOTE: (chatplan/99_invalid) This message is sent when the SMS is sent to an invalid address. gt("Invalid Address") + def localize(string_key, params): return str(gt(string_key) % params) diff --git a/client/core/interconnect.py b/client/core/interconnect.py index 33b1c022..987fb4e2 100644 --- a/client/core/interconnect.py +++ b/client/core/interconnect.py @@ -171,7 +171,12 @@ 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 bts locale and notifications + status['bts_locale'] = self.conf['locale'] + status['notifications'] = subscriber.notif_status().get_notification() # 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/_osmocom.py b/client/core/subscriber/_osmocom.py index aa088037..f4397c12 100644 --- a/client/core/subscriber/_osmocom.py +++ b/client/core/subscriber/_osmocom.py @@ -26,7 +26,7 @@ from core import number_utilities from core.config_database import ConfigDB -from core.subscriber.base import BaseSubscriber, SubscriberNotFound +from core.subscriber.base import BaseSubscriber, SubscriberNotFound, BaseSubscriberStatus from core.exceptions import BSSError diff --git a/client/core/subscriber/base.py b/client/core/subscriber/base.py index 562c2543..b1400d4f 100644 --- a/client/core/subscriber/base.py +++ b/client/core/subscriber/base.py @@ -14,18 +14,20 @@ 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 -from core.exceptions import SubscriberNotFound +from core.exceptions import SubscriberNotFound, EventNotFound +from core.freeswitch_strings import BASE_MESSAGES +from itertools import count class BaseSubscriber(KVStore): - def __init__(self, connector=None): super(BaseSubscriber, self).__init__('subscribers', connector, @@ -485,3 +487,302 @@ 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 + + def notif_status(self, update=None): + status = BaseBTSNotification() + if update is not None: + status.process_notifcaiton(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)) + + +class BaseBTSNotification(KVStore): + _ids = count(0) + + def __init__(self, connector=None): + self.id = next(self._ids) + super(BaseBTSNotification, self).__init__('notification', connector, + key_name='event', + val_name='message') + # add only once. + if 2 > self.id: + for message in BASE_MESSAGES: + self.get_or_create(message, BASE_MESSAGES[message]) + + def get_notification(self, event=None): + if event: # non-empty list, return requested notifications + return self.get(event) + elif event is None: # empty list, return all + events = list(self.items()) + else: + return {} # empty list - return an empty dict + res = {} + for (event, message) in events: + res[event] = message + return res + + def _set_notification(self, cur, event, message): + try: + self._update(cur, event, message) + except KeyError: + raise EventNotFound(event) + + def get_events(self): + return {key for key in self.get_notification().keys()} + + def delete_notification(self, event): + del self[event] + + def create_notification(self, event, message): + def _add_if_absent(cur): + if self._get_option(cur, event): + raise ValueError(event) + + self._insert(cur, event, message) + + self._connector.with_cursor(_add_if_absent) + + def update_notification(self, event, message): + def _update(cur): + self._set_notification(cur, event, message) + + self._connector.with_cursor(_update) + + def process_notifcaiton(self, notifications): + """ + notifications: {event: some_event , message: some_translated_message} + Update notification messages w.r.t current bts language + :param event: Number(int type) or Event(string type) + """ + bts_events = self.get_events() + cloud_events = set(notifications.keys()) + + events_to_add = cloud_events.difference(bts_events) + events_to_delete = bts_events.difference(cloud_events) + events_to_update = bts_events.intersection(cloud_events) + + for event in events_to_delete: + self.delete_notification(event) + + for event in events_to_update: + message = notifications[event] + try: + self.update_notification(event, message) + except EventNotFound as e: + logger.warning( + "Notification sync fail! Event: %s is not found Error: %s" + % (event, e)) + except ValueError as e: + logger.error("Notification sync fail! Event: %s, Error: %s" + % (event, e)) + events_to_add.add(notifications) # try to add it (again) + + for event in events_to_add: + message = notifications[event] + self.create_notification(event, message) + try: + self.update_notification(event, message) + except (EventNotFound, ValueError) as e: + logger.error( + "Notification sync fail! Event: %s Error: %s" % + (event, e)) + + def get_or_create(self, key, message=None): + if message is not None: + # flag the message for translation, + # cloud to translate and revert back notification for next time + if self.get(key) is None: + message = str(message) + '*' + self.create_notification(event=key, message=message) + return + return self.get(key) diff --git a/client/scripts/freeswitch/VBTS_Get_Account_Status.py b/client/scripts/freeswitch/VBTS_Get_Account_Status.py new file mode 100644 index 00000000..db7787e1 --- /dev/null +++ b/client/scripts/freeswitch/VBTS_Get_Account_Status.py @@ -0,0 +1,96 @@ +"""Get a subscriber's account balance via their IMSI. + +Copyright (c) 2016-present, Facebook, Inc. +All rights reserved. + +This source code is licensed under the BSD-style license found in the +LICENSE file in the root directory of this source tree. An additional grant +of patent rights can be found in the PATENTS file in the same directory. +""" + +from freeswitch import consoleLog + +from core.subscriber import subscriber +from core.subscriber.base import SubscriberNotFound + + +def chat(message, args): + """Handle chat requests. + + Args: + string of the form | + + Subscriber State can be: + active (unblocked), -active (blocked),first_expired (validity expired) + """ + args = args.split('|') + imsi = args[0] + dest_imsi = False + + if len(args) > 1: + dest_imsi = True + if len(imsi) < 4: # Toll Free Numbers don't have imsis + subscriber_state = 'active' + else: + subscriber_state = str( + subscriber.status().get_account_status(imsi)).lower() + else: + subscriber_state = str( + subscriber.status().get_account_status(imsi)).lower() + try: + account_status = False + if not dest_imsi: + if 'active' == subscriber_state: + account_status = True + else: + # incoming number status + allowed_states = ['active', 'active*', + 'first_expired', 'first_expired*'] + if subscriber_state in allowed_states: + account_status = True + + except SubscriberNotFound: + account_status = False + consoleLog('info', "Returned Chat:" + str(account_status) + "\n") + message.chat_execute('set', '_openbts_ret=%s' % account_status) + + +def fsapi(session, stream, env, args): + """Handle FS API requests. + + Args: + string of the form | + + Subscriber State can be: + active (unblocked), -active (blocked),first_expired (validity expired) + """ + args = args.split('|') + imsi = args[0] + dest_imsi = False + + if len(args) > 1: + dest_imsi = True + if len(imsi) < 4: # Toll Free Numbers don't have imsis + subscriber_state = 'active' + else: + subscriber_state = str( + subscriber.status().get_account_status(imsi)).lower() + else: + subscriber_state = str( + subscriber.status().get_account_status(imsi)).lower() + try: + account_status = False + if not dest_imsi: + if 'active' == subscriber_state: + account_status = True + else: + # incoming number status + allowed_states = ['active', 'active*', + 'first_expired', 'first_expired*'] + if subscriber_state in allowed_states: + account_status = True + + except SubscriberNotFound: + account_status = False + consoleLog('info', "Returned FSAPI: " + str(account_status) + "\n") + stream.write(str(account_status)) \ No newline at end of file diff --git a/client/scripts/freeswitch/endaga_notification.py b/client/scripts/freeswitch/endaga_notification.py new file mode 100644 index 00000000..dc548b32 --- /dev/null +++ b/client/scripts/freeswitch/endaga_notification.py @@ -0,0 +1,79 @@ +# Copyright (c) 2016-present, Facebook, Inc. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. An additional grant +# of patent rights can be found in the PATENTS file in the same directory. + +import json + +from freeswitch import consoleLog + +from core.freeswitch_strings import BASE_MESSAGES +from core.subscriber.base import BaseBTSNotification + +notification = BaseBTSNotification() + + +def parse(args): + """ + The format of the arguments must be: + + This is some %(word)s string.|{'word': 1234} + This is some string. + + The first argument is required; the second argument is not + required. This will return the first argument and either the + second as a dictionary or an empty dictionary if the second + isn't present. + """ + + res = args.split('|', 1) + if len(res) == 1: + return args, {} + else: + return res[0], json.loads(res[1]) + + +def localize(args): + + event, params = parse(args) + # For notifications by cloud + if 'EVENT' == event.split('_')[0]: + event = event.split('_')[1] + if notification.get_notification(event) and len(event) == 3: + # not handling params for events created by CLOUD + message = str(True) + else: + message = str(False) + consoleLog('info', 'message to check for event %s' % event) + else: + try: + message = str(notification.get_notification(event) % params) + consoleLog('info', "Localizing %s: %s" % (args, message)) + except: + # Let's send english as default + message = str(BASE_MESSAGES[event] % params) + consoleLog('info', "translation missing for '%s'" % event) + consoleLog('info', 'message %s' % message) + return message + + +def chat(message, args): + res = localize(args) + message.chat_execute('set', '_localstr=%s' % res) + + +def fsapi(session, stream, env, args): + message = localize(args) + if isinstance(session, str): + # we're in the FS CLI, so no session object + consoleLog('info', + "No session; otherwise would set _localstr=%s" % message) + else: + session.execute("set", "_localstr=%s" % message) + + +def handler(session, args): + res = localize(args) + session.execute("set", "_localstr=%s" % res) diff --git a/cloud/endagaweb/forms/dashboard_forms.py b/cloud/endagaweb/forms/dashboard_forms.py index 2f9a6ddc..8d89f4c4 100644 --- a/cloud/endagaweb/forms/dashboard_forms.py +++ b/cloud/endagaweb/forms/dashboard_forms.py @@ -10,19 +10,24 @@ import datetime +import pytz +from crispy_forms.bootstrap import StrictButton, FieldWithButtons +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Layout, Submit, Field, Fieldset, ButtonHolder from django import forms +from django.contrib.auth.forms import PasswordChangeForm, PasswordResetForm +from django.contrib.auth.models import User +from django.core import urlresolvers from django.db.models import Value from django.db.models.functions import Coalesce -from django.core import urlresolvers -from crispy_forms.helper import FormHelper -from crispy_forms.layout import Layout, Submit, Field -from crispy_forms.bootstrap import StrictButton, FieldWithButtons -from django.contrib.auth.forms import PasswordChangeForm -import pytz +from django.utils import safestring from ccm.common.currency import CURRENCIES from endagaweb import models from endagaweb.templatetags import apptags +from django.contrib.auth import password_validation +from googletrans.constants import LANGUAGES, LANGCODES +from django.conf import settings class UpdateContactForm(forms.Form): @@ -368,3 +373,135 @@ def __init__(self, *args, **kwargs): self.helper.form_action = '/dashboard/staff/tower-monitoring' self.helper.add_input(Submit('submit', 'Select')) self.helper.layout = Layout('tower') + + +class NotificationForm(forms.Form): + language_choices = [] + for key in settings.BTS_LANGUAGES: + language_choices.append((key, LANGUAGES[key].capitalize())) + types = ( + ('automatic', 'Automatic'), + ('mapped', 'Mapped') + ) + help_text = ( + 'Automatic: Sent to user automatically,
' + 'Mapped: Notification will be sent to mapped users.' + ) + type = forms.ChoiceField(required=True, label='', help_text=help_text, + choices=types, + widget=forms.RadioSelect( + attrs={'title': 'Notification type'}), ) + + event_info = "" + event = forms.CharField(required=True, help_text=event_info, + widget=forms.TextInput( + attrs={'title': 'alphabets or ' + 'alphanumeric only', + 'style': 'width:300px', + 'onchange': 'checkEvent()', + }), label='Events') + + edit = "" + edit = safestring.mark_safe(edit) + html_break = safestring.mark_safe('
') + message_edit_help = 'Note: Any change to above message will reflect all ' \ + 'translations. %s %s ' \ + 'Translations can be changed separately.' % ( + edit, html_break) + message = forms.CharField(required=True, min_length=20, max_length=160, + label='Message', + help_text=message_edit_help, + widget=forms.Textarea( + attrs={'title': 'Message for translation ' + '(max characters: 160)', + 'placeholder': 'Enter Message...', + 'rows': '2', + 'onchange': 'getTranslation(this)', + } + )) + info = "Details on how to add message..." + number = forms.IntegerField(required=True, disabled=True, min_value=1, + max_value=999, widget=forms.NumberInput( + attrs={'class': 'form-control', 'pattern': '[0-9]{3}', + 'title': 'numerical input only', 'style': 'width:100px', + 'oninvalid': "setCustomValidity('Enter number " + "(max: 3 digits)')", + 'onchange': "try{" + "setCustomValidity('')}" + "catch(e){}; checkEvent();", + }), + ) + pk = forms.CharField(widget=forms.HiddenInput()) + + def __init__(self, language=None, *args, **kwargs): + super(NotificationForm, self).__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_id = 'network-notification-form' + self.helper.form_method = 'POST' + self.helper.form_action = '/dashboard/network/notification' + fields = [] + fields.extend(['type', 'number', 'event', 'message', 'pk']) + if language: + languages = dict(language).keys() + else: + languages = settings.BTS_LANGUAGES + missing = '' + if len(languages) != len(settings.BTS_LANGUAGES): + missing = list(set(settings.BTS_LANGUAGES) - set(languages)) + self.helper.add_input( + Submit('submit', 'Submit', css_class='invisible pull-right')) + for key in settings.BTS_LANGUAGES: + placeholder = 'Translation in %s ' % LANGUAGES[ + key].capitalize() + label = LANGUAGES[key].capitalize() + if key in missing: + placeholder = 'New language %s' % LANGUAGES[ + key].capitalize() + + self.fields['lang_%s' % key] = forms.CharField( + required=True, min_length=20, max_length=160, + label=label, widget=forms.Textarea( + attrs={'id': 'lang_%s' % key, + 'title': "For dynamic values you can add " + "%(account_balance)s for number " + "%(number)s etc." + "Always add a space after" + "wildcard(s)", + 'placeholder': placeholder, + 'rows': '2', + 'onchange': 'enableUpdate()', + } + )) + fields.append('lang_%s' % key) + self.helper.layout = Layout(*fields) + + +class NotificationSearchForm(forms.Form): + """Crispy search form for notifications under network""" + choices = [] + lang = settings.BTS_LANGUAGES + for lg in lang: + choices.append((lg, LANGUAGES[lg].capitalize())) + + query = forms.CharField(required=False, label="", + widget=forms.TextInput( + attrs={'placeholder': 'Message or Type of' + ' event' + })) + language = forms.ChoiceField(label="", choices=choices, required=False, + widget=forms.Select( + attrs={'onchange': 'form.submit();'})) + + def __init__(self, *args, **kwargs): + super(NotificationSearchForm, self).__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_id = 'id-NotificationSearchForm' + self.helper.form_method = 'get' + self.helper.form_action = '/dashboard/network/notification' + search_button = StrictButton('Filter', css_class='btn-default', + type='submit') + self.helper.form_class = 'col-sm-4' + self.helper.layout = Layout(FieldWithButtons('query', search_button), + 'language') diff --git a/cloud/endagaweb/models.py b/cloud/endagaweb/models.py index 65b0780f..d1ec2906 100644 --- a/cloud/endagaweb/models.py +++ b/cloud/endagaweb/models.py @@ -44,6 +44,7 @@ from endagaweb.notifications import bts_up from endagaweb.util import currency as util_currency from endagaweb.util import dbutils as dbutils +from googletrans.constants import LANGUAGES stripe.api_key = settings.STRIPE_API_KEY @@ -1793,3 +1794,23 @@ class FileUpload(models.Model): created_time = models.DateTimeField(auto_now_add=True) modified_time = models.DateTimeField(auto_now_add=True) accessed_time = models.DateTimeField(auto_now=True) + +class Notification(models.Model): + # """ + # Notification messages and their translations + # """ + TYPE = ( + ('automatic', 'Automatic'), + ('mapped', 'Mapped') + ) + + network = models.ForeignKey('Network', on_delete=models.CASCADE) + event = models.CharField(max_length=100, null=True) + message = models.TextField(max_length=160, null=True) + type = models.CharField(max_length=10, choices=TYPE, default='automatic') + language = models.CharField(max_length=6, default='en') + translation = models.TextField(max_length=160, null=True) + protected = models.BooleanField(default=False) + + class Meta: + unique_together = ('event', 'translation', 'network') diff --git a/cloud/endagaweb/static/img/loader.gif b/cloud/endagaweb/static/img/loader.gif new file mode 100644 index 00000000..1560b646 Binary files /dev/null and b/cloud/endagaweb/static/img/loader.gif differ diff --git a/cloud/endagaweb/templates/dashboard/network_detail/header.html b/cloud/endagaweb/templates/dashboard/network_detail/header.html index 4e9d3965..05215b64 100644 --- a/cloud/endagaweb/templates/dashboard/network_detail/header.html +++ b/cloud/endagaweb/templates/dashboard/network_detail/header.html @@ -6,6 +6,8 @@ LICENSE file in the root directory of this source tree. An additional grant of patent rights can be found in the PATENTS file in the same directory. {% endcomment %} +{% load guardian_tags %} +{% get_obj_perms request.user for network as 'user_permission' %}
diff --git a/cloud/endagaweb/templates/dashboard/network_detail/nav.html b/cloud/endagaweb/templates/dashboard/network_detail/nav.html index d2c2d396..d3107460 100644 --- a/cloud/endagaweb/templates/dashboard/network_detail/nav.html +++ b/cloud/endagaweb/templates/dashboard/network_detail/nav.html @@ -30,6 +30,12 @@ {% endif %} ">Denominations + +