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 = "" \ + "Notification already exists!" + 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 = " Click Edit to change." + 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' %} @@ -13,6 +15,22 @@ {% if network.name %} "{{ network.name }}" {% endif %} + {% if 'edit_denomination' in user_permission %} + {% if active_tab == 'network-denominations' %} + Add Denomination + + {% endif %} + {% endif %} + {% if 'edit_notification' in user_permission %} + {% if active_tab == 'network-notifications' %} + Add Notification + + {% endif %} + {% endif %} 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 + + Notifications + + +.modal.fade:not(.in).left .modal-dialog { + -webkit-transform: translate3d(-25%, 0, 0); + transform: translate3d(-25%, 0, 0); + //transform: translate3d(-25%, -25%, 0); + //transform: translate3d(-25%, 25%, 0); +} +.modal.fade:not(.in).right .modal-dialog { + -webkit-transform: translate3d(125%, 0, 0); + transform: translate3d(125%, 0, 0); + //transform: translate3d(25%, -25%, 0); + //transform: translate3d(25%, 25%, 0); +} +.modal.fade:not(.in).bottom .modal-dialog { + -webkit-transform: translate3d(0, 125%, 0); + transform: translate3d(0, 125%, 0); +} +@-webkit-keyframes ring { + 0% { + -webkit-transform: rotate(-15deg); + transform: rotate(-15deg); + } + + 2% { + -webkit-transform: rotate(15deg); + transform: rotate(15deg); + } + + 4% { + -webkit-transform: rotate(-18deg); + transform: rotate(-18deg); + } + + 6% { + -webkit-transform: rotate(18deg); + transform: rotate(18deg); + } + + 8% { + -webkit-transform: rotate(-22deg); + transform: rotate(-22deg); + } + + 10% { + -webkit-transform: rotate(22deg); + transform: rotate(22deg); + } + + 12% { + -webkit-transform: rotate(-18deg); + transform: rotate(-18deg); + } + + 14% { + -webkit-transform: rotate(18deg); + transform: rotate(18deg); + } + + 16% { + -webkit-transform: rotate(-12deg); + transform: rotate(-12deg); + } + + 18% { + -webkit-transform: rotate(12deg); + transform: rotate(12deg); + } + + 20% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } +} + +@keyframes ring { + 0% { + -webkit-transform: rotate(-15deg); + -ms-transform: rotate(-15deg); + transform: rotate(-15deg); + } + + 2% { + -webkit-transform: rotate(15deg); + -ms-transform: rotate(15deg); + transform: rotate(15deg); + } + + 4% { + -webkit-transform: rotate(-18deg); + -ms-transform: rotate(-18deg); + transform: rotate(-18deg); + } + + 6% { + -webkit-transform: rotate(18deg); + -ms-transform: rotate(18deg); + transform: rotate(18deg); + } + + 8% { + -webkit-transform: rotate(-22deg); + -ms-transform: rotate(-22deg); + transform: rotate(-22deg); + } + + 10% { + -webkit-transform: rotate(22deg); + -ms-transform: rotate(22deg); + transform: rotate(22deg); + } + + 12% { + -webkit-transform: rotate(-18deg); + -ms-transform: rotate(-18deg); + transform: rotate(-18deg); + } + + 14% { + -webkit-transform: rotate(18deg); + -ms-transform: rotate(18deg); + transform: rotate(18deg); + } + + 16% { + -webkit-transform: rotate(-12deg); + -ms-transform: rotate(-12deg); + transform: rotate(-12deg); + } + + 18% { + -webkit-transform: rotate(12deg); + -ms-transform: rotate(12deg); + transform: rotate(12deg); + } + + 20% { + -webkit-transform: rotate(0deg); + -ms-transform: rotate(0deg); + transform: rotate(0deg); + } +} + +.faa-ring.animated, +.faa-ring.animated-hover:hover, +.faa-parent.animated-hover:hover > .faa-ring { + -webkit-animation: ring 2s ease infinite; + animation: ring 2s ease infinite; + transform-origin-x: 50%; + transform-origin-y: 0px; + transform-origin-z: initial; +} + +.loadinggif { + background:url('/static/img/loader.gif') no-repeat center center; +} +.btn{ + margin-left: 5px; +} +.btn:last-child{ + margin-left: 0; +} +.textarea{ + resize:none; +} +table tbody tr.highlight td { + background-color: #ddd; +} +label.btn span { + font-size: 1.5em ; +} + +/* Rounded sliders */ +.slider.round { + border-radius: 34px; +} +.slider.round:before { + border-radius: 50%; +} + +.loader { + border: 16px solid lightgreen; + border-radius: 70%; + border-top: 16px solid white; + width: 30px; + height: 30px; + -webkit-animation: spin 2s linear infinite; + animation: spin 2s linear infinite; + position: fixed; + top: 95%; + left: 1%; + -webkit-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); +} + +@-webkit-keyframes spin { + 0% { -webkit-transform: rotate(0deg); } + 100% { -webkit-transform: rotate(360deg); } +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} +.loader-text { + position: fixed; + top: 96%; + font-size: 1.0em ; + left: 7%; +} + + +{% endblock %} + +{% block content %} +{% include "dashboard/network_detail/header.html" with network=network active_tab='network-notifications' %} + + + + {% include "dashboard/network_detail/nav.html" with active_tab='network-notifications' %} + {% get_obj_perms request.user for network as 'user_permission' %} + + {% for message in messages %} + + × + {{ message }} + + {% endfor %} + {% crispy search %} + + + {% csrf_token %} + {% if records > 0 %} + {% if current_lang %} + {% endif %} + {% render_table notification_table %} + {% if 'edit_notification' in user_permission %} + + Action(s) + + + + Delete + + Edit + + + {% endif %} + + {% else %} + No notifications in the current network + {% endif %} + + + + + + + + + + × + + + Notification + + + + + + {% csrf_token %} + {% crispy notification %} + + + + Connection Error! + + + + + + + + + + + + + × + + + Confirmation + + + + + Are you sure you want to delete? + + This action will delete event and all its translations! + + + + + + + + + + + + × + + + + Message + + + + + {% for key, language in languages.items %} + + {{ language }} + + + + + {% endfor %} + + + + + +{% include "dashboard/subscriber_detail/broadcast.html" with target='network' %} +{% endblock %} +{% block js %} + +{% endblock %} diff --git a/cloud/endagaweb/urls.py b/cloud/endagaweb/urls.py index d2b8d10c..9273cf8d 100644 --- a/cloud/endagaweb/urls.py +++ b/cloud/endagaweb/urls.py @@ -145,6 +145,9 @@ url(r'^dashboard/network/denominations$', endagaweb.views.network.NetworkDenomination.as_view(), name='network-denominations'), + url(r'^dashboard/network/denominations/manage$', + endagaweb.views.network.NetworkDenominationEdit.as_view(), + name='network-denominations-manage'), url(r'^dashboard/network/inactive-subscribers$', endagaweb.views.network.NetworkInactiveSubscribers.as_view(), name='network-inactive-subscribers'), @@ -153,6 +156,13 @@ name='network-edit'), url(r'^dashboard/network/select/(?P[0-9]+)$', endagaweb.views.network.NetworkSelectView.as_view()), + # Notifications + url(r'^dashboard/network/notification$', + endagaweb.views.network.NetworkNotifications.as_view(), + name='network-notifications'), + url(r'^dashboard/network/notification/update', + endagaweb.views.network.NetworkNotificationsEdit.as_view(), + name='network-notifications-manage'), # The activity table. url(r'^dashboard/activity', endagaweb.views.dashboard.ActivityView.as_view(), diff --git a/cloud/endagaweb/views/django_tables.py b/cloud/endagaweb/views/django_tables.py index fe8daf18..0cefca7b 100644 --- a/cloud/endagaweb/views/django_tables.py +++ b/cloud/endagaweb/views/django_tables.py @@ -20,6 +20,7 @@ from ccm.common.currency import humanize_credits, CURRENCIES from endagaweb import models +from googletrans.constants import LANGUAGES def render_user_profile(record): @@ -365,3 +366,58 @@ def render_action(self, record): element += "Delete" % (record.id) return safestring.mark_safe(element) + +class NotificationTable(tables.Table): + """ + Notification table for managing notification messages + """ + + class Meta: + model = models.Notification + fields = ('id', 'event', 'message', 'type') + attrs = {'class': 'table'} + + id = tables.CheckBoxColumn(accessor="pk", + attrs={"th__input": + {"onclick": "toggle(this)"} + } + ) + type = tables.Column() + event = tables.Column(orderable=False) + message = tables.Column(orderable=False) + + def render_event(self, record): + event = str(record.event).replace('_', ' ').upper() + element = "%s " % (record.id, + event) + return safestring.mark_safe(element) + + +class NotificationTableTranslated(tables.Table): + """ + Notification table specific language message + """ + + class Meta: + model = models.Notification + fields = ('id', 'event', 'translation', 'type') + attrs = {'class': 'table'} + + id = tables.CheckBoxColumn(accessor="pk", + attrs={"th__input": + {"onclick": "toggle(this)"} + } + ) + type = tables.Column() + event = tables.Column(orderable=False) + translation = tables.Column(orderable=False, verbose_name='Message') + + def render_event(self, record): + event = str(record.event).replace('_', ' ').upper() + element = "%s " % (record.id, + event) + return safestring.mark_safe(element) diff --git a/cloud/endagaweb/views/network.py b/cloud/endagaweb/views/network.py index 318335c4..7315e441 100644 --- a/cloud/endagaweb/views/network.py +++ b/cloud/endagaweb/views/network.py @@ -9,26 +9,30 @@ """ import datetime -import time import json +import time +import django_tables2 as tables from django import http from django import template +from django.conf import settings from django.contrib import messages +from django.core import exceptions from django.core import urlresolvers -from django.db import transaction +from django.db import transaction, IntegrityError from django.shortcuts import redirect -import django_tables2 as tables +from django.template.loader import get_template +from googletrans.constants import LANGUAGES from guardian.shortcuts import get_objects_for_user -from django.conf import settings from ccm.common.currency import parse_credits, humanize_credits, \ CURRENCIES, DEFAULT_CURRENCY from endagaweb import models from endagaweb.forms import dashboard_forms -from endagaweb.views.dashboard import ProtectedView +from endagaweb.forms import dashboard_forms as dform +from endagaweb.util import api from endagaweb.views import django_tables - +from endagaweb.views.dashboard import ProtectedView NUMBER_COUNTRIES = { 'US': 'United States (+1)', @@ -41,6 +45,7 @@ class NetworkInfo(ProtectedView): """View info on a single network.""" + permission_required = 'view_network' def get(self, request): """Handles GET requests.""" @@ -111,6 +116,7 @@ def get(self, request): class NetworkInactiveSubscribers(ProtectedView): """Edit settings for expiring inactive subs.""" + permission_required = 'edit_network' def get(self, request): """Handles GET requests.""" @@ -145,6 +151,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, @@ -169,22 +176,27 @@ 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')) class NetworkPrices(ProtectedView): """View pricing for a single network.""" + permission_required = ['view_network', 'edit_network'] def get(self, request): """Handles GET requests.""" @@ -356,6 +368,7 @@ def parse_subscriber_cost(self, string, currency=DEFAULT_CURRENCY): class NetworkEdit(ProtectedView): """Edit basic network info (but not prices).""" + permission_required = 'edit_network' def get(self, request): """Handles GET requests.""" @@ -441,11 +454,14 @@ class NetworkSelectView(ProtectedView): network. They must have view_network permission on the instance for this to work. """ + permission_required = 'view_network' def get(self, request, network_id): user_profile = models.UserProfile.objects.get(user=request.user) try: network = models.Network.objects.get(pk=network_id) + if 'sync_status' in request.session: + del request.session['sync_status'] except models.Network.DoesNotExist: return http.HttpResponseBadRequest() @@ -457,8 +473,33 @@ def get(self, request, network_id): return http.HttpResponseRedirect(request.META.get('HTTP_REFERER', '/dashboard')) +def sync_denomination(network_id, status): + """ Rebase denomination table remove pending changes. """ + if status == 'apply': + with transaction.atomic(): + models.NetworkDenomination.objects.filter( + network=network_id, + status__in=['pending']).update(status='done') + deleted_denom = models.NetworkDenomination.objects.filter( + status__in=['deleted']) + for denomination in deleted_denom: + denomination.delete() + if status == 'discard': + with transaction.atomic(): + new_denom = models.NetworkDenomination.objects.filter( + status__in=['pending']) + for denomination in new_denom: + denomination.delete() + deleted_denom = models.NetworkDenomination.objects.filter( + status__in=['deleted']) + for denomination in deleted_denom: + denomination.status = 'done' + denomination.save() + + class NetworkDenomination(ProtectedView): """Assign denominations bracket for recharge/adjust-credit in network.""" + permission_required = 'view_denomination' def get(self, request): """Handles GET requests.""" @@ -466,8 +507,16 @@ def get(self, request): network = user_profile.network currency = network.subscriber_currency + sync_status = False + if 'sync_status' in request.session: + sync_status = request.session['sync_status'] + else: + sync_denomination(network.id, 'discard') + request.session['sync_status'] = sync_status + # Count the associated denomination with selected network. - denom = models.NetworkDenomination.objects.filter(network=network) + denom = models.NetworkDenomination.objects.filter( + network=network, status__in=['done', 'pending']) denom_count = denom.count() dnm_id = request.GET.get('id', None) @@ -490,18 +539,25 @@ def get(self, request): return http.HttpResponse(json.dumps(response), content_type="application/json") - # Configure the table of denominations. Do not show any pagination - # controls if the total number of donominations is small. - if not user_profile.user.is_staff: - denom_table = django_tables.DenominationListTable(list(denom)) - else: - denom_table = django_tables.DenominationTable(list(denom)) + invalid_ranges = [] + max_denominations = 100000 + for denomination in denom: + if denomination.start_amount > (max_denominations): + start_range = humanize_credits((max_denominations), + CURRENCIES[currency]).money_str() + end_range = humanize_credits((denomination.start_amount), + CURRENCIES[currency]).money_str() + invalid_ranges.append({"start": start_range, + "end": end_range}) + max_denominations = denomination.end_amount + next_start_amount = humanize_credits(max_denominations, + CURRENCIES[currency]).amount + denom_table = django_tables.DenominationTable(list(denom)) towers_per_page = 8 paginate = False if denom > towers_per_page: paginate = {'per_page': towers_per_page} tables.RequestConfig(request, paginate=paginate).configure(denom_table) - # Set the context with various stats. context = { 'networks': get_objects_for_user(request.user, 'view_network', @@ -512,6 +568,9 @@ def get(self, request): 'number_country': NUMBER_COUNTRIES[network.number_country], 'denomination': denom_count, 'denominations_table': denom_table, + 'invalid_ranges': invalid_ranges, + 'next_start_amount': next_start_amount, + 'sync_status': sync_status } # Render template. info_template = template.loader.get_template( @@ -519,6 +578,11 @@ def get(self, request): html = info_template.render(context, request) return http.HttpResponse(html) + +class NetworkDenominationEdit(ProtectedView): + + permission_required = ['view_denomination', 'edit_denomination'] + def post(self, request): """Operators can use this API to add denomination to a network. @@ -528,6 +592,16 @@ def post(self, request): user_profile = models.UserProfile.objects.get(user=request.user) network = user_profile.network try: + sync = request.GET.get('sync', False) + if sync: + sync_denomination(network.id, 'apply') + request.session['sync_status'] = False + messages.success( + request, 'New denomination changes applied successfully.', + extra_tags='alert alert-success') + return http.HttpResponse(json.dumps({'status': 'ok'}), + content_type="application/json") + currency = network.subscriber_currency start_amount_raw = request.POST.get('start_amount') start_amount = parse_credits(start_amount_raw, @@ -546,9 +620,9 @@ def post(self, request): request, message, extra_tags='alert alert-danger') return redirect(urlresolvers.reverse('network-denominations')) - elif start_amount <= 0 or end_amount <= 0: + elif start_amount < 1 or end_amount <= 1: messages.error(request, - 'Enter value >0 for start/end amount.', + 'Enter value >= 1 for start amount.', extra_tags='alert alert-danger') return redirect(urlresolvers.reverse('network-denominations')) elif validity_days <= 0: @@ -567,13 +641,14 @@ def post(self, request): if dnm_id > 0: try: denom = models.NetworkDenomination.objects.get( - id=dnm_id) + id=dnm_id, status__in=['done', 'pending']) # Check for existing denomination range exist. denom_exists = \ models.NetworkDenomination.objects.filter( - end_amount__gte=start_amount, - start_amount__lte=end_amount, - network=user_profile.network).exclude( + end_amount__gte=start_amount+1, + start_amount__lte=end_amount-1, + network=user_profile.network, + status__in=['done', 'pending']).exclude( id=dnm_id).count() if denom_exists: messages.error( @@ -581,11 +656,18 @@ def post(self, request): extra_tags='alert alert-danger') return redirect( urlresolvers.reverse('network-denominations')) - denom.network = user_profile.network - denom.start_amount = start_amount - denom.end_amount = end_amount - denom.validity_days = validity_days + denom.status = 'deleted' denom.save() + # Create new denomination for updated record + new_denom = models.NetworkDenomination( + network=user_profile.network) + new_denom.network = user_profile.network + new_denom.start_amount = start_amount + new_denom.end_amount = end_amount + new_denom.validity_days = validity_days + new_denom.status = 'pending' + new_denom.save() + request.session['sync_status'] = True messages.success( request, 'Denomination is updated successfully.', extra_tags='alert alert-success') @@ -598,9 +680,10 @@ def post(self, request): else: # Check for existing denomination range exist. denom_exists = models.NetworkDenomination.objects.filter( - end_amount__gte=start_amount, - start_amount__lte=end_amount, - network=user_profile.network).count() + end_amount__gte=start_amount+1, + start_amount__lte=end_amount-1, + network=user_profile.network, + status__in=['done', 'pending']).count() if denom_exists: messages.error( request, 'Denomination range already exists.', @@ -614,7 +697,9 @@ def post(self, request): denom.start_amount = start_amount denom.end_amount = end_amount denom.validity_days = validity_days + denom.status = 'pending' denom.save() + request.session['sync_status'] = True messages.success( request, 'Denomination is created successfully.', extra_tags='alert alert-success') @@ -626,28 +711,291 @@ def post(self, request): return redirect(urlresolvers.reverse('network-denominations')) def delete(self, request): - """Handles delete requests.""" + """soft delete denominations, this can be commit/rollback by + sync_denomination() as per request.""" response = { 'status': 'ok', 'messages': [], } - dnm_id = request.GET.get('id') or False - if dnm_id: + dnm_ids = request.GET.getlist('ids[]') or False + if dnm_ids: try: - denom = models.NetworkDenomination.objects.get(id=dnm_id) - denom.delete() + models.NetworkDenomination.objects.filter( + id__in=dnm_ids).update(status='deleted') + request.session['sync_status'] = True response['status'] = 'success' messages.success(request, 'Denomination deleted successfully.', extra_tags='alert alert-success') except models.NetworkDenomination.DoesNotExist: response['status'] = 'failed' - messages.error( - request, 'Invalid denomination ID.', + messages.error( request, 'Invalid denomination ID.', extra_tags='alert alert-danger') else: response['status'] = 'failed' - messages.error( - request, 'Invalid request data.', + messages.error(request, 'Invalid request data.', extra_tags='alert alert-danger') return http.HttpResponse(json.dumps(response), content_type="application/json") + + +class NetworkBalanceLimit(ProtectedView): + """Edit basic network info (to add credit to Network).""" + permission_required = ['edit_network', 'view_network'] + + def get(self, request): + """Handles GET requests.""" + user_profile = models.UserProfile.objects.get(user=request.user) + network = user_profile.network + # Set the context with various stats. + currency = network.subscriber_currency + context = { + 'networks': get_objects_for_user(request.user, 'view_network', + klass=models.Network), + 'user_profile': user_profile, + 'network': network, + 'currency': CURRENCIES[network.subscriber_currency], + 'network_balance_limit_form': dashboard_forms.NetworkBalanceLimit({ + 'max_balance': '', + 'max_unsuccessful_transaction': '', + + }), + } + # Render template. + edit_template = template.loader.get_template( + 'dashboard/network_detail/network-balancelimit.html') + html = edit_template.render(context, request) + return http.HttpResponse(html) + + def post(self, request): + """Handles POST requests.""" + user_profile = models.UserProfile.objects.get(user=request.user) + network = user_profile.network + success = [] + if 'max_balance' not in request.POST: + return http.HttpResponseBadRequest() + if 'max_unsuccessful_transaction' not in request.POST: + return http.HttpResponseBadRequest() + try: + form = dform.NetworkBalanceLimit(data=request.POST) + if form.is_valid(): + cleaned_field_data = form.clean_network_balance() + max_balance = cleaned_field_data.get("max_balance") + max_failure_transaction = cleaned_field_data.get("max_unsuccessful_transaction") + with transaction.atomic(): + try: + currency = network.subscriber_currency + if max_balance: + balance = float(max_balance) + max_network_amount = parse_credits(balance, + CURRENCIES[ + currency]).amount_raw + network.max_balance = max_network_amount + success.append( + 'Network maximum balance limit updated.') + if max_failure_transaction: + transaction_val = int(max_failure_transaction) + network.max_failure_transaction = transaction_val + success.append( + 'Network maximun permissible unsuccessful' \ + ' transactions limit updated.') + network.save() + except ValueError: + error_text = 'Error : please provide valid value.' + messages.error(request, error_text, + extra_tags="alert alert-danger") + return redirect( + urlresolvers.reverse('network_balance_limit')) + messages.success(request, + ''.join(success), + extra_tags="alert alert-success") + return redirect(urlresolvers.reverse('network_balance_limit')) + except exceptions.ValidationError as e: + tags = 'password alert alert-danger' + messages.error(request, ''.join(e.messages), extra_tags=tags) + return redirect(urlresolvers.reverse('network_balance_limit')) + + +class NetworkNotifications(ProtectedView): + """View event notifications for current network. """ + + permission_required = 'view_notification' + + def get(self, request): + """Handles GET requests. + Show event-notification listing page""" + user_profile = models.UserProfile.objects.get(user=request.user) + network = user_profile.network + notifications = models.Notification.objects.filter(network=network) + notification_id = request.GET.get('id', None) + number = event = None + languages = {} + if notification_id: + response = { + 'status': 'ok', + 'messages': [], + 'data': {} + } + notification = models.Notification.objects.get(id=notification_id) + try: + number = int(notification.event) + except ValueError: + event = str(notification.event).replace('_', ' ').upper() + notifications = models.Notification.objects.filter( + event=notification.event) + translations = {} + for notif in notifications: + translations[notif.language] = notif.translation + notification_data = { + 'id': notification.id, + 'event': event, + 'number': number, + 'message': notification.message, + 'protected': notification.protected, + 'translations': translations, + 'type': notification.type, + } + response["data"] = notification_data + return http.HttpResponse(json.dumps(response), + content_type="application/json") + # Set the response context. + langs = notifications.values_list('language', flat=True).distinct() + for lg in langs: + languages[lg] = str(LANGUAGES[lg]).capitalize() + query = request.GET.get('query', None) + language = request.GET.get('language', None) + notifications = notifications.distinct('event') + l_notifications = q_notifications = None + notification_table = django_tables.NotificationTable( + list(notifications)) + if query and len(query) > 0: + q_notifications = (notifications.filter(event__icontains=str( + query).replace(' ', '_')) | + notifications.filter(type__icontains=query) | + notifications.filter( + translation__icontains=query) | + notifications.filter(message__icontains=query)) + notification_table = django_tables.NotificationTable( + list(q_notifications)) + if language and len(language) > 0: + l_notifications = notifications.filter( + language=language) + notification_table = django_tables.NotificationTableTranslated( + list(l_notifications)) + if q_notifications and l_notifications: + notifications = q_notifications.filter( + language=language) + notification_table = django_tables.NotificationTableTranslated( + list(notifications)) + tables.RequestConfig(request, paginate={'per_page': 10}).configure( + notification_table) + # default page language + if not language: + language = 'en' + context = { + 'networks': get_objects_for_user(request.user, 'view_network', + klass=models.Network), + 'user_profile': user_profile, + 'notification': dashboard_forms.NotificationForm( + language=languages, + initial={'type': 'automatic'},), + 'notification_table': notification_table, + 'records': len(list(notifications)), + 'languages': languages, + 'network': network, + 'search': dform.NotificationSearchForm({'query': query, + 'language': language}), + } + # Render template. + template = get_template('dashboard/network_detail/notifications.html') + html = template.render(context, request) + return http.HttpResponse(html) + + +class NetworkNotificationsEdit(ProtectedView): + + permission_required = ['edit_notification', 'view_notification'] + + def post(self, request): + """Handles POST requests. + CRUD operations for notifications. + """ + delete_notification = request.POST.getlist('id') or None + if delete_notification is None: + # Create/Edit the notifications + user_profile = models.UserProfile.objects.get(user=request.user) + network = user_profile.network + type = request.POST.get('type') + event = request.POST.get('event') + message = request.POST.get('message') + number = request.POST.get('number') + pk = request.POST.get('pk') + if event: + try: + int(event) + alert_message = 'Mapped events cannot be numeric only!' + messages.error(request, alert_message, + extra_tags="alert alert-danger") + return redirect(urlresolvers.reverse( + 'network-notifications')) + except ValueError: + # to use event as key on client + event = str(event).lower().strip().replace(' ', '_') + if number: + # Format number to 3 digits + event = str(number) + if int(number) < 10: + event = '00' + event + elif int(number) < 100: + event = '0' + event + if int(pk) != 0: + # Check for existing notification and update + notification = models.Notification.objects.get(id=pk) + all_notifications = models.Notification.objects.filter( + event=notification.event) + for msg in all_notifications: + msg.type = type + if message: + msg.message = message + msg.translation = request.POST.get('lang_' + msg.language) + if not msg.protected: + msg.event = event + msg.save() + resp = 'Updated Successfully!' + else: + try: + if not models.Notification.objects.filter( + event=event, + language__in=settings.BTS_LANGUAGES, + network=network).exists(): + # Create new notifications + languages = settings.BTS_LANGUAGES + with transaction.atomic(): + for language in languages: + translation = request.POST.get( + 'lang_' + language) + notification =\ + models.Notification.objects.create( + network=network, language=language) + notification.type = type + notification.message = message + notification.translation = translation + notification.event = event + notification.save() + resp = 'Added Successfully!' + messages.success(request, resp) + return redirect( + urlresolvers.reverse('network-notifications')) + except IntegrityError: + resp = 'Notification Already Exists!' + messages.warning(request, resp) + return redirect( + urlresolvers.reverse('network-notifications')) + else: + # Delete notifications + notifications = models.Notification.objects.filter( + id__in=delete_notification) + events = notifications.values_list('event', flat=True).distinct() + models.Notification.objects.filter(event__in=events).delete() + resp = 'Selected notification(s) deleted successfully.' + messages.success(request, resp) + return redirect(urlresolvers.reverse('network-notifications')) diff --git a/cloud/requirements.txt b/cloud/requirements.txt index 91b9b67a..43bd489f 100644 --- a/cloud/requirements.txt +++ b/cloud/requirements.txt @@ -47,3 +47,4 @@ tornado==3.2.2 uWSGI==2.0.12 urllib3[secure]>=1.18.1 xlrd==0.9.3 +googletrans==2.2.0 diff --git a/cloud/settings.py b/cloud/settings.py index 76b6fe28..34a20992 100644 --- a/cloud/settings.py +++ b/cloud/settings.py @@ -61,3 +61,7 @@ # Python dotted path to the WSGI application used by Django's runserver. WSGI_APPLICATION = 'endagaweb.wsgi.application' + +# BTS Notification Languages for translation. +#TODO(sharma-sagar): currently supported for below languages only. +BTS_LANGUAGES = ['id', 'tl', 'en']
No notifications in the current network
This action will delete event and all its translations!