Merge branch 'philip/the-great-bump' into 'master'
The great bump See merge request grassrootseconomics/cic-internal-integration!239
This commit is contained in:
commit
fad0a4b580
@ -1,25 +0,0 @@
|
||||
[app]
|
||||
ALLOWED_IP=0.0.0.0/0
|
||||
LOCALE_FALLBACK=sw
|
||||
LOCALE_PATH=var/lib/locale/
|
||||
MAX_BODY_LENGTH=1024
|
||||
PASSWORD_PEPPER=QYbzKff6NhiQzY3ygl2BkiKOpER8RE/Upqs/5aZWW+I=
|
||||
SERVICE_CODE=*483*46#,*483*061#,*384*96#
|
||||
SUPPORT_PHONE_NUMBER=0757628885
|
||||
|
||||
[phone_number]
|
||||
REGION=KE
|
||||
|
||||
[ussd]
|
||||
MENU_FILE=data/ussd_menu.json
|
||||
user =
|
||||
pass =
|
||||
|
||||
[statemachine]
|
||||
STATES=states/
|
||||
TRANSITIONS=transitions/
|
||||
|
||||
[client]
|
||||
host =
|
||||
port =
|
||||
ssl =
|
@ -1,10 +0,0 @@
|
||||
[database]
|
||||
NAME=cic_ussd
|
||||
USER=postgres
|
||||
PASSWORD=
|
||||
HOST=localhost
|
||||
PORT=5432
|
||||
ENGINE=postgresql
|
||||
DRIVER=psycopg2
|
||||
DEBUG=0
|
||||
POOL_SIZE=1
|
@ -1,9 +0,0 @@
|
||||
[celery]
|
||||
BROKER_URL=redis://
|
||||
RESULT_URL=redis://
|
||||
|
||||
[redis]
|
||||
HOSTNAME=redis
|
||||
PASSWORD=
|
||||
PORT=6379
|
||||
DATABASE=0
|
@ -1,15 +0,0 @@
|
||||
[app]
|
||||
ALLOWED_IP=127.0.0.1
|
||||
LOCALE_FALLBACK=en
|
||||
LOCALE_PATH=var/lib/locale/
|
||||
MAX_BODY_LENGTH=1024
|
||||
PASSWORD_PEPPER=QYbzKff6NhiQzY3ygl2BkiKOpER8RE/Upqs/5aZWW+I=
|
||||
SERVICE_CODE=*483*46#
|
||||
SUPPORT_PHONE_NUMBER=0757628885
|
||||
|
||||
[ussd]
|
||||
MENU_FILE=/usr/local/lib/python3.8/site-packages/cic_ussd/db/ussd_menu.json
|
||||
|
||||
[statemachine]
|
||||
STATES=/usr/src/cic-ussd/states/
|
||||
TRANSITIONS=/usr/src/cic-ussd/transitions/
|
@ -1,8 +0,0 @@
|
||||
[database]
|
||||
NAME=cic_ussd_test
|
||||
USER=postgres
|
||||
PASSWORD=
|
||||
HOST=localhost
|
||||
PORT=5432
|
||||
ENGINE=sqlite
|
||||
DRIVER=pysqlite
|
@ -1,5 +0,0 @@
|
||||
[pgp]
|
||||
export_dir = /usr/src/pgp/keys/
|
||||
keys_path = /usr/src/secrets/
|
||||
private_keys = privatekeys_meta.asc
|
||||
passphrase =
|
@ -1,9 +0,0 @@
|
||||
[celery]
|
||||
BROKER_URL = filesystem://
|
||||
RESULT_URL = filesystem://
|
||||
|
||||
[redis]
|
||||
HOSTNAME=localhost
|
||||
PASSWORD=
|
||||
PORT=6379
|
||||
DATABASE=0
|
@ -31,7 +31,7 @@ test-mr-cic-ussd:
|
||||
pip install --extra-index-url https://pip.grassrootseconomics.net:8433
|
||||
--extra-index-url https://gitlab.com/api/v4/projects/27624814/packages/pypi/simple
|
||||
-r test_requirements.txt
|
||||
- export PYTHONPATH=. && pytest -x --cov=cic_eth --cov-fail-under=90 --cov-report term-missing tests
|
||||
- export PYTHONPATH=. && pytest -x --cov=cic_ussd --cov-fail-under=90 --cov-report term-missing tests/cic_ussd
|
||||
needs: ["build-mr-cic-ussd"]
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
|
@ -1,49 +0,0 @@
|
||||
# standard imports
|
||||
import json
|
||||
|
||||
# third-party imports
|
||||
from cic_eth.api import Api
|
||||
from cic_types.models.person import Person
|
||||
from cic_types.processor import generate_metadata_pointer
|
||||
|
||||
# local imports
|
||||
from cic_ussd.chain import Chain
|
||||
from cic_ussd.db.models.account import Account
|
||||
from cic_ussd.metadata import blockchain_address_to_metadata_pointer
|
||||
from cic_ussd.redis import get_cached_data
|
||||
|
||||
|
||||
def define_account_tx_metadata(user: Account):
|
||||
# get sender metadata
|
||||
identifier = blockchain_address_to_metadata_pointer(
|
||||
blockchain_address=user.blockchain_address
|
||||
)
|
||||
key = generate_metadata_pointer(
|
||||
identifier=identifier,
|
||||
cic_type=':cic.person'
|
||||
)
|
||||
account_metadata = get_cached_data(key=key)
|
||||
|
||||
if account_metadata:
|
||||
account_metadata = json.loads(account_metadata)
|
||||
person = Person()
|
||||
deserialized_person = person.deserialize(person_data=account_metadata)
|
||||
given_name = deserialized_person.given_name
|
||||
family_name = deserialized_person.family_name
|
||||
phone_number = deserialized_person.tel
|
||||
|
||||
return f'{given_name} {family_name} {phone_number}'
|
||||
else:
|
||||
phone_number = user.phone_number
|
||||
return phone_number
|
||||
|
||||
|
||||
def retrieve_account_statement(blockchain_address: str):
|
||||
chain_str = Chain.spec.__str__()
|
||||
cic_eth_api = Api(
|
||||
chain_str=chain_str,
|
||||
callback_queue='cic-ussd',
|
||||
callback_task='cic_ussd.tasks.callback_handler.process_statement_callback',
|
||||
callback_param=blockchain_address
|
||||
)
|
||||
cic_eth_api.list(address=blockchain_address, limit=9)
|
0
apps/cic-ussd/cic_ussd/account/__init__.py
Normal file
0
apps/cic-ussd/cic_ussd/account/__init__.py
Normal file
90
apps/cic-ussd/cic_ussd/account/balance.py
Normal file
90
apps/cic-ussd/cic_ussd/account/balance.py
Normal file
@ -0,0 +1,90 @@
|
||||
# standard imports
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
# third-party imports
|
||||
from cic_eth.api import Api
|
||||
|
||||
# local imports
|
||||
from cic_ussd.account.transaction import from_wei
|
||||
from cic_ussd.cache import cache_data_key, get_cached_data
|
||||
from cic_ussd.error import CachedDataNotFoundError
|
||||
|
||||
logg = logging.getLogger()
|
||||
|
||||
|
||||
def get_balances(address: str,
|
||||
chain_str: str,
|
||||
token_symbol: str,
|
||||
asynchronous: bool = False,
|
||||
callback_param: any = None,
|
||||
callback_queue='cic-ussd',
|
||||
callback_task='cic_ussd.tasks.callback_handler.process_balances_callback') -> Optional[list]:
|
||||
"""This function queries cic-eth for an account's balances, It provides a means to receive the balance either
|
||||
asynchronously or synchronously.. It returns a dictionary containing the network, outgoing and incoming balances.
|
||||
:param address: Ethereum address of an account.
|
||||
:type address: str, 0x-hex
|
||||
:param chain_str: The chain name and network id.
|
||||
:type chain_str: str
|
||||
:param asynchronous: Boolean value checking whether to return balances asynchronously.
|
||||
:type asynchronous: bool
|
||||
:param callback_param: Data to be sent along with the callback containing balance data.
|
||||
:type callback_param: any
|
||||
:param callback_queue:
|
||||
:type callback_queue:
|
||||
:param callback_task: A celery task path to which callback data should be sent.
|
||||
:type callback_task: str
|
||||
:param token_symbol: ERC20 token symbol of the account whose balance is being queried.
|
||||
:type token_symbol: str
|
||||
:return: A list containing balance data if called synchronously. | None
|
||||
:rtype: list | None
|
||||
"""
|
||||
logg.debug(f'retrieving balance for address: {address}')
|
||||
if asynchronous:
|
||||
cic_eth_api = Api(
|
||||
chain_str=chain_str,
|
||||
callback_queue=callback_queue,
|
||||
callback_task=callback_task,
|
||||
callback_param=callback_param
|
||||
)
|
||||
cic_eth_api.balance(address=address, token_symbol=token_symbol)
|
||||
else:
|
||||
cic_eth_api = Api(chain_str=chain_str)
|
||||
balance_request_task = cic_eth_api.balance(
|
||||
address=address,
|
||||
token_symbol=token_symbol)
|
||||
return balance_request_task.get()
|
||||
|
||||
|
||||
def calculate_available_balance(balances: dict) -> float:
|
||||
"""This function calculates an account's balance at a specific point in time by computing the difference from the
|
||||
outgoing balance and the sum of the incoming and network balances.
|
||||
:param balances: incoming, network and outgoing balances.
|
||||
:type balances: dict
|
||||
:return: Token value of the available balance.
|
||||
:rtype: float
|
||||
"""
|
||||
incoming_balance = balances.get('balance_incoming')
|
||||
outgoing_balance = balances.get('balance_outgoing')
|
||||
network_balance = balances.get('balance_network')
|
||||
|
||||
available_balance = (network_balance + incoming_balance) - outgoing_balance
|
||||
return from_wei(value=available_balance)
|
||||
|
||||
|
||||
def get_cached_available_balance(blockchain_address: str) -> float:
|
||||
"""This function attempts to retrieve balance data from the redis cache.
|
||||
:param blockchain_address: Ethereum address of an account.
|
||||
:type blockchain_address: str
|
||||
:raises CachedDataNotFoundError: No cached balance data could be found.
|
||||
:return: Operational balance of an account.
|
||||
:rtype: float
|
||||
"""
|
||||
identifier = bytes.fromhex(blockchain_address[2:])
|
||||
key = cache_data_key(identifier, salt=':cic.balances')
|
||||
cached_balances = get_cached_data(key=key)
|
||||
if cached_balances:
|
||||
return calculate_available_balance(json.loads(cached_balances))
|
||||
else:
|
||||
raise CachedDataNotFoundError(f'No cached available balance for address: {blockchain_address}')
|
20
apps/cic-ussd/cic_ussd/account/maps.py
Normal file
20
apps/cic-ussd/cic_ussd/account/maps.py
Normal file
@ -0,0 +1,20 @@
|
||||
# standard imports
|
||||
|
||||
# external imports
|
||||
|
||||
# local imports
|
||||
|
||||
|
||||
def gender():
|
||||
return {
|
||||
'1': 'male',
|
||||
'2': 'female',
|
||||
'3': 'other'
|
||||
}
|
||||
|
||||
|
||||
def language():
|
||||
return {
|
||||
'1': 'en',
|
||||
'2': 'sw'
|
||||
}
|
44
apps/cic-ussd/cic_ussd/account/metadata.py
Normal file
44
apps/cic-ussd/cic_ussd/account/metadata.py
Normal file
@ -0,0 +1,44 @@
|
||||
# standard imports
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
# external imports
|
||||
from chainlib.hash import strip_0x
|
||||
from cic_types.models.person import Person
|
||||
|
||||
# local imports
|
||||
from cic_ussd.metadata import PreferencesMetadata
|
||||
|
||||
logg = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_cached_preferred_language(blockchain_address: str) -> Optional[str]:
|
||||
"""This function retrieves an account's set preferred language from preferences metadata in redis cache.
|
||||
:param blockchain_address:
|
||||
:type blockchain_address:
|
||||
:return: Account's set preferred language | Fallback preferred language.
|
||||
:rtype: str
|
||||
"""
|
||||
identifier = bytes.fromhex(strip_0x(blockchain_address))
|
||||
preferences_metadata_handler = PreferencesMetadata(identifier)
|
||||
cached_preferences_metadata = preferences_metadata_handler.get_cached_metadata()
|
||||
if cached_preferences_metadata:
|
||||
preferences_metadata = json.loads(cached_preferences_metadata)
|
||||
return preferences_metadata.get('preferred_language')
|
||||
return None
|
||||
|
||||
|
||||
def parse_account_metadata(account_metadata: dict) -> str:
|
||||
"""
|
||||
:param account_metadata:
|
||||
:type account_metadata:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
person = Person()
|
||||
deserialized_person = person.deserialize(person_data=account_metadata)
|
||||
given_name = deserialized_person.given_name
|
||||
family_name = deserialized_person.family_name
|
||||
phone_number = deserialized_person.tel
|
||||
return f'{given_name} {family_name} {phone_number}'
|
111
apps/cic-ussd/cic_ussd/account/statement.py
Normal file
111
apps/cic-ussd/cic_ussd/account/statement.py
Normal file
@ -0,0 +1,111 @@
|
||||
# standard imports
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
# external imports
|
||||
import celery
|
||||
from chainlib.hash import strip_0x
|
||||
from cic_eth.api import Api
|
||||
|
||||
# local import
|
||||
from cic_ussd.account.chain import Chain
|
||||
from cic_ussd.account.transaction import from_wei
|
||||
from cic_ussd.cache import cache_data_key, get_cached_data
|
||||
from cic_ussd.translation import translation_for
|
||||
|
||||
logg = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def filter_statement_transactions(transaction_list: list) -> list:
|
||||
"""This function parses a transaction list and removes all transactions that entail interactions with the
|
||||
zero address as the source transaction.
|
||||
:param transaction_list: Array containing transaction objects.
|
||||
:type transaction_list: list
|
||||
:return: Transactions exclusive of the zero address transactions.
|
||||
:rtype: list
|
||||
"""
|
||||
return [tx for tx in transaction_list if tx.get('source_token') != '0x0000000000000000000000000000000000000000']
|
||||
|
||||
|
||||
def generate(querying_party: str, queue: Optional[str], transaction: dict):
|
||||
"""
|
||||
:param querying_party:
|
||||
:type querying_party:
|
||||
:param queue:
|
||||
:type queue:
|
||||
:param transaction:
|
||||
:type transaction:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
s_generate_statement = celery.signature(
|
||||
'cic_ussd.tasks.processor.generate_statement', [querying_party, transaction], queue=queue
|
||||
)
|
||||
s_generate_statement.apply_async()
|
||||
|
||||
|
||||
def get_cached_statement(blockchain_address: str) -> bytes:
|
||||
"""This function retrieves an account's cached record of a specific number of transactions in chronological order.
|
||||
:param blockchain_address: Bytes representation of the hex value of an account's blockchain address.
|
||||
:type blockchain_address: bytes
|
||||
:return: Account's transactions statements.
|
||||
:rtype: str
|
||||
"""
|
||||
identifier = bytes.fromhex(strip_0x(blockchain_address))
|
||||
key = cache_data_key(identifier=identifier, salt=':cic.statement')
|
||||
return get_cached_data(key=key)
|
||||
|
||||
|
||||
def parse_statement_transactions(statement: list):
|
||||
"""This function extracts information for transaction objects loaded from the redis cache and structures the data in
|
||||
a format that is appropriate for the ussd interface.
|
||||
:param statement: A list of transaction objects.
|
||||
:type statement: list
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
parsed_transactions = []
|
||||
for transaction in statement:
|
||||
action_tag = transaction.get('action_tag')
|
||||
amount = from_wei(transaction.get('token_value'))
|
||||
direction_tag = transaction.get('direction_tag')
|
||||
token_symbol = transaction.get('token_symbol')
|
||||
metadata_id = transaction.get('metadata_id')
|
||||
timestamp = datetime.datetime.now().strftime('%d/%m/%y, %H:%M')
|
||||
transaction_repr = f'{action_tag} {amount} {token_symbol} {direction_tag} {metadata_id} {timestamp}'
|
||||
parsed_transactions.append(transaction_repr)
|
||||
return parsed_transactions
|
||||
|
||||
|
||||
def query_statement(blockchain_address: str, limit: int = 9):
|
||||
"""This function queries cic-eth for a set of chronologically ordered number of transactions associated with
|
||||
an account.
|
||||
:param blockchain_address: Ethereum address associated with an account.
|
||||
:type blockchain_address: str, 0x-hex
|
||||
:param limit: Number of transactions to be returned.
|
||||
:type limit: int
|
||||
"""
|
||||
logg.debug(f'retrieving balance for address: {blockchain_address}')
|
||||
chain_str = Chain.spec.__str__()
|
||||
cic_eth_api = Api(
|
||||
chain_str=chain_str,
|
||||
callback_queue='cic-ussd',
|
||||
callback_task='cic_ussd.tasks.callback_handler.statement_callback',
|
||||
callback_param=blockchain_address
|
||||
)
|
||||
cic_eth_api.list(address=blockchain_address, limit=limit)
|
||||
|
||||
|
||||
def statement_transaction_set(preferred_language: str, transaction_reprs: list):
|
||||
"""
|
||||
:param preferred_language:
|
||||
:type preferred_language:
|
||||
:param transaction_reprs:
|
||||
:type transaction_reprs:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
if not transaction_reprs:
|
||||
return translation_for('helpers.no_transaction_history', preferred_language)
|
||||
return ''.join(f'{transaction_repr}\n' for transaction_repr in transaction_reprs)
|
61
apps/cic-ussd/cic_ussd/account/tokens.py
Normal file
61
apps/cic-ussd/cic_ussd/account/tokens.py
Normal file
@ -0,0 +1,61 @@
|
||||
# standard imports
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
|
||||
# external imports
|
||||
from cic_eth.api import Api
|
||||
|
||||
# local imports
|
||||
from cic_ussd.account.chain import Chain
|
||||
from cic_ussd.cache import cache_data_key, get_cached_data
|
||||
from cic_ussd.error import SeppukuError
|
||||
|
||||
|
||||
logg = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_cached_default_token(chain_str: str) -> Optional[str]:
|
||||
"""This function attempts to retrieve the default token's data from the redis cache.
|
||||
:param chain_str: chain name and network id.
|
||||
:type chain_str: str
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
logg.debug(f'Retrieving default token from cache for chain: {chain_str}')
|
||||
key = cache_data_key(identifier=chain_str.encode('utf-8'), salt=':cic.default_token_data')
|
||||
return get_cached_data(key=key)
|
||||
|
||||
|
||||
def get_default_token_symbol():
|
||||
"""This function attempts to retrieve the default token's symbol from cached default token's data.
|
||||
:raises SeppukuError: The system should terminate itself because the default token is required for an appropriate
|
||||
system state.
|
||||
:return: Default token's symbol.
|
||||
:rtype: str
|
||||
"""
|
||||
chain_str = Chain.spec.__str__()
|
||||
cached_default_token = get_cached_default_token(chain_str)
|
||||
if cached_default_token:
|
||||
default_token_data = json.loads(cached_default_token)
|
||||
return default_token_data.get('symbol')
|
||||
else:
|
||||
logg.warning('Cached default token data not found. Attempting retrieval from default token API')
|
||||
default_token_data = query_default_token(chain_str)
|
||||
if default_token_data:
|
||||
return default_token_data.get('symbol')
|
||||
else:
|
||||
raise SeppukuError(f'Could not retrieve default token for: {chain_str}')
|
||||
|
||||
|
||||
def query_default_token(chain_str: str):
|
||||
"""This function synchronously queries cic-eth for the deployed system's default token.
|
||||
:param chain_str: Chain name and network id.
|
||||
:type chain_str: str
|
||||
:return: Token's data.
|
||||
:rtype: dict
|
||||
"""
|
||||
logg.debug(f'Querying API for default token on chain: {chain_str}')
|
||||
cic_eth_api = Api(chain_str=chain_str)
|
||||
default_token_request_task = cic_eth_api.default_token()
|
||||
return default_token_request_task.get()
|
172
apps/cic-ussd/cic_ussd/account/transaction.py
Normal file
172
apps/cic-ussd/cic_ussd/account/transaction.py
Normal file
@ -0,0 +1,172 @@
|
||||
# standard import
|
||||
import decimal
|
||||
import logging
|
||||
from typing import Dict, Tuple
|
||||
|
||||
# external import
|
||||
from cic_eth.api import Api
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
# local import
|
||||
from cic_ussd.db.models.account import Account
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.error import UnknownUssdRecipient
|
||||
from cic_ussd.translation import translation_for
|
||||
|
||||
logg = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _add_tags(action_tag_key: str, preferred_language: str, direction_tag_key: str, transaction: dict):
|
||||
""" This function adds action and direction tags to a transaction data object.
|
||||
:param action_tag_key: Key mapping to a helper entry in the translation files describing an action.
|
||||
:type action_tag_key: str
|
||||
:param preferred_language: An account's set preferred language.
|
||||
:type preferred_language: str
|
||||
:param direction_tag_key: Key mapping to a helper entry in the translation files describing a transaction's
|
||||
direction relative to the transaction's subject account.
|
||||
:type direction_tag_key: str
|
||||
:param transaction: Parsed transaction data object.
|
||||
:type transaction: dict
|
||||
"""
|
||||
action_tag = translation_for(action_tag_key, preferred_language)
|
||||
direction_tag = translation_for(direction_tag_key, preferred_language)
|
||||
transaction['action_tag'] = action_tag
|
||||
transaction['direction_tag'] = direction_tag
|
||||
|
||||
|
||||
def aux_transaction_data(preferred_language: str, transaction: dict) -> dict:
|
||||
"""This function adds auxiliary data to a transaction object offering contextual information relative to the
|
||||
subject account's role in the transaction.
|
||||
:param preferred_language: An account's set preferred language.
|
||||
:type preferred_language: str
|
||||
:param transaction: Parsed transaction data object.
|
||||
:type transaction: dict
|
||||
:return: Transaction object with contextual data.
|
||||
:rtype: dict
|
||||
"""
|
||||
role = transaction.get('role')
|
||||
if role == 'recipient':
|
||||
_add_tags('helpers.received', preferred_language, 'helpers.from', transaction)
|
||||
if role == 'sender':
|
||||
_add_tags('helpers.sent', preferred_language, 'helpers.to', transaction)
|
||||
return transaction
|
||||
|
||||
|
||||
def from_wei(value: int) -> float:
|
||||
"""This function converts values in Wei to a token in the cic network.
|
||||
:param value: Value in Wei
|
||||
:type value: int
|
||||
:return: SRF equivalent of value in Wei
|
||||
:rtype: float
|
||||
"""
|
||||
value = float(value) / 1e+6
|
||||
return truncate(value=value, decimals=2)
|
||||
|
||||
|
||||
def to_wei(value: int) -> int:
|
||||
"""This functions converts values from a token in the cic network to Wei.
|
||||
:param value: Value in SRF
|
||||
:type value: int
|
||||
:return: Wei equivalent of value in SRF
|
||||
:rtype: int
|
||||
"""
|
||||
return int(value * 1e+6)
|
||||
|
||||
|
||||
def truncate(value: float, decimals: int):
|
||||
"""This function truncates a value to a specified number of decimals places.
|
||||
:param value: The value to be truncated.
|
||||
:type value: float
|
||||
:param decimals: The number of decimals for the value to be truncated to
|
||||
:type decimals: int
|
||||
:return: The truncated value.
|
||||
:rtype: int
|
||||
"""
|
||||
decimal.getcontext().rounding = decimal.ROUND_DOWN
|
||||
contextualized_value = decimal.Decimal(value)
|
||||
return round(contextualized_value, decimals)
|
||||
|
||||
|
||||
def transaction_actors(transaction: dict) -> Tuple[Dict, Dict]:
|
||||
""" This function parses transaction data into a tuple of transaction data objects representative of
|
||||
of the source and destination account's involved in a transaction.
|
||||
:param transaction: Transaction data object.
|
||||
:type transaction: dict
|
||||
:return: Recipient and sender transaction data object
|
||||
:rtype: Tuple[Dict, Dict]
|
||||
"""
|
||||
destination_token_symbol = transaction.get('destination_token_symbol')
|
||||
destination_token_value = transaction.get('destination_token_value') or transaction.get('to_value')
|
||||
recipient_blockchain_address = transaction.get('recipient')
|
||||
sender_blockchain_address = transaction.get('sender')
|
||||
source_token_symbol = transaction.get('source_token_symbol')
|
||||
source_token_value = transaction.get('source_token_value') or transaction.get('from_value')
|
||||
|
||||
recipient_transaction_data = {
|
||||
"token_symbol": destination_token_symbol,
|
||||
"token_value": destination_token_value,
|
||||
"blockchain_address": recipient_blockchain_address,
|
||||
"role": "recipient",
|
||||
}
|
||||
sender_transaction_data = {
|
||||
"blockchain_address": sender_blockchain_address,
|
||||
"token_symbol": source_token_symbol,
|
||||
"token_value": source_token_value,
|
||||
"role": "sender",
|
||||
}
|
||||
return recipient_transaction_data, sender_transaction_data
|
||||
|
||||
|
||||
def validate_transaction_account(session: Session, transaction: dict) -> Account:
|
||||
"""This function checks whether the blockchain address specified in a parsed transaction object resolves to an
|
||||
account object in the ussd system.
|
||||
:param session: Database session object.
|
||||
:type session: Session
|
||||
:param transaction: Parsed transaction data object.
|
||||
:type transaction: dict
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
blockchain_address = transaction.get('blockchain_address')
|
||||
role = transaction.get('role')
|
||||
session = SessionBase.bind_session(session)
|
||||
account = session.query(Account).filter_by(blockchain_address=blockchain_address).first()
|
||||
if not account:
|
||||
if role == 'recipient':
|
||||
raise UnknownUssdRecipient(
|
||||
f'Tx for recipient: {blockchain_address} has no matching account in the system.'
|
||||
)
|
||||
if role == 'sender':
|
||||
logg.warning(f'Tx from sender: {blockchain_address} has no matching account in system.')
|
||||
|
||||
SessionBase.release_session(session)
|
||||
return account
|
||||
|
||||
|
||||
class OutgoingTransaction:
|
||||
|
||||
def __init__(self, chain_str: str, from_address: str, to_address: str):
|
||||
"""
|
||||
:param chain_str: The chain name and network id.
|
||||
:type chain_str: str
|
||||
:param from_address: Ethereum address of the sender
|
||||
:type from_address: str, 0x-hex
|
||||
:param to_address: Ethereum address of the recipient
|
||||
:type to_address: str, 0x-hex
|
||||
"""
|
||||
self.chain_str = chain_str
|
||||
self.cic_eth_api = Api(chain_str=chain_str)
|
||||
self.from_address = from_address
|
||||
self.to_address = to_address
|
||||
|
||||
def transfer(self, amount: int, token_symbol: str):
|
||||
"""This function initiates standard transfers between one account to another
|
||||
:param amount: The amount of tokens to be sent
|
||||
:type amount: int
|
||||
:param token_symbol: ERC20 token symbol of token to send
|
||||
:type token_symbol: str
|
||||
"""
|
||||
self.cic_eth_api.transfer(from_address=self.from_address,
|
||||
to_address=self.to_address,
|
||||
value=to_wei(value=amount),
|
||||
token_symbol=token_symbol)
|
@ -1,92 +0,0 @@
|
||||
# standard imports
|
||||
import json
|
||||
import logging
|
||||
from typing import Union
|
||||
|
||||
# third-party imports
|
||||
import celery
|
||||
from cic_eth.api import Api
|
||||
|
||||
# local imports
|
||||
from cic_ussd.error import CachedDataNotFoundError
|
||||
from cic_ussd.redis import create_cached_data_key, get_cached_data
|
||||
from cic_ussd.conversions import from_wei
|
||||
|
||||
logg = logging.getLogger()
|
||||
|
||||
|
||||
def get_balances(
|
||||
address: str,
|
||||
chain_str: str,
|
||||
token_symbol: str,
|
||||
asynchronous: bool = False,
|
||||
callback_param: any = None,
|
||||
callback_task='cic_ussd.tasks.callback_handler.process_balances_callback') -> Union[celery.Task, dict]:
|
||||
"""
|
||||
This function queries cic-eth for an account's balances, It provides a means to receive the balance either
|
||||
asynchronously or synchronously depending on the provided value for teh asynchronous parameter. It returns a
|
||||
dictionary containing network, outgoing and incoming balances.
|
||||
:param address: Ethereum address of the recipient
|
||||
:type address: str, 0x-hex
|
||||
:param chain_str: The chain name and network id.
|
||||
:type chain_str: str
|
||||
:param callback_param:
|
||||
:type callback_param:
|
||||
:param callback_task:
|
||||
:type callback_task:
|
||||
:param token_symbol: ERC20 token symbol of the account whose balance is being queried.
|
||||
:type token_symbol: str
|
||||
:param asynchronous: Boolean value checking whether to return balances asynchronously.
|
||||
:type asynchronous: bool
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
logg.debug(f'Retrieving balance for address: {address}')
|
||||
if asynchronous:
|
||||
cic_eth_api = Api(
|
||||
chain_str=chain_str,
|
||||
callback_queue='cic-ussd',
|
||||
callback_task=callback_task,
|
||||
callback_param=callback_param
|
||||
)
|
||||
cic_eth_api.balance(address=address, token_symbol=token_symbol)
|
||||
else:
|
||||
cic_eth_api = Api(chain_str=chain_str)
|
||||
balance_request_task = cic_eth_api.balance(
|
||||
address=address,
|
||||
token_symbol=token_symbol)
|
||||
return balance_request_task.get()[0]
|
||||
|
||||
|
||||
def compute_operational_balance(balances: dict) -> float:
|
||||
"""This function calculates the right balance given incoming and outgoing
|
||||
:param balances:
|
||||
:type balances:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
incoming_balance = balances.get('balance_incoming')
|
||||
outgoing_balance = balances.get('balance_outgoing')
|
||||
network_balance = balances.get('balance_network')
|
||||
|
||||
operational_balance = (network_balance + incoming_balance) - outgoing_balance
|
||||
return from_wei(value=operational_balance)
|
||||
|
||||
|
||||
def get_cached_operational_balance(blockchain_address: str):
|
||||
"""
|
||||
:param blockchain_address:
|
||||
:type blockchain_address:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
key = create_cached_data_key(
|
||||
identifier=bytes.fromhex(blockchain_address[2:]),
|
||||
salt=':cic.balances_data'
|
||||
)
|
||||
cached_balance = get_cached_data(key=key)
|
||||
if cached_balance:
|
||||
operational_balance = compute_operational_balance(balances=json.loads(cached_balance))
|
||||
return operational_balance
|
||||
else:
|
||||
raise CachedDataNotFoundError('Cached operational balance not found.')
|
@ -8,8 +8,8 @@ from redis import Redis
|
||||
logg = logging.getLogger()
|
||||
|
||||
|
||||
class InMemoryStore:
|
||||
cache: Redis = None
|
||||
class Cache:
|
||||
store: Redis = None
|
||||
|
||||
|
||||
def cache_data(key: str, data: str):
|
||||
@ -21,9 +21,10 @@ def cache_data(key: str, data: str):
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
cache = InMemoryStore.cache
|
||||
cache = Cache.store
|
||||
cache.set(name=key, value=data)
|
||||
cache.persist(name=key)
|
||||
logg.debug(f'caching: {data} with key: {key}.')
|
||||
|
||||
|
||||
def get_cached_data(key: str):
|
||||
@ -33,11 +34,11 @@ def get_cached_data(key: str):
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
cache = InMemoryStore.cache
|
||||
cache = Cache.store
|
||||
return cache.get(name=key)
|
||||
|
||||
|
||||
def create_cached_data_key(identifier: bytes, salt: str):
|
||||
def cache_data_key(identifier: bytes, salt: str):
|
||||
"""
|
||||
:param identifier:
|
||||
:type identifier:
|
@ -1,41 +0,0 @@
|
||||
# standard imports
|
||||
import decimal
|
||||
|
||||
# third-party imports
|
||||
|
||||
# local imports
|
||||
|
||||
|
||||
def truncate(value: float, decimals: int):
|
||||
"""This function truncates a value to a specified number of decimals places.
|
||||
:param value: The value to be truncated.
|
||||
:type value: float
|
||||
:param decimals: The number of decimals for the value to be truncated to
|
||||
:type decimals: int
|
||||
:return: The truncated value.
|
||||
:rtype: int
|
||||
"""
|
||||
decimal.getcontext().rounding = decimal.ROUND_DOWN
|
||||
contextualized_value = decimal.Decimal(value)
|
||||
return round(contextualized_value, decimals)
|
||||
|
||||
|
||||
def from_wei(value: int) -> float:
|
||||
"""This function converts values in Wei to a token in the cic network.
|
||||
:param value: Value in Wei
|
||||
:type value: int
|
||||
:return: platform's default token equivalent of value in Wei
|
||||
:rtype: float
|
||||
"""
|
||||
value = float(value) / 1e+6
|
||||
return truncate(value=value, decimals=2)
|
||||
|
||||
|
||||
def to_wei(value: int) -> int:
|
||||
"""This functions converts values from a token in the cic network to Wei.
|
||||
:param value: Value in platform's default token
|
||||
:type value: int
|
||||
:return: Wei equivalent of value in platform's default token
|
||||
:rtype: int
|
||||
"""
|
||||
return int(value * 1e+6)
|
@ -26,7 +26,7 @@ def upgrade():
|
||||
sa.Column('msisdn', sa.String(), nullable=False),
|
||||
sa.Column('user_input', sa.String(), nullable=True),
|
||||
sa.Column('state', sa.String(), nullable=False),
|
||||
sa.Column('session_data', postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('data', postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('version', sa.Integer(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
@ -24,7 +24,7 @@ def upgrade():
|
||||
sa.Column('preferred_language', sa.String(), nullable=True),
|
||||
sa.Column('password_hash', sa.String(), nullable=True),
|
||||
sa.Column('failed_pin_attempts', sa.Integer(), nullable=False),
|
||||
sa.Column('account_status', sa.Integer(), nullable=False),
|
||||
sa.Column('status', sa.Integer(), nullable=False),
|
||||
sa.Column('created', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
|
@ -1,11 +1,17 @@
|
||||
# standard imports
|
||||
import json
|
||||
|
||||
# external imports
|
||||
from chainlib.hash import strip_0x
|
||||
from cic_eth.api import Api
|
||||
|
||||
# local imports
|
||||
from cic_ussd.account.metadata import get_cached_preferred_language, parse_account_metadata
|
||||
from cic_ussd.cache import Cache, cache_data_key, get_cached_data
|
||||
from cic_ussd.db.enum import AccountStatus
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.db.models.task_tracker import TaskTracker
|
||||
from cic_ussd.encoder import check_password_hash, create_password_hash
|
||||
|
||||
# third party imports
|
||||
from sqlalchemy import Column, Integer, String
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
@ -21,9 +27,32 @@ class Account(SessionBase):
|
||||
phone_number = Column(String)
|
||||
password_hash = Column(String)
|
||||
failed_pin_attempts = Column(Integer)
|
||||
account_status = Column(Integer)
|
||||
status = Column(Integer)
|
||||
preferred_language = Column(String)
|
||||
|
||||
def __init__(self, blockchain_address, phone_number):
|
||||
self.blockchain_address = blockchain_address
|
||||
self.phone_number = phone_number
|
||||
self.password_hash = None
|
||||
self.failed_pin_attempts = 0
|
||||
self.status = AccountStatus.PENDING.value
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Account: {self.blockchain_address}>'
|
||||
|
||||
def activate_account(self):
|
||||
"""This method is used to reset failed pin attempts and change account status to Active."""
|
||||
self.failed_pin_attempts = 0
|
||||
self.status = AccountStatus.ACTIVE.value
|
||||
|
||||
def create_password(self, password):
|
||||
"""This method takes a password value and hashes the value before assigning it to the corresponding
|
||||
`hashed_password` attribute in the user record.
|
||||
:param password: A password value
|
||||
:type password: str
|
||||
"""
|
||||
self.password_hash = create_password_hash(password)
|
||||
|
||||
@staticmethod
|
||||
def get_by_phone_number(phone_number: str, session: Session):
|
||||
"""Retrieves an account from a phone number.
|
||||
@ -39,23 +68,68 @@ class Account(SessionBase):
|
||||
SessionBase.release_session(session=session)
|
||||
return account
|
||||
|
||||
def __init__(self, blockchain_address, phone_number):
|
||||
self.blockchain_address = blockchain_address
|
||||
self.phone_number = phone_number
|
||||
self.password_hash = None
|
||||
self.failed_pin_attempts = 0
|
||||
self.account_status = AccountStatus.PENDING.value
|
||||
def has_preferred_language(self) -> bool:
|
||||
return get_cached_preferred_language(self.blockchain_address) is not None
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Account: {self.blockchain_address}>'
|
||||
|
||||
def create_password(self, password):
|
||||
"""This method takes a password value and hashes the value before assigning it to the corresponding
|
||||
`hashed_password` attribute in the user record.
|
||||
:param password: A password value
|
||||
:type password: str
|
||||
def has_valid_pin(self, session: Session):
|
||||
"""
|
||||
self.password_hash = create_password_hash(password)
|
||||
:param session:
|
||||
:type session:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
return self.get_status(session) == AccountStatus.ACTIVE.name and self.password_hash is not None
|
||||
|
||||
def pin_is_blocked(self, session: Session) -> bool:
|
||||
"""
|
||||
:param session:
|
||||
:type session:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
return self.failed_pin_attempts == 3 and self.get_status(session) == AccountStatus.LOCKED.name
|
||||
|
||||
def reset_pin(self, session: Session) -> str:
|
||||
"""This function resets the number of failed pin attempts to zero. It places the account in pin reset status
|
||||
enabling users to reset their pins.
|
||||
:param session: Database session object.
|
||||
:type session: Session
|
||||
"""
|
||||
session = SessionBase.bind_session(session=session)
|
||||
self.failed_pin_attempts = 0
|
||||
self.status = AccountStatus.RESET.value
|
||||
session.add(self)
|
||||
session.flush()
|
||||
SessionBase.release_session(session=session)
|
||||
return f'Pin reset successful.'
|
||||
|
||||
def standard_metadata_id(self) -> str:
|
||||
"""This function creates an account's standard metadata identification information that contains an account owner's
|
||||
given name, family name and phone number and defaults to a phone number in the absence of metadata.
|
||||
:return: Standard metadata identification information | e164 formatted phone number.
|
||||
:rtype: str
|
||||
"""
|
||||
identifier = bytes.fromhex(strip_0x(self.blockchain_address))
|
||||
key = cache_data_key(identifier, ':cic.person')
|
||||
account_metadata = get_cached_data(key)
|
||||
if not account_metadata:
|
||||
return self.phone_number
|
||||
account_metadata = json.loads(account_metadata)
|
||||
return parse_account_metadata(account_metadata)
|
||||
|
||||
def get_status(self, session: Session):
|
||||
"""This function handles account status queries, it checks whether an account's failed pin attempts exceed 2 and
|
||||
updates the account status locked, it then returns the account status
|
||||
:return: The account status for a user object
|
||||
:rtype: str
|
||||
"""
|
||||
session = SessionBase.bind_session(session=session)
|
||||
if self.failed_pin_attempts > 2:
|
||||
self.status = AccountStatus.LOCKED.value
|
||||
session.add(self)
|
||||
session.flush()
|
||||
SessionBase.release_session(session=session)
|
||||
return AccountStatus(self.status).name
|
||||
|
||||
def verify_password(self, password):
|
||||
"""This method takes a password value and compares it to the user's corresponding `hashed_password` value to
|
||||
@ -67,33 +141,41 @@ class Account(SessionBase):
|
||||
"""
|
||||
return check_password_hash(password, self.password_hash)
|
||||
|
||||
def reset_account_pin(self):
|
||||
"""This method is used to unlock a user's account."""
|
||||
self.failed_pin_attempts = 0
|
||||
self.account_status = AccountStatus.RESET.value
|
||||
|
||||
def get_account_status(self):
|
||||
"""This method checks whether the account is past the allowed number of failed pin attempts.
|
||||
If so, it changes the accounts status to Locked.
|
||||
:return: The account status for a user object
|
||||
:rtype: str
|
||||
def create(chain_str: str, phone_number: str, session: Session):
|
||||
"""
|
||||
if self.failed_pin_attempts > 2:
|
||||
self.account_status = AccountStatus.LOCKED.value
|
||||
return AccountStatus(self.account_status).name
|
||||
|
||||
def activate_account(self):
|
||||
"""This method is used to reset failed pin attempts and change account status to Active."""
|
||||
self.failed_pin_attempts = 0
|
||||
self.account_status = AccountStatus.ACTIVE.value
|
||||
|
||||
def has_valid_pin(self):
|
||||
"""This method checks whether the user's account status and if a pin hash is present which implies
|
||||
pin validity.
|
||||
:return: The presence of a valid pin and status of the account being active.
|
||||
:rtype: bool
|
||||
:param chain_str:
|
||||
:type chain_str:
|
||||
:param phone_number:
|
||||
:type phone_number:
|
||||
:param session:
|
||||
:type session:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
valid_pin = None
|
||||
if self.get_account_status() == 'ACTIVE' and self.password_hash is not None:
|
||||
valid_pin = True
|
||||
return valid_pin
|
||||
api = Api(callback_task='cic_ussd.tasks.callback_handler.account_creation_callback',
|
||||
callback_queue='cic-ussd',
|
||||
callback_param='',
|
||||
chain_str=chain_str)
|
||||
task_uuid = api.create_account().id
|
||||
TaskTracker.add(session=session, task_uuid=task_uuid)
|
||||
cache_creation_task_uuid(phone_number=phone_number, task_uuid=task_uuid)
|
||||
|
||||
|
||||
def cache_creation_task_uuid(phone_number: str, task_uuid: str):
|
||||
"""This function stores the task id that is returned from a task spawned to create a blockchain account in the redis
|
||||
cache.
|
||||
:param phone_number: The phone number for the user whose account is being created.
|
||||
:type phone_number: str
|
||||
:param task_uuid: A celery task id
|
||||
:type task_uuid: str
|
||||
"""
|
||||
cache = Cache.store
|
||||
account_creation_request_data = {
|
||||
'phone_number': phone_number,
|
||||
'sms_notification_sent': False,
|
||||
'status': 'PENDING',
|
||||
'task_uuid': task_uuid
|
||||
}
|
||||
cache.set(task_uuid, json.dumps(account_creation_request_data))
|
||||
cache.persist(name=task_uuid)
|
||||
|
@ -12,7 +12,7 @@ from sqlalchemy.pool import (
|
||||
QueuePool,
|
||||
AssertionPool,
|
||||
NullPool,
|
||||
)
|
||||
)
|
||||
|
||||
logg = logging.getLogger().getChild(__name__)
|
||||
|
||||
@ -42,14 +42,12 @@ class SessionBase(Model):
|
||||
localsessions = {}
|
||||
"""Contains dictionary of sessions initiated by db model components"""
|
||||
|
||||
|
||||
@staticmethod
|
||||
def create_session():
|
||||
"""Creates a new database session.
|
||||
"""
|
||||
return SessionBase.sessionmaker()
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _set_engine(engine):
|
||||
"""Sets the database engine static property
|
||||
@ -57,7 +55,6 @@ class SessionBase(Model):
|
||||
SessionBase.engine = engine
|
||||
SessionBase.sessionmaker = sessionmaker(bind=SessionBase.engine)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def connect(dsn, pool_size=16, debug=False):
|
||||
"""Create new database connection engine and connect to database backend.
|
||||
@ -72,7 +69,7 @@ class SessionBase(Model):
|
||||
logg.info('db using queue pool')
|
||||
e = create_engine(
|
||||
dsn,
|
||||
max_overflow=pool_size*3,
|
||||
max_overflow=pool_size * 3,
|
||||
pool_pre_ping=True,
|
||||
pool_size=pool_size,
|
||||
pool_recycle=60,
|
||||
@ -100,7 +97,6 @@ class SessionBase(Model):
|
||||
|
||||
SessionBase._set_engine(e)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def disconnect():
|
||||
"""Disconnect from database and free resources.
|
||||
@ -108,18 +104,16 @@ class SessionBase(Model):
|
||||
SessionBase.engine.dispose()
|
||||
SessionBase.engine = None
|
||||
|
||||
|
||||
@staticmethod
|
||||
def bind_session(session=None):
|
||||
localsession = session
|
||||
if localsession == None:
|
||||
if localsession is None:
|
||||
localsession = SessionBase.create_session()
|
||||
localsession_key = str(id(localsession))
|
||||
logg.debug('creating new session {}'.format(localsession_key))
|
||||
SessionBase.localsessions[localsession_key] = localsession
|
||||
return localsession
|
||||
|
||||
|
||||
@staticmethod
|
||||
def release_session(session=None):
|
||||
session_key = str(id(session))
|
||||
|
@ -3,6 +3,7 @@ import logging
|
||||
|
||||
# third-party imports
|
||||
from sqlalchemy import Column, String
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
@ -17,3 +18,17 @@ class TaskTracker(SessionBase):
|
||||
self.task_uuid = task_uuid
|
||||
|
||||
task_uuid = Column(String, nullable=False)
|
||||
|
||||
@staticmethod
|
||||
def add(session: Session, task_uuid: str):
|
||||
"""This function persists celery tasks uuids to storage.
|
||||
:param session: Database session object.
|
||||
:type session: Session
|
||||
:param task_uuid: The uuid for an initiated task.
|
||||
:type task_uuid: str
|
||||
"""
|
||||
session = SessionBase.bind_session(session=session)
|
||||
task_record = TaskTracker(task_uuid=task_uuid)
|
||||
session.add(task_record)
|
||||
session.flush()
|
||||
SessionBase.release_session(session=session)
|
||||
|
@ -2,9 +2,10 @@
|
||||
import logging
|
||||
|
||||
# third-party imports
|
||||
from sqlalchemy import Column, String, Integer
|
||||
from sqlalchemy import Column, desc, Integer, String
|
||||
from sqlalchemy.dialects.postgresql import JSON
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
@ -16,26 +17,26 @@ logg = logging.getLogger(__name__)
|
||||
class UssdSession(SessionBase):
|
||||
__tablename__ = 'ussd_session'
|
||||
|
||||
data = Column(JSON)
|
||||
external_session_id = Column(String, nullable=False, index=True, unique=True)
|
||||
service_code = Column(String, nullable=False)
|
||||
msisdn = Column(String, nullable=False)
|
||||
user_input = Column(String)
|
||||
service_code = Column(String, nullable=False)
|
||||
state = Column(String, nullable=False)
|
||||
session_data = Column(JSON)
|
||||
user_input = Column(String)
|
||||
version = Column(Integer, nullable=False)
|
||||
|
||||
def set_data(self, key, session, value):
|
||||
if self.session_data is None:
|
||||
self.session_data = {}
|
||||
self.session_data[key] = value
|
||||
if self.data is None:
|
||||
self.data = {}
|
||||
self.data[key] = value
|
||||
|
||||
# https://stackoverflow.com/questions/42559434/updates-to-json-field-dont-persist-to-db
|
||||
flag_modified(self, "session_data")
|
||||
flag_modified(self, "data")
|
||||
session.add(self)
|
||||
|
||||
def get_data(self, key):
|
||||
if self.session_data is not None:
|
||||
return self.session_data.get(key)
|
||||
if self.data is not None:
|
||||
return self.data.get(key)
|
||||
else:
|
||||
return None
|
||||
|
||||
@ -51,9 +52,37 @@ class UssdSession(SessionBase):
|
||||
session.add(self)
|
||||
|
||||
@staticmethod
|
||||
def have_session_for_phone(phone):
|
||||
r = UssdSession.session.query(UssdSession).filter_by(msisdn=phone).first()
|
||||
return r is not None
|
||||
def has_record_for_phone_number(phone_number: str, session: Session):
|
||||
"""
|
||||
:param phone_number:
|
||||
:type phone_number:
|
||||
:param session:
|
||||
:type session:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
session = SessionBase.bind_session(session=session)
|
||||
ussd_session = session.query(UssdSession).filter_by(msisdn=phone_number).first()
|
||||
SessionBase.release_session(session=session)
|
||||
return ussd_session is not None
|
||||
|
||||
@staticmethod
|
||||
def last_ussd_session(phone_number: str, session: Session):
|
||||
"""
|
||||
:param phone_number:
|
||||
:type phone_number:
|
||||
:param session:
|
||||
:type session:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
session = SessionBase.bind_session(session=session)
|
||||
ussd_session = session.query(UssdSession) \
|
||||
.filter_by(msisdn=phone_number) \
|
||||
.order_by(desc(UssdSession.created)) \
|
||||
.first()
|
||||
SessionBase.release_session(session=session)
|
||||
return ussd_session
|
||||
|
||||
def to_json(self):
|
||||
""" This function serializes the in db ussd session object to a JSON object
|
||||
@ -61,11 +90,11 @@ class UssdSession(SessionBase):
|
||||
:rtype: dict
|
||||
"""
|
||||
return {
|
||||
"data": self.data,
|
||||
"external_session_id": self.external_session_id,
|
||||
"service_code": self.service_code,
|
||||
"msisdn": self.msisdn,
|
||||
"user_input": self.user_input,
|
||||
"service_code": self.service_code,
|
||||
"state": self.state,
|
||||
"session_data": self.session_data,
|
||||
"user_input": self.user_input,
|
||||
"version": self.version
|
||||
}
|
||||
|
@ -8,27 +8,27 @@ class SessionNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidFileFormatError(OSError):
|
||||
class InvalidFileFormatError(Exception):
|
||||
"""Raised when the file format is invalid."""
|
||||
pass
|
||||
|
||||
|
||||
class ActionDataNotFoundError(OSError):
|
||||
"""Raised when action data matching a specific task uuid is not found in the redis cache"""
|
||||
class AccountCreationDataNotFound(Exception):
|
||||
"""Raised when account creation data matching a specific task uuid is not found in the redis cache"""
|
||||
pass
|
||||
|
||||
|
||||
class MetadataNotFoundError(OSError):
|
||||
class MetadataNotFoundError(Exception):
|
||||
"""Raised when metadata is expected but not available in cache."""
|
||||
pass
|
||||
|
||||
|
||||
class UnsupportedMethodError(OSError):
|
||||
class UnsupportedMethodError(Exception):
|
||||
"""Raised when the method passed to the make request function is unsupported."""
|
||||
pass
|
||||
|
||||
|
||||
class CachedDataNotFoundError(OSError):
|
||||
class CachedDataNotFoundError(Exception):
|
||||
"""Raised when the method passed to the make request function is unsupported."""
|
||||
pass
|
||||
|
||||
@ -51,3 +51,5 @@ class InitializationError(Exception):
|
||||
class UnknownUssdRecipient(Exception):
|
||||
"""Raised when a recipient of a transaction is not known to the ussd application."""
|
||||
|
||||
|
||||
|
||||
|
0
apps/cic-ussd/cic_ussd/http/__init__.py
Normal file
0
apps/cic-ussd/cic_ussd/http/__init__.py
Normal file
65
apps/cic-ussd/cic_ussd/http/requests.py
Normal file
65
apps/cic-ussd/cic_ussd/http/requests.py
Normal file
@ -0,0 +1,65 @@
|
||||
# standard imports
|
||||
import logging
|
||||
from typing import Optional, Union
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
# external imports
|
||||
import requests
|
||||
from requests.exceptions import HTTPError
|
||||
|
||||
# local imports
|
||||
from cic_ussd.error import UnsupportedMethodError
|
||||
|
||||
logg = logging.getLogger(__file__)
|
||||
|
||||
|
||||
def error_handler(result: requests.Response):
|
||||
""""""
|
||||
status_code = result.status_code
|
||||
|
||||
if 100 <= status_code < 200:
|
||||
raise HTTPError(f'Informational errors: {status_code}, reason: {result.reason}')
|
||||
|
||||
elif 300 <= status_code < 400:
|
||||
raise HTTPError(f'Redirect Issues: {status_code}, reason: {result.reason}')
|
||||
|
||||
elif 400 <= status_code < 500:
|
||||
raise HTTPError(f'Client Error: {status_code}, reason: {result.reason}')
|
||||
|
||||
elif 500 <= status_code < 600:
|
||||
raise HTTPError(f'Server Error: {status_code}, reason: {result.reason}')
|
||||
|
||||
|
||||
def get_query_parameters(env: dict, query_name: Optional[str] = None) -> Union[dict, str]:
|
||||
""""""
|
||||
parsed_url = urlparse(env.get('REQUEST_URI'))
|
||||
params = parse_qs(parsed_url.query)
|
||||
if query_name:
|
||||
return params.get(query_name)[0]
|
||||
return params
|
||||
|
||||
|
||||
def get_request_endpoint(env: dict) -> str:
|
||||
""""""
|
||||
return env.get('PATH_INFO')
|
||||
|
||||
|
||||
def get_request_method(env: dict) -> str:
|
||||
""""""
|
||||
return env.get('REQUEST_METHOD').upper()
|
||||
|
||||
|
||||
def make_request(method: str, url: str, data: any = None, headers: dict = None):
|
||||
""""""
|
||||
if method == 'GET':
|
||||
logg.debug(f'Retrieving data from: {url}')
|
||||
result = requests.get(url=url)
|
||||
elif method == 'POST':
|
||||
logg.debug(f'Posting to: {url} with: {data}')
|
||||
result = requests.post(url=url, data=data, headers=headers)
|
||||
elif method == 'PUT':
|
||||
logg.debug(f'Putting to: {url} with: {data}')
|
||||
result = requests.put(url=url, data=data, headers=headers)
|
||||
else:
|
||||
raise UnsupportedMethodError(f'Unsupported method: {method}')
|
||||
return result
|
26
apps/cic-ussd/cic_ussd/http/responses.py
Normal file
26
apps/cic-ussd/cic_ussd/http/responses.py
Normal file
@ -0,0 +1,26 @@
|
||||
# standard imports
|
||||
import logging
|
||||
from typing import Tuple
|
||||
|
||||
# external imports
|
||||
|
||||
# local imports
|
||||
|
||||
|
||||
def with_content_headers(headers: list, response: str) -> Tuple[bytes, list]:
|
||||
"""This function calculates the length of a http response body and appends the content length to the headers.
|
||||
:param headers: A list of tuples defining headers for responses.
|
||||
:type headers: list
|
||||
:param response: The response to send for an incoming http request
|
||||
:type response: str
|
||||
:return: A tuple containing the response bytes and a list of tuples defining headers
|
||||
:rtype: tuple
|
||||
"""
|
||||
response_bytes = response.encode('utf-8')
|
||||
content_length = len(response_bytes)
|
||||
content_length_header = ('Content-Length', str(content_length))
|
||||
for position, header in enumerate(headers):
|
||||
if 'Content-Length' in header:
|
||||
headers.pop(position)
|
||||
headers.append(content_length_header)
|
||||
return response_bytes, headers
|
87
apps/cic-ussd/cic_ussd/http/routes.py
Normal file
87
apps/cic-ussd/cic_ussd/http/routes.py
Normal file
@ -0,0 +1,87 @@
|
||||
# standard imports
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
# external imports
|
||||
from sqlalchemy import desc
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db.enum import AccountStatus
|
||||
from cic_ussd.db.models.account import Account
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.http.requests import get_query_parameters, get_request_method
|
||||
from cic_ussd.http.responses import with_content_headers
|
||||
|
||||
logg = logging.getLogger(__file__)
|
||||
|
||||
|
||||
def _get_locked_accounts(env: dict, session: Session):
|
||||
offset = 0
|
||||
limit = 100
|
||||
|
||||
locked_accounts_path = r'/accounts/locked/(\d+)?/?(\d+)?'
|
||||
r = re.match(locked_accounts_path, env.get('PATH_INFO'))
|
||||
|
||||
if r:
|
||||
if r.lastindex > 1:
|
||||
offset = r[1]
|
||||
limit = r[2]
|
||||
else:
|
||||
limit = r[1]
|
||||
session = SessionBase.bind_session(session)
|
||||
accounts = session.query(Account.blockchain_address)\
|
||||
.filter(Account.status == AccountStatus.LOCKED.value, Account.failed_pin_attempts >= 3)\
|
||||
.order_by(desc(Account.updated))\
|
||||
.offset(offset)\
|
||||
.limit(limit)\
|
||||
.all()
|
||||
accounts = [blockchain_address for (blockchain_address,) in accounts]
|
||||
SessionBase.release_session(session=session)
|
||||
response = json.dumps(accounts)
|
||||
return response, '200 OK'
|
||||
|
||||
|
||||
def locked_accounts(env: dict, session: Session) -> tuple:
|
||||
"""
|
||||
:param env:
|
||||
:type env:
|
||||
:param session:
|
||||
:type session:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
if get_request_method(env) == 'GET':
|
||||
return _get_locked_accounts(env, session)
|
||||
return '', '405 Play by the rules'
|
||||
|
||||
|
||||
def pin_reset(env: dict, phone_number: str, session: Session):
|
||||
""""""
|
||||
account = session.query(Account).filter_by(phone_number=phone_number).first()
|
||||
if not account:
|
||||
return '', '404 Not found'
|
||||
|
||||
if get_request_method(env) == 'PUT':
|
||||
return account.reset_pin(session), '200 OK'
|
||||
|
||||
if get_request_method(env) == 'GET':
|
||||
status = account.get_status(session)
|
||||
response = {
|
||||
'status': f'{status}'
|
||||
}
|
||||
response = json.dumps(response)
|
||||
return response, '200 OK'
|
||||
|
||||
|
||||
def handle_pin_requests(env, session, errors_headers, start_response):
|
||||
phone_number = get_query_parameters(env=env, query_name='phoneNumber')
|
||||
phone_number = quote_plus(phone_number)
|
||||
response, message = pin_reset(env=env, phone_number=phone_number, session=session)
|
||||
response_bytes, headers = with_content_headers(errors_headers, response)
|
||||
session.commit()
|
||||
session.close()
|
||||
start_response(message, headers)
|
||||
return [response_bytes]
|
@ -65,11 +65,10 @@ class UssdMenu:
|
||||
:rtype: Document.
|
||||
"""
|
||||
menu = UssdMenu.ussd_menu_db.get(UssdMenu.Menu.name == name)
|
||||
if not menu:
|
||||
if menu:
|
||||
return menu
|
||||
logg.error("No USSD Menu with name {}".format(name))
|
||||
return UssdMenu.ussd_menu_db.get(UssdMenu.Menu.name == 'exit_invalid_request')
|
||||
else:
|
||||
return menu
|
||||
|
||||
@staticmethod
|
||||
def set_description(name: str, description: str):
|
||||
|
@ -1,46 +1,10 @@
|
||||
# standard imports
|
||||
|
||||
# third-party imports
|
||||
import requests
|
||||
from chainlib.eth.address import to_checksum
|
||||
from hexathon import (
|
||||
add_0x,
|
||||
strip_0x,
|
||||
)
|
||||
# external imports
|
||||
|
||||
# local imports
|
||||
from cic_ussd.error import UnsupportedMethodError
|
||||
|
||||
|
||||
def make_request(method: str, url: str, data: any = None, headers: dict = None):
|
||||
"""
|
||||
:param method:
|
||||
:type method:
|
||||
:param url:
|
||||
:type url:
|
||||
:param data:
|
||||
:type data:
|
||||
:param headers:
|
||||
:type headers:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
if method == 'GET':
|
||||
result = requests.get(url=url)
|
||||
elif method == 'POST':
|
||||
result = requests.post(url=url, data=data, headers=headers)
|
||||
elif method == 'PUT':
|
||||
result = requests.put(url=url, data=data, headers=headers)
|
||||
else:
|
||||
raise UnsupportedMethodError(f'Unsupported method: {method}')
|
||||
return result
|
||||
|
||||
|
||||
def blockchain_address_to_metadata_pointer(blockchain_address: str):
|
||||
"""
|
||||
:param blockchain_address:
|
||||
:type blockchain_address:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
return bytes.fromhex(strip_0x(blockchain_address))
|
||||
from .base import Metadata
|
||||
from .custom import CustomMetadata
|
||||
from .person import PersonMetadata
|
||||
from .phone import PhonePointerMetadata
|
||||
from .preferences import PreferencesMetadata
|
||||
|
@ -5,17 +5,14 @@ import os
|
||||
from typing import Dict, Union
|
||||
|
||||
# third-part imports
|
||||
import requests
|
||||
from cic_types.models.person import generate_metadata_pointer, Person
|
||||
|
||||
# local imports
|
||||
from cic_ussd.metadata import make_request
|
||||
from cic_ussd.cache import cache_data, get_cached_data
|
||||
from cic_ussd.http.requests import error_handler, make_request
|
||||
from cic_ussd.metadata.signer import Signer
|
||||
from cic_ussd.redis import cache_data
|
||||
from cic_ussd.error import MetadataStoreError
|
||||
|
||||
|
||||
logg = logging.getLogger().getChild(__name__)
|
||||
logg = logging.getLogger(__file__)
|
||||
|
||||
|
||||
class Metadata:
|
||||
@ -27,37 +24,10 @@ class Metadata:
|
||||
base_url = None
|
||||
|
||||
|
||||
def metadata_http_error_handler(result: requests.Response):
|
||||
""" This function handles and appropriately raises errors from http requests interacting with the metadata server.
|
||||
:param result: The response object from a http request.
|
||||
:type result: requests.Response
|
||||
"""
|
||||
status_code = result.status_code
|
||||
|
||||
if 100 <= status_code < 200:
|
||||
raise MetadataStoreError(f'Informational errors: {status_code}, reason: {result.reason}')
|
||||
|
||||
elif 300 <= status_code < 400:
|
||||
raise MetadataStoreError(f'Redirect Issues: {status_code}, reason: {result.reason}')
|
||||
|
||||
elif 400 <= status_code < 500:
|
||||
raise MetadataStoreError(f'Client Error: {status_code}, reason: {result.reason}')
|
||||
|
||||
elif 500 <= status_code < 600:
|
||||
raise MetadataStoreError(f'Server Error: {status_code}, reason: {result.reason}')
|
||||
|
||||
|
||||
class MetadataRequestsHandler(Metadata):
|
||||
|
||||
def __init__(self, cic_type: str, identifier: bytes, engine: str = 'pgp'):
|
||||
"""
|
||||
:param cic_type: The salt value with which to hash a specific metadata identifier.
|
||||
:type cic_type: str
|
||||
:param engine: Encryption used for sending data to the metadata server.
|
||||
:type engine: str
|
||||
:param identifier: A unique element of data in bytes necessary for creating a metadata pointer.
|
||||
:type identifier: bytes
|
||||
"""
|
||||
""""""
|
||||
self.cic_type = cic_type
|
||||
self.engine = engine
|
||||
self.headers = {
|
||||
@ -73,22 +43,16 @@ class MetadataRequestsHandler(Metadata):
|
||||
self.url = os.path.join(self.base_url, self.metadata_pointer)
|
||||
|
||||
def create(self, data: Union[Dict, str]):
|
||||
""" This function is responsible for posting data to the metadata server with a corresponding metadata pointer
|
||||
for storage.
|
||||
:param data: The data to be stored in the metadata server.
|
||||
:type data: dict|str
|
||||
"""
|
||||
""""""
|
||||
data = json.dumps(data)
|
||||
result = make_request(method='POST', url=self.url, data=data, headers=self.headers)
|
||||
metadata_http_error_handler(result=result)
|
||||
|
||||
error_handler(result=result)
|
||||
metadata = result.json()
|
||||
self.edit(data=metadata)
|
||||
return self.edit(data=metadata)
|
||||
|
||||
def edit(self, data: Union[Dict, str]):
|
||||
""" This function is responsible for editing data in the metadata server corresponding to a unique pointer.
|
||||
:param data: The data to be edited in the metadata server.
|
||||
:type data: dict
|
||||
"""
|
||||
""""""
|
||||
cic_meta_signer = Signer()
|
||||
signature = cic_meta_signer.sign_digest(data=data)
|
||||
algorithm = cic_meta_signer.get_operational_key().get('algo')
|
||||
@ -104,42 +68,34 @@ class MetadataRequestsHandler(Metadata):
|
||||
formatted_data = json.dumps(formatted_data)
|
||||
result = make_request(method='PUT', url=self.url, data=formatted_data, headers=self.headers)
|
||||
logg.info(f'signed metadata submission status: {result.status_code}.')
|
||||
metadata_http_error_handler(result=result)
|
||||
error_handler(result=result)
|
||||
try:
|
||||
decoded_identifier = self.identifier.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
decoded_identifier = self.identifier.hex()
|
||||
logg.info(f'identifier: {decoded_identifier}. metadata pointer: {self.metadata_pointer} set to: {data}.')
|
||||
return result
|
||||
|
||||
def query(self):
|
||||
"""
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
# retrieve the metadata
|
||||
""""""
|
||||
result = make_request(method='GET', url=self.url)
|
||||
metadata_http_error_handler(result=result)
|
||||
|
||||
# json serialize retrieved data
|
||||
error_handler(result=result)
|
||||
result_data = result.json()
|
||||
|
||||
# validate result data format
|
||||
if not isinstance(result_data, dict):
|
||||
raise ValueError(f'Invalid result data object: {result_data}.')
|
||||
|
||||
if result.status_code == 200:
|
||||
if self.cic_type == ':cic.person':
|
||||
# validate person metadata
|
||||
person = Person()
|
||||
person_data = person.deserialize(person_data=result_data)
|
||||
|
||||
# format new person data for caching
|
||||
serialized_person_data = person_data.serialize()
|
||||
data = json.dumps(serialized_person_data)
|
||||
else:
|
||||
data = json.dumps(result_data)
|
||||
|
||||
# cache metadata
|
||||
cache_data(key=self.metadata_pointer, data=data)
|
||||
logg.debug(f'caching: {data} with key: {self.metadata_pointer}')
|
||||
return result_data
|
||||
|
||||
def get_cached_metadata(self):
|
||||
""""""
|
||||
key = generate_metadata_pointer(self.identifier, self.cic_type)
|
||||
return get_cached_data(key)
|
||||
|
@ -1,6 +1,6 @@
|
||||
# standard imports
|
||||
|
||||
# third-party imports
|
||||
# external imports
|
||||
|
||||
# local imports
|
||||
from .base import MetadataRequestsHandler
|
||||
|
@ -29,10 +29,8 @@ class Signer:
|
||||
def __init__(self):
|
||||
self.gpg = gnupg.GPG(gnupghome=self.gpg_path)
|
||||
|
||||
# parse key file data
|
||||
key_file = open(self.key_file_path, 'r')
|
||||
with open(self.key_file_path, 'r') as key_file:
|
||||
self.key_data = key_file.read()
|
||||
key_file.close()
|
||||
|
||||
def get_operational_key(self):
|
||||
"""
|
||||
|
@ -21,9 +21,6 @@ class Notifier:
|
||||
:param preferred_language: A notification recipient's preferred language.
|
||||
:type preferred_language: str
|
||||
"""
|
||||
if self.queue is False:
|
||||
notify_api = Api()
|
||||
else:
|
||||
notify_api = Api(queue=self.queue)
|
||||
notify_api = Api() if self.queue is False else Api(queue=self.queue)
|
||||
message = translation_for(key=key, preferred_language=preferred_language, **kwargs)
|
||||
notify_api.sms(recipient=phone_number, message=message)
|
||||
|
@ -1,521 +0,0 @@
|
||||
# standard imports
|
||||
import json
|
||||
import logging
|
||||
|
||||
# third party imports
|
||||
import celery
|
||||
import i18n
|
||||
from cic_eth.api.api_task import Api
|
||||
from sqlalchemy.orm.session import Session
|
||||
from tinydb.table import Document
|
||||
from typing import Optional
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db.models.account import Account
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.db.models.ussd_session import UssdSession
|
||||
from cic_ussd.db.models.task_tracker import TaskTracker
|
||||
from cic_ussd.menu.ussd_menu import UssdMenu
|
||||
from cic_ussd.processor import custom_display_text, process_request, retrieve_most_recent_ussd_session
|
||||
from cic_ussd.redis import InMemoryStore
|
||||
from cic_ussd.session.ussd_session import UssdSession as InMemoryUssdSession
|
||||
from cic_ussd.validator import check_known_user, validate_response_type
|
||||
|
||||
logg = logging.getLogger()
|
||||
|
||||
|
||||
def add_tasks_to_tracker(session, task_uuid: str):
|
||||
"""This function takes tasks spawned over api interfaces and records their creation time for tracking.
|
||||
:param session:
|
||||
:type session:
|
||||
:param task_uuid: The uuid for an initiated task.
|
||||
:type task_uuid: str
|
||||
"""
|
||||
session = SessionBase.bind_session(session=session)
|
||||
task_record = TaskTracker(task_uuid=task_uuid)
|
||||
session.add(task_record)
|
||||
session.flush()
|
||||
SessionBase.release_session(session=session)
|
||||
|
||||
|
||||
def define_response_with_content(headers: list, response: str) -> tuple:
|
||||
"""This function encodes responses to byte form in order to make feasible for uwsgi response formats. It then
|
||||
computes the length of the response and appends the content length to the headers.
|
||||
:param headers: A list of tuples defining headers for responses.
|
||||
:type headers: list
|
||||
:param response: The response to send for an incoming http request
|
||||
:type response: str
|
||||
:return: A tuple containing the response bytes and a list of tuples defining headers
|
||||
:rtype: tuple
|
||||
"""
|
||||
response_bytes = response.encode('utf-8')
|
||||
content_length = len(response_bytes)
|
||||
content_length_header = ('Content-Length', str(content_length))
|
||||
# check for content length defaulted to zero in error headers
|
||||
for position, header in enumerate(headers):
|
||||
if 'Content-Length' in header:
|
||||
headers.pop(position)
|
||||
headers.append(content_length_header)
|
||||
return response_bytes, headers
|
||||
|
||||
|
||||
def create_ussd_session(
|
||||
external_session_id: str,
|
||||
phone: str,
|
||||
service_code: str,
|
||||
user_input: str,
|
||||
current_menu: str,
|
||||
session_data: Optional[dict] = None) -> InMemoryUssdSession:
|
||||
"""
|
||||
Creates a new ussd session
|
||||
:param external_session_id: Session id value provided by AT
|
||||
:type external_session_id: str
|
||||
:param phone: A valid phone number
|
||||
:type phone: str
|
||||
:param service_code: service code passed over request
|
||||
:type service_code AT service code
|
||||
:param user_input: Input from the request
|
||||
:type user_input: str
|
||||
:param current_menu: Menu name that is currently being displayed on the ussd session
|
||||
:type current_menu: str
|
||||
:param session_data: Any additional data that was persisted during the user's interaction with the system.
|
||||
:type session_data: dict.
|
||||
:return: ussd session object
|
||||
:rtype: Session
|
||||
"""
|
||||
session = InMemoryUssdSession(
|
||||
external_session_id=external_session_id,
|
||||
msisdn=phone,
|
||||
user_input=user_input,
|
||||
state=current_menu,
|
||||
service_code=service_code,
|
||||
session_data=session_data
|
||||
)
|
||||
return session
|
||||
|
||||
|
||||
def create_or_update_session(
|
||||
external_session_id: str,
|
||||
phone: str,
|
||||
service_code: str,
|
||||
user_input: str,
|
||||
current_menu: str,
|
||||
session,
|
||||
session_data: Optional[dict] = None) -> InMemoryUssdSession:
|
||||
"""
|
||||
Handles the creation or updating of session as necessary.
|
||||
:param external_session_id: Session id value provided by AT
|
||||
:type external_session_id: str
|
||||
:param phone: A valid phone number
|
||||
:type phone: str
|
||||
:param service_code: service code passed over request
|
||||
:type service_code: AT service code
|
||||
:param user_input: input from the request
|
||||
:type user_input: str
|
||||
:param current_menu: Menu name that is currently being displayed on the ussd session
|
||||
:type current_menu: str
|
||||
:param session:
|
||||
:type session:
|
||||
:param session_data: Any additional data that was persisted during the user's interaction with the system.
|
||||
:type session_data: dict.
|
||||
:return: ussd session object
|
||||
:rtype: InMemoryUssdSession
|
||||
"""
|
||||
session = SessionBase.bind_session(session=session)
|
||||
existing_ussd_session = session.query(UssdSession).filter_by(
|
||||
external_session_id=external_session_id).first()
|
||||
|
||||
if existing_ussd_session:
|
||||
ussd_session = update_ussd_session(
|
||||
ussd_session=existing_ussd_session,
|
||||
current_menu=current_menu,
|
||||
user_input=user_input,
|
||||
session_data=session_data
|
||||
)
|
||||
else:
|
||||
ussd_session = create_ussd_session(
|
||||
external_session_id=external_session_id,
|
||||
phone=phone,
|
||||
service_code=service_code,
|
||||
user_input=user_input,
|
||||
current_menu=current_menu,
|
||||
session_data=session_data
|
||||
)
|
||||
SessionBase.release_session(session=session)
|
||||
return ussd_session
|
||||
|
||||
|
||||
def get_account_status(phone_number, session: Session) -> str:
|
||||
"""Get the status of a user's account.
|
||||
:param phone_number: The phone number to be checked.
|
||||
:type phone_number: str
|
||||
:param session:
|
||||
:type session:
|
||||
:return: The user account status.
|
||||
:rtype: str
|
||||
"""
|
||||
session = SessionBase.bind_session(session=session)
|
||||
account = Account.get_by_phone_number(phone_number=phone_number, session=session)
|
||||
status = account.get_account_status()
|
||||
session.add(account)
|
||||
session.flush()
|
||||
SessionBase.release_session(session=session)
|
||||
|
||||
return status
|
||||
|
||||
|
||||
def get_latest_input(user_input: str) -> str:
|
||||
"""This function gets the last value entered by the user from the collective user input which follows the pattern of
|
||||
asterix (*) separated entries.
|
||||
:param user_input: The data entered by a user.
|
||||
:type user_input: str
|
||||
:return: The last element in the user input value.
|
||||
:rtype: str
|
||||
"""
|
||||
return user_input.split('*')[-1]
|
||||
|
||||
|
||||
def initiate_account_creation_request(chain_str: str,
|
||||
external_session_id: str,
|
||||
phone_number: str,
|
||||
service_code: str,
|
||||
session,
|
||||
user_input: str) -> str:
|
||||
"""This function issues a task to create a blockchain account on cic-eth. It then creates a record of the ussd
|
||||
session corresponding to the creation of the account and returns a response denoting that the user's account is
|
||||
being created.
|
||||
:param chain_str: The chain name and network id.
|
||||
:type chain_str: str
|
||||
:param external_session_id: A unique ID from africastalking.
|
||||
:type external_session_id: str
|
||||
:param phone_number: The phone number for the account to be created.
|
||||
:type phone_number: str
|
||||
:param service_code: The service code dialed.
|
||||
:type service_code: str
|
||||
:param session:
|
||||
:type session:
|
||||
:param user_input: The input entered by the user.
|
||||
:type user_input: str
|
||||
:return: A response denoting that the account is being created.
|
||||
:rtype: str
|
||||
"""
|
||||
# attempt to create a user
|
||||
cic_eth_api = Api(callback_task='cic_ussd.tasks.callback_handler.process_account_creation_callback',
|
||||
callback_queue='cic-ussd',
|
||||
callback_param='',
|
||||
chain_str=chain_str)
|
||||
creation_task_id = cic_eth_api.create_account().id
|
||||
|
||||
# record task initiation time
|
||||
add_tasks_to_tracker(task_uuid=creation_task_id, session=session)
|
||||
|
||||
# cache account creation data
|
||||
cache_account_creation_task_id(phone_number=phone_number, task_id=creation_task_id)
|
||||
|
||||
# find menu to notify user account is being created
|
||||
current_menu = UssdMenu.find_by_name(name='account_creation_prompt')
|
||||
|
||||
# create a ussd session session
|
||||
create_or_update_session(
|
||||
external_session_id=external_session_id,
|
||||
phone=phone_number,
|
||||
service_code=service_code,
|
||||
current_menu=current_menu.get('name'),
|
||||
session=session,
|
||||
user_input=user_input)
|
||||
|
||||
# define response to relay to user
|
||||
response = define_multilingual_responses(
|
||||
key='ussd.kenya.account_creation_prompt', locales=['en', 'sw'], prefix='END')
|
||||
return response
|
||||
|
||||
|
||||
def define_multilingual_responses(key: str, locales: list, prefix: str, **kwargs):
|
||||
"""This function returns responses in multiple languages in the interest of enabling responses in more than one
|
||||
language.
|
||||
:param key: The key to access some text value from the translation files.
|
||||
:type key: str
|
||||
:param locales: A list of the locales to translate the text value to.
|
||||
:type locales: list
|
||||
:param prefix: The prefix for the text value either: (CON|END)
|
||||
:type prefix: str
|
||||
:param kwargs: Other arguments to be passed to the translator
|
||||
:type kwargs: kwargs
|
||||
:return: A string of the text value in multiple languages.
|
||||
:rtype: str
|
||||
"""
|
||||
prefix = prefix.upper()
|
||||
response = f'{prefix} '
|
||||
for locale in locales:
|
||||
response += i18n.t(key=key, locale=locale, **kwargs)
|
||||
response += '\n'
|
||||
return response
|
||||
|
||||
|
||||
def persist_session_to_db_task(external_session_id: str, queue: str):
|
||||
"""
|
||||
This function creates a signature matching the persist session to db task and runs the task asynchronously.
|
||||
:param external_session_id: Session id value provided by AT
|
||||
:type external_session_id: str
|
||||
:param queue: Celery queue on which task should run
|
||||
:type queue: str
|
||||
"""
|
||||
s_persist_session_to_db = celery.signature(
|
||||
'cic_ussd.tasks.ussd_session.persist_session_to_db',
|
||||
[external_session_id]
|
||||
)
|
||||
s_persist_session_to_db.apply_async(queue=queue)
|
||||
|
||||
|
||||
def cache_account_creation_task_id(phone_number: str, task_id: str):
|
||||
"""This function stores the task id that is returned from a task spawned to create a blockchain account in the redis
|
||||
cache.
|
||||
:param phone_number: The phone number for the user whose account is being created.
|
||||
:type phone_number: str
|
||||
:param task_id: A celery task id
|
||||
:type task_id: str
|
||||
"""
|
||||
redis_cache = InMemoryStore.cache
|
||||
account_creation_request_data = {
|
||||
'phone_number': phone_number,
|
||||
'sms_notification_sent': False,
|
||||
'status': 'PENDING',
|
||||
'task_id': task_id,
|
||||
}
|
||||
redis_cache.set(task_id, json.dumps(account_creation_request_data))
|
||||
redis_cache.persist(name=task_id)
|
||||
|
||||
|
||||
def process_current_menu(account: Account, session: Session, ussd_session: Optional[dict], user_input: str) -> Document:
|
||||
"""This function checks user input and returns a corresponding ussd menu
|
||||
:param ussd_session: An in db ussd session object.
|
||||
:type ussd_session: UssdSession
|
||||
:param account: A account object.
|
||||
:type account: Account
|
||||
:param session:
|
||||
:type session:
|
||||
:param user_input: The user's input.
|
||||
:type user_input: str
|
||||
:return: An in memory ussd menu object.
|
||||
:rtype: Document
|
||||
"""
|
||||
# handle invalid inputs
|
||||
if ussd_session and user_input == "":
|
||||
current_menu = UssdMenu.find_by_name(name='exit_invalid_input')
|
||||
else:
|
||||
# get current state
|
||||
latest_input = get_latest_input(user_input=user_input)
|
||||
session = SessionBase.bind_session(session=session)
|
||||
current_menu = process_request(
|
||||
account=account,
|
||||
session=session,
|
||||
ussd_session=ussd_session,
|
||||
user_input=latest_input)
|
||||
SessionBase.release_session(session=session)
|
||||
return current_menu
|
||||
|
||||
|
||||
def process_menu_interaction_requests(chain_str: str,
|
||||
external_session_id: str,
|
||||
phone_number: str,
|
||||
queue: str,
|
||||
service_code: str,
|
||||
session,
|
||||
user_input: str) -> str:
|
||||
"""This function handles requests intended for interaction with ussd menu, it checks whether a user matching the
|
||||
provided phone number exists and in the absence of which it creates an account for the user.
|
||||
In the event that a user exists it processes the request and returns an appropriate response.
|
||||
:param chain_str: The chain name and network id.
|
||||
:type chain_str: str
|
||||
:param external_session_id: Unique session id from AfricasTalking
|
||||
:type external_session_id: str
|
||||
:param phone_number: Phone number of the user making the request.
|
||||
:type phone_number: str
|
||||
:param queue: The celery queue on which to run tasks
|
||||
:type queue: str
|
||||
:param service_code: The service dialed by the user making the request.
|
||||
:type service_code: str
|
||||
:param session:
|
||||
:type session:
|
||||
:param user_input: The inputs entered by the user.
|
||||
:type user_input: str
|
||||
:return: A response based on the request received.
|
||||
:rtype: str
|
||||
"""
|
||||
# check whether the user exists
|
||||
if not check_known_user(phone_number=phone_number, session=session):
|
||||
response = initiate_account_creation_request(chain_str=chain_str,
|
||||
external_session_id=external_session_id,
|
||||
phone_number=phone_number,
|
||||
service_code=service_code,
|
||||
session=session,
|
||||
user_input=user_input)
|
||||
|
||||
else:
|
||||
# get account
|
||||
session = SessionBase.bind_session(session=session)
|
||||
account = Account.get_by_phone_number(phone_number=phone_number, session=session)
|
||||
|
||||
# retrieve and cache user's metadata
|
||||
blockchain_address = account.blockchain_address
|
||||
s_query_person_metadata = celery.signature(
|
||||
'cic_ussd.tasks.metadata.query_person_metadata',
|
||||
[blockchain_address]
|
||||
)
|
||||
s_query_person_metadata.apply_async(queue='cic-ussd')
|
||||
|
||||
# find any existing ussd session
|
||||
existing_ussd_session = session.query(UssdSession).filter_by(external_session_id=external_session_id).first()
|
||||
|
||||
# validate user inputs
|
||||
if existing_ussd_session:
|
||||
current_menu = process_current_menu(
|
||||
account=account,
|
||||
session=session,
|
||||
ussd_session=existing_ussd_session.to_json(),
|
||||
user_input=user_input
|
||||
)
|
||||
else:
|
||||
current_menu = process_current_menu(
|
||||
account=account,
|
||||
session=session,
|
||||
ussd_session=None,
|
||||
user_input=user_input
|
||||
)
|
||||
|
||||
last_ussd_session = retrieve_most_recent_ussd_session(phone_number=account.phone_number, session=session)
|
||||
|
||||
if last_ussd_session:
|
||||
# create or update the ussd session as appropriate
|
||||
ussd_session = create_or_update_session(
|
||||
external_session_id=external_session_id,
|
||||
phone=phone_number,
|
||||
service_code=service_code,
|
||||
user_input=user_input,
|
||||
current_menu=current_menu.get('name'),
|
||||
session=session,
|
||||
session_data=last_ussd_session.session_data
|
||||
)
|
||||
else:
|
||||
ussd_session = create_or_update_session(
|
||||
external_session_id=external_session_id,
|
||||
phone=phone_number,
|
||||
service_code=service_code,
|
||||
user_input=user_input,
|
||||
current_menu=current_menu.get('name'),
|
||||
session=session
|
||||
)
|
||||
|
||||
# define appropriate response
|
||||
response = custom_display_text(
|
||||
account=account,
|
||||
display_key=current_menu.get('display_key'),
|
||||
menu_name=current_menu.get('name'),
|
||||
session=session,
|
||||
ussd_session=ussd_session.to_json(),
|
||||
)
|
||||
|
||||
# check that the response from the processor is valid
|
||||
if not validate_response_type(processor_response=response):
|
||||
raise Exception(f'Invalid response: {response}')
|
||||
|
||||
# persist session to db
|
||||
persist_session_to_db_task(external_session_id=external_session_id, queue=queue)
|
||||
SessionBase.release_session(session=session)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def reset_pin(phone_number: str, session: Session) -> str:
|
||||
"""Reset account status from Locked to Pending.
|
||||
:param phone_number: The phone number belonging to the account to be unlocked.
|
||||
:type phone_number: str
|
||||
:param session:
|
||||
:type session:
|
||||
:return: The status of the pin reset.
|
||||
:rtype: str
|
||||
"""
|
||||
session = SessionBase.bind_session(session=session)
|
||||
account = Account.get_by_phone_number(phone_number=phone_number, session=session)
|
||||
account.reset_account_pin()
|
||||
session.add(account)
|
||||
session.flush()
|
||||
SessionBase.release_session(session=session)
|
||||
|
||||
response = f'Pin reset for user {phone_number} is successful!'
|
||||
return response
|
||||
|
||||
|
||||
def update_ussd_session(
|
||||
ussd_session: InMemoryUssdSession,
|
||||
user_input: str,
|
||||
current_menu: str,
|
||||
session_data: Optional[dict] = None) -> InMemoryUssdSession:
|
||||
"""
|
||||
Updates a ussd session
|
||||
:param ussd_session: Session id value provided by AT
|
||||
:type ussd_session: InMemoryUssdSession
|
||||
:param user_input: Input from the request
|
||||
:type user_input: str
|
||||
:param current_menu: Menu name that is currently being displayed on the ussd session
|
||||
:type current_menu: str
|
||||
:param session_data: Any additional data that was persisted during the user's interaction with the system.
|
||||
:type session_data: dict.
|
||||
:return: ussd session object
|
||||
:rtype: InMemoryUssdSession
|
||||
"""
|
||||
if session_data is None:
|
||||
session_data = ussd_session.session_data
|
||||
|
||||
session = InMemoryUssdSession(
|
||||
external_session_id=ussd_session.external_session_id,
|
||||
msisdn=ussd_session.msisdn,
|
||||
user_input=user_input,
|
||||
state=current_menu,
|
||||
service_code=ussd_session.service_code,
|
||||
session_data=session_data
|
||||
)
|
||||
return session
|
||||
|
||||
|
||||
def save_to_in_memory_ussd_session_data(queue: str, session: Session, session_data: dict, ussd_session: dict):
|
||||
"""This function is used to save information to the session data attribute of a ussd session object in the redis
|
||||
cache.
|
||||
:param queue: The queue on which the celery task should run.
|
||||
:type queue: str
|
||||
:param session:
|
||||
:type session:
|
||||
:param session_data: A dictionary containing data for a specific ussd session in redis that needs to be saved
|
||||
temporarily.
|
||||
:type session_data: dict
|
||||
:param ussd_session: A ussd session passed to the state machine.
|
||||
:type ussd_session: UssdSession
|
||||
"""
|
||||
# define redis cache entry point
|
||||
cache = InMemoryStore.cache
|
||||
|
||||
# get external session id
|
||||
external_session_id = ussd_session.get('external_session_id')
|
||||
|
||||
# check for existing session data
|
||||
existing_session_data = ussd_session.get('session_data')
|
||||
|
||||
# merge old session data with new inputs to session data
|
||||
if existing_session_data:
|
||||
session_data = {**existing_session_data, **session_data}
|
||||
|
||||
# get corresponding session record
|
||||
in_redis_ussd_session = cache.get(external_session_id)
|
||||
in_redis_ussd_session = json.loads(in_redis_ussd_session)
|
||||
|
||||
# create new in memory ussd session with current ussd session data
|
||||
create_or_update_session(
|
||||
external_session_id=external_session_id,
|
||||
phone=in_redis_ussd_session.get('msisdn'),
|
||||
service_code=in_redis_ussd_session.get('service_code'),
|
||||
user_input=in_redis_ussd_session.get('user_input'),
|
||||
current_menu=in_redis_ussd_session.get('state'),
|
||||
session=session,
|
||||
session_data=session_data
|
||||
)
|
||||
persist_session_to_db_task(external_session_id=external_session_id, queue=queue)
|
@ -29,9 +29,10 @@ def process_phone_number(phone_number: str, region: str):
|
||||
pass
|
||||
|
||||
phone_number_object = phonenumbers.parse(phone_number, region)
|
||||
parsed_phone_number = phonenumbers.format_number(phone_number_object, phonenumbers.PhoneNumberFormat.E164)
|
||||
return phonenumbers.format_number(
|
||||
phone_number_object, phonenumbers.PhoneNumberFormat.E164
|
||||
)
|
||||
|
||||
return parsed_phone_number
|
||||
|
||||
class Support:
|
||||
phone_number = None
|
||||
|
@ -1,562 +0,0 @@
|
||||
# standard imports
|
||||
import datetime
|
||||
import logging
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
# third party imports
|
||||
from sqlalchemy import desc
|
||||
from cic_eth.api import Api
|
||||
from sqlalchemy.orm.session import Session
|
||||
from tinydb.table import Document
|
||||
|
||||
# local imports
|
||||
from cic_ussd.account import define_account_tx_metadata, retrieve_account_statement
|
||||
from cic_ussd.balance import compute_operational_balance, get_balances, get_cached_operational_balance
|
||||
from cic_ussd.chain import Chain
|
||||
from cic_ussd.db.models.account import Account
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.db.models.ussd_session import UssdSession
|
||||
from cic_ussd.db.enum import AccountStatus
|
||||
from cic_ussd.error import SeppukuError
|
||||
from cic_ussd.menu.ussd_menu import UssdMenu
|
||||
from cic_ussd.metadata import blockchain_address_to_metadata_pointer
|
||||
from cic_ussd.phone_number import Support
|
||||
from cic_ussd.redis import cache_data, create_cached_data_key, get_cached_data
|
||||
from cic_ussd.state_machine import UssdStateMachine
|
||||
from cic_ussd.conversions import to_wei, from_wei
|
||||
from cic_ussd.translation import translation_for
|
||||
from cic_types.models.person import generate_metadata_pointer, get_contact_data_from_vcard
|
||||
|
||||
logg = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_default_token_data():
|
||||
chain_str = Chain.spec.__str__()
|
||||
cic_eth_api = Api(chain_str=chain_str)
|
||||
default_token_request_task = cic_eth_api.default_token()
|
||||
default_token_data = default_token_request_task.get()
|
||||
return default_token_data
|
||||
|
||||
|
||||
def retrieve_token_symbol(chain_str: str = Chain.spec.__str__()):
|
||||
"""
|
||||
:param chain_str:
|
||||
:type chain_str:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
cache_key = create_cached_data_key(
|
||||
identifier=chain_str.encode('utf-8'),
|
||||
salt=':cic.default_token_data'
|
||||
)
|
||||
cached_data = get_cached_data(key=cache_key)
|
||||
if cached_data:
|
||||
default_token_data = json.loads(cached_data)
|
||||
return default_token_data.get('symbol')
|
||||
else:
|
||||
logg.warning('Cached default token data not found. Attempting retrieval from default token API')
|
||||
default_token_data = get_default_token_data()
|
||||
if default_token_data:
|
||||
return default_token_data.get('symbol')
|
||||
else:
|
||||
raise SeppukuError(f'Could not retrieve default token for: {chain_str}')
|
||||
|
||||
|
||||
def process_pin_authorization(account: Account, display_key: str, **kwargs) -> str:
|
||||
"""This method provides translation for all ussd menu entries that follow the pin authorization pattern.
|
||||
:param account: The account in a running USSD session.
|
||||
:type account: Account
|
||||
:param display_key: The path in the translation files defining an appropriate ussd response
|
||||
:type display_key: str
|
||||
:param kwargs: Any additional information required by the text values in the internationalization files.
|
||||
:type kwargs
|
||||
:return: A string value corresponding the ussd menu's text value.
|
||||
:rtype: str
|
||||
"""
|
||||
remaining_attempts = 3
|
||||
if account.failed_pin_attempts > 0:
|
||||
return translation_for(
|
||||
key=f'{display_key}.retry',
|
||||
preferred_language=account.preferred_language,
|
||||
remaining_attempts=(remaining_attempts - account.failed_pin_attempts)
|
||||
)
|
||||
else:
|
||||
return translation_for(
|
||||
key=f'{display_key}.first',
|
||||
preferred_language=account.preferred_language,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
def process_exit_insufficient_balance(account: Account, display_key: str, session: Session, ussd_session: dict):
|
||||
"""This function processes the exit menu letting users their account balance is insufficient to perform a specific
|
||||
transaction.
|
||||
:param account: The account requesting access to the ussd menu.
|
||||
:type account: Account
|
||||
:param display_key: The path in the translation files defining an appropriate ussd response
|
||||
:type display_key: str
|
||||
:param session:
|
||||
:type session:
|
||||
:param ussd_session: A JSON serialized in-memory ussd session object
|
||||
:type ussd_session: dict
|
||||
:return: Corresponding translation text response
|
||||
:rtype: str
|
||||
"""
|
||||
# get account balance
|
||||
operational_balance = get_cached_operational_balance(blockchain_address=account.blockchain_address)
|
||||
|
||||
# compile response data
|
||||
user_input = ussd_session.get('user_input').split('*')[-1]
|
||||
transaction_amount = to_wei(value=int(user_input))
|
||||
|
||||
# get default data
|
||||
token_symbol = retrieve_token_symbol()
|
||||
|
||||
recipient_phone_number = ussd_session.get('session_data').get('recipient_phone_number')
|
||||
recipient = Account.get_by_phone_number(phone_number=recipient_phone_number, session=session)
|
||||
|
||||
tx_recipient_information = define_account_tx_metadata(user=recipient)
|
||||
|
||||
return translation_for(
|
||||
key=display_key,
|
||||
preferred_language=account.preferred_language,
|
||||
amount=from_wei(transaction_amount),
|
||||
token_symbol=token_symbol,
|
||||
recipient_information=tx_recipient_information,
|
||||
token_balance=operational_balance
|
||||
)
|
||||
|
||||
|
||||
def process_exit_successful_transaction(account: Account, display_key: str, session: Session, ussd_session: dict):
|
||||
"""This function processes the exit menu after a successful initiation for a transfer of tokens.
|
||||
:param account: The account requesting access to the ussd menu.
|
||||
:type account: Account
|
||||
:param display_key: The path in the translation files defining an appropriate ussd response
|
||||
:type display_key: str
|
||||
:param session:
|
||||
:type session:
|
||||
:param ussd_session: A JSON serialized in-memory ussd session object
|
||||
:type ussd_session: dict
|
||||
:return: Corresponding translation text response
|
||||
:rtype: str
|
||||
"""
|
||||
transaction_amount = to_wei(int(ussd_session.get('session_data').get('transaction_amount')))
|
||||
token_symbol = retrieve_token_symbol()
|
||||
recipient_phone_number = ussd_session.get('session_data').get('recipient_phone_number')
|
||||
recipient = Account.get_by_phone_number(phone_number=recipient_phone_number, session=session)
|
||||
tx_recipient_information = define_account_tx_metadata(user=recipient)
|
||||
tx_sender_information = define_account_tx_metadata(user=account)
|
||||
|
||||
return translation_for(
|
||||
key=display_key,
|
||||
preferred_language=account.preferred_language,
|
||||
transaction_amount=from_wei(transaction_amount),
|
||||
token_symbol=token_symbol,
|
||||
recipient_information=tx_recipient_information,
|
||||
sender_information=tx_sender_information
|
||||
)
|
||||
|
||||
|
||||
def process_transaction_pin_authorization(account: Account, display_key: str, session: Session, ussd_session: dict):
|
||||
"""This function processes pin authorization where making a transaction is concerned. It constructs a
|
||||
pre-transaction response menu that shows the details of the transaction.
|
||||
:param account: The account requesting access to the ussd menu.
|
||||
:type account: Account
|
||||
:param display_key: The path in the translation files defining an appropriate ussd response
|
||||
:type display_key: str
|
||||
:param session:
|
||||
:type session:
|
||||
:param ussd_session: The USSD session determining what user data needs to be extracted and added to the menu's
|
||||
text values.
|
||||
:type ussd_session: UssdSession
|
||||
:return: Corresponding translation text response
|
||||
:rtype: str
|
||||
"""
|
||||
# compile response data
|
||||
recipient_phone_number = ussd_session.get('session_data').get('recipient_phone_number')
|
||||
recipient = Account.get_by_phone_number(phone_number=recipient_phone_number, session=session)
|
||||
tx_recipient_information = define_account_tx_metadata(user=recipient)
|
||||
tx_sender_information = define_account_tx_metadata(user=account)
|
||||
|
||||
token_symbol = retrieve_token_symbol()
|
||||
user_input = ussd_session.get('session_data').get('transaction_amount')
|
||||
transaction_amount = to_wei(value=int(user_input))
|
||||
return process_pin_authorization(
|
||||
account=account,
|
||||
display_key=display_key,
|
||||
recipient_information=tx_recipient_information,
|
||||
transaction_amount=from_wei(transaction_amount),
|
||||
token_symbol=token_symbol,
|
||||
sender_information=tx_sender_information
|
||||
)
|
||||
|
||||
|
||||
def process_account_balances(user: Account, display_key: str):
|
||||
"""
|
||||
:param user:
|
||||
:type user:
|
||||
:param display_key:
|
||||
:type display_key:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
# retrieve cached balance
|
||||
operational_balance = get_cached_operational_balance(blockchain_address=user.blockchain_address)
|
||||
|
||||
logg.debug('Requires call to retrieve tax and bonus amounts')
|
||||
tax = ''
|
||||
bonus = ''
|
||||
token_symbol = retrieve_token_symbol()
|
||||
return translation_for(
|
||||
key=display_key,
|
||||
preferred_language=user.preferred_language,
|
||||
operational_balance=operational_balance,
|
||||
tax=tax,
|
||||
bonus=bonus,
|
||||
token_symbol=token_symbol
|
||||
)
|
||||
|
||||
|
||||
def format_transactions(transactions: list, preferred_language: str, token_symbol: str):
|
||||
|
||||
formatted_transactions = ''
|
||||
if len(transactions) > 0:
|
||||
for transaction in transactions:
|
||||
recipient_phone_number = transaction.get('recipient_phone_number')
|
||||
sender_phone_number = transaction.get('sender_phone_number')
|
||||
value = transaction.get('to_value')
|
||||
timestamp = transaction.get('timestamp')
|
||||
action_tag = transaction.get('action_tag')
|
||||
direction = transaction.get('direction')
|
||||
token_symbol = token_symbol
|
||||
|
||||
if action_tag == 'SENT' or action_tag == 'ULITUMA':
|
||||
formatted_transactions += f'{action_tag} {value} {token_symbol} {direction} {recipient_phone_number} {timestamp}.\n'
|
||||
else:
|
||||
formatted_transactions += f'{action_tag} {value} {token_symbol} {direction} {sender_phone_number} {timestamp}. \n'
|
||||
return formatted_transactions
|
||||
else:
|
||||
if preferred_language == 'en':
|
||||
formatted_transactions = 'NO TRANSACTION HISTORY'
|
||||
else:
|
||||
formatted_transactions = 'HAMNA RIPOTI YA MATUMIZI'
|
||||
return formatted_transactions
|
||||
|
||||
|
||||
def process_display_user_metadata(user: Account, display_key: str):
|
||||
"""
|
||||
:param user:
|
||||
:type user:
|
||||
:param display_key:
|
||||
:type display_key:
|
||||
"""
|
||||
key = generate_metadata_pointer(
|
||||
identifier=blockchain_address_to_metadata_pointer(blockchain_address=user.blockchain_address),
|
||||
cic_type=':cic.person'
|
||||
)
|
||||
cached_metadata = get_cached_data(key)
|
||||
if cached_metadata:
|
||||
user_metadata = json.loads(cached_metadata)
|
||||
contact_data = get_contact_data_from_vcard(vcard=user_metadata.get('vcard'))
|
||||
logg.debug(f'{contact_data}')
|
||||
full_name = f'{contact_data.get("given")} {contact_data.get("family")}'
|
||||
date_of_birth = user_metadata.get('date_of_birth')
|
||||
year_of_birth = date_of_birth.get('year')
|
||||
present_year = datetime.datetime.now().year
|
||||
age = present_year - year_of_birth
|
||||
gender = user_metadata.get('gender')
|
||||
products = ', '.join(user_metadata.get('products'))
|
||||
location = user_metadata.get('location').get('area_name')
|
||||
|
||||
return translation_for(
|
||||
key=display_key,
|
||||
preferred_language=user.preferred_language,
|
||||
full_name=full_name,
|
||||
age=age,
|
||||
gender=gender,
|
||||
location=location,
|
||||
products=products
|
||||
)
|
||||
else:
|
||||
# TODO [Philip]: All these translations could be moved to translation files.
|
||||
logg.warning(f'Expected person metadata but found none in cache for key: {key}')
|
||||
|
||||
absent = ''
|
||||
if user.preferred_language == 'en':
|
||||
absent = 'Not provided'
|
||||
elif user.preferred_language == 'sw':
|
||||
absent = 'Haijawekwa'
|
||||
|
||||
return translation_for(
|
||||
key=display_key,
|
||||
preferred_language=user.preferred_language,
|
||||
full_name=absent,
|
||||
gender=absent,
|
||||
age=absent,
|
||||
location=absent,
|
||||
products=absent
|
||||
)
|
||||
|
||||
|
||||
def process_account_statement(user: Account, display_key: str):
|
||||
"""
|
||||
:param user:
|
||||
:type user:
|
||||
:param display_key:
|
||||
:type display_key:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
# retrieve cached statement
|
||||
identifier = blockchain_address_to_metadata_pointer(blockchain_address=user.blockchain_address)
|
||||
key = create_cached_data_key(identifier=identifier, salt=':cic.statement')
|
||||
transactions = get_cached_data(key=key)
|
||||
|
||||
token_symbol = retrieve_token_symbol()
|
||||
|
||||
first_transaction_set = []
|
||||
middle_transaction_set = []
|
||||
last_transaction_set = []
|
||||
|
||||
transactions = json.loads(transactions)
|
||||
|
||||
if len(transactions) > 6:
|
||||
last_transaction_set += transactions[6:]
|
||||
middle_transaction_set += transactions[3:][:3]
|
||||
first_transaction_set += transactions[:3]
|
||||
# there are probably much cleaner and operational inexpensive ways to do this so find them
|
||||
elif 3 < len(transactions) < 7:
|
||||
middle_transaction_set += transactions[3:]
|
||||
first_transaction_set += transactions[:3]
|
||||
else:
|
||||
first_transaction_set += transactions[:3]
|
||||
|
||||
if display_key == 'ussd.kenya.first_transaction_set':
|
||||
return translation_for(
|
||||
key=display_key,
|
||||
preferred_language=user.preferred_language,
|
||||
first_transaction_set=format_transactions(
|
||||
transactions=first_transaction_set,
|
||||
preferred_language=user.preferred_language,
|
||||
token_symbol=token_symbol
|
||||
)
|
||||
)
|
||||
elif display_key == 'ussd.kenya.middle_transaction_set':
|
||||
return translation_for(
|
||||
key=display_key,
|
||||
preferred_language=user.preferred_language,
|
||||
middle_transaction_set=format_transactions(
|
||||
transactions=middle_transaction_set,
|
||||
preferred_language=user.preferred_language,
|
||||
token_symbol=token_symbol
|
||||
)
|
||||
)
|
||||
|
||||
elif display_key == 'ussd.kenya.last_transaction_set':
|
||||
return translation_for(
|
||||
key=display_key,
|
||||
preferred_language=user.preferred_language,
|
||||
last_transaction_set=format_transactions(
|
||||
transactions=last_transaction_set,
|
||||
preferred_language=user.preferred_language,
|
||||
token_symbol=token_symbol
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def process_start_menu(display_key: str, user: Account):
|
||||
"""This function gets data on an account's balance and token in order to append it to the start of the start menu's
|
||||
title. It passes said arguments to the translation function and returns the appropriate corresponding text from the
|
||||
translation files.
|
||||
:param user: The user requesting access to the ussd menu.
|
||||
:type user: Account
|
||||
:param display_key: The path in the translation files defining an appropriate ussd response
|
||||
:type display_key: str
|
||||
:return: Corresponding translation text response
|
||||
:rtype: str
|
||||
"""
|
||||
token_symbol = retrieve_token_symbol()
|
||||
chain_str = Chain.spec.__str__()
|
||||
blockchain_address = user.blockchain_address
|
||||
|
||||
# get balances synchronously for display on start menu
|
||||
balances_data = get_balances(address=blockchain_address, chain_str=chain_str, token_symbol=token_symbol)
|
||||
|
||||
key = create_cached_data_key(
|
||||
identifier=bytes.fromhex(blockchain_address[2:]),
|
||||
salt=':cic.balances_data'
|
||||
)
|
||||
cache_data(key=key, data=json.dumps(balances_data))
|
||||
|
||||
# get operational balance
|
||||
operational_balance = compute_operational_balance(balances=balances_data)
|
||||
|
||||
# retrieve and cache account's statement
|
||||
retrieve_account_statement(blockchain_address=blockchain_address)
|
||||
|
||||
return translation_for(
|
||||
key=display_key,
|
||||
preferred_language=user.preferred_language,
|
||||
account_balance=operational_balance,
|
||||
account_token_name=token_symbol
|
||||
)
|
||||
|
||||
|
||||
def retrieve_most_recent_ussd_session(phone_number: str, session: Session) -> UssdSession:
|
||||
# get last ussd session based on user phone number
|
||||
session = SessionBase.bind_session(session=session)
|
||||
last_ussd_session = session.query(UssdSession)\
|
||||
.filter_by(msisdn=phone_number)\
|
||||
.order_by(desc(UssdSession.created))\
|
||||
.first()
|
||||
SessionBase.release_session(session=session)
|
||||
return last_ussd_session
|
||||
|
||||
|
||||
def process_request(account: Account, session, user_input: str, ussd_session: Optional[dict] = None) -> Document:
|
||||
"""This function assesses a request based on the user from the request comes, the session_id and the user's
|
||||
input. It determines whether the request translates to a return to an existing session by checking whether the
|
||||
provided session id exists in the database or whether the creation of a new ussd session object is warranted.
|
||||
It then returns the appropriate ussd menu text values.
|
||||
:param account: The account requesting access to the ussd menu.
|
||||
:type account: Account
|
||||
:param session:
|
||||
:type session:
|
||||
:param user_input: The value a user enters in the ussd menu.
|
||||
:type user_input: str
|
||||
:param ussd_session: A JSON serialized in-memory ussd session object
|
||||
:type ussd_session: dict
|
||||
:return: A ussd menu's corresponding text value.
|
||||
:rtype: Document
|
||||
"""
|
||||
|
||||
if ussd_session:
|
||||
if user_input == "0":
|
||||
return UssdMenu.parent_menu(menu_name=ussd_session.get('state'))
|
||||
else:
|
||||
successive_state = next_state(
|
||||
account=account,
|
||||
session=session,
|
||||
ussd_session=ussd_session,
|
||||
user_input=user_input)
|
||||
return UssdMenu.find_by_name(name=successive_state)
|
||||
else:
|
||||
if account.has_valid_pin():
|
||||
last_ussd_session = retrieve_most_recent_ussd_session(phone_number=account.phone_number, session=session)
|
||||
|
||||
if last_ussd_session:
|
||||
# get last state
|
||||
last_state = last_ussd_session.state
|
||||
# if last state is account_creation_prompt and metadata exists, show start menu
|
||||
if last_state in [
|
||||
'account_creation_prompt',
|
||||
'exit',
|
||||
'exit_invalid_pin',
|
||||
'exit_invalid_new_pin',
|
||||
'exit_pin_mismatch',
|
||||
'exit_invalid_request',
|
||||
'exit_successful_transaction'
|
||||
]:
|
||||
return UssdMenu.find_by_name(name='start')
|
||||
else:
|
||||
return UssdMenu.find_by_name(name=last_state)
|
||||
else:
|
||||
if account.failed_pin_attempts >= 3 and account.get_account_status() == AccountStatus.LOCKED.name:
|
||||
return UssdMenu.find_by_name(name='exit_pin_blocked')
|
||||
elif account.preferred_language is None:
|
||||
return UssdMenu.find_by_name(name='initial_language_selection')
|
||||
else:
|
||||
return UssdMenu.find_by_name(name='initial_pin_entry')
|
||||
|
||||
|
||||
def next_state(account: Account, session, ussd_session: dict, user_input: str) -> str:
|
||||
"""This function navigates the state machine based on the ussd session object and user inputs it receives.
|
||||
It checks the user input and provides the successive state in the state machine. It then updates the session's
|
||||
state attribute with the new state.
|
||||
:param account: The account requesting access to the ussd menu.
|
||||
:type account: Account
|
||||
:param session:
|
||||
:type session:
|
||||
:param ussd_session: A JSON serialized in-memory ussd session object
|
||||
:type ussd_session: dict
|
||||
:param user_input: The value a user enters in the ussd menu.
|
||||
:type user_input: str
|
||||
:return: A string value corresponding the successive give a specific state in the state machine.
|
||||
"""
|
||||
state_machine = UssdStateMachine(ussd_session=ussd_session)
|
||||
state_machine.scan_data((user_input, ussd_session, account, session))
|
||||
new_state = state_machine.state
|
||||
|
||||
return new_state
|
||||
|
||||
|
||||
def process_exit_invalid_menu_option(display_key: str, preferred_language: str):
|
||||
return translation_for(
|
||||
key=display_key,
|
||||
preferred_language=preferred_language,
|
||||
support_phone=Support.phone_number
|
||||
)
|
||||
|
||||
|
||||
def custom_display_text(
|
||||
account: Account,
|
||||
display_key: str,
|
||||
menu_name: str,
|
||||
session: Session,
|
||||
ussd_session: dict) -> str:
|
||||
"""This function extracts the appropriate session data based on the current menu name. It then inserts them as
|
||||
keywords in the i18n function.
|
||||
:param account: The account in a running USSD session.
|
||||
:type account: Account
|
||||
:param display_key: The path in the translation files defining an appropriate ussd response
|
||||
:type display_key: str
|
||||
:param menu_name: The name by which a specific menu can be identified.
|
||||
:type menu_name: str
|
||||
:param session:
|
||||
:type session:
|
||||
:param ussd_session: A JSON serialized in-memory ussd session object
|
||||
:type ussd_session: dict
|
||||
:return: A string value corresponding the ussd menu's text value.
|
||||
:rtype: str
|
||||
"""
|
||||
if menu_name == 'transaction_pin_authorization':
|
||||
return process_transaction_pin_authorization(
|
||||
account=account,
|
||||
display_key=display_key,
|
||||
session=session,
|
||||
ussd_session=ussd_session)
|
||||
elif menu_name == 'exit_insufficient_balance':
|
||||
return process_exit_insufficient_balance(
|
||||
account=account,
|
||||
display_key=display_key,
|
||||
session=session,
|
||||
ussd_session=ussd_session)
|
||||
elif menu_name == 'exit_successful_transaction':
|
||||
return process_exit_successful_transaction(
|
||||
account=account,
|
||||
display_key=display_key,
|
||||
session=session,
|
||||
ussd_session=ussd_session)
|
||||
elif menu_name == 'start':
|
||||
return process_start_menu(display_key=display_key, user=account)
|
||||
elif 'pin_authorization' in menu_name:
|
||||
return process_pin_authorization(
|
||||
account=account,
|
||||
display_key=display_key,
|
||||
session=session)
|
||||
elif 'enter_current_pin' in menu_name:
|
||||
return process_pin_authorization(
|
||||
account=account,
|
||||
display_key=display_key,
|
||||
session=session)
|
||||
elif menu_name == 'account_balances':
|
||||
return process_account_balances(display_key=display_key, user=account)
|
||||
elif 'transaction_set' in menu_name:
|
||||
return process_account_statement(display_key=display_key, user=account)
|
||||
elif menu_name == 'display_user_metadata':
|
||||
return process_display_user_metadata(display_key=display_key, user=account)
|
||||
elif menu_name == 'exit_invalid_menu_option':
|
||||
return process_exit_invalid_menu_option(display_key=display_key, preferred_language=account.preferred_language)
|
||||
else:
|
||||
return translation_for(key=display_key, preferred_language=account.preferred_language)
|
0
apps/cic-ussd/cic_ussd/processor/__init__.py
Normal file
0
apps/cic-ussd/cic_ussd/processor/__init__.py
Normal file
305
apps/cic-ussd/cic_ussd/processor/menu.py
Normal file
305
apps/cic-ussd/cic_ussd/processor/menu.py
Normal file
@ -0,0 +1,305 @@
|
||||
# standard imports
|
||||
import json
|
||||
import logging
|
||||
|
||||
# external imports
|
||||
import i18n.config
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
# local imports
|
||||
from cic_ussd.account.balance import calculate_available_balance, get_balances, get_cached_available_balance
|
||||
from cic_ussd.account.chain import Chain
|
||||
from cic_ussd.account.metadata import get_cached_preferred_language
|
||||
from cic_ussd.account.statement import (
|
||||
get_cached_statement,
|
||||
parse_statement_transactions,
|
||||
query_statement,
|
||||
statement_transaction_set
|
||||
)
|
||||
from cic_ussd.account.transaction import from_wei, to_wei
|
||||
from cic_ussd.account.tokens import get_default_token_symbol
|
||||
from cic_ussd.cache import cache_data_key, cache_data
|
||||
from cic_ussd.db.models.account import Account
|
||||
from cic_ussd.metadata import PersonMetadata
|
||||
from cic_ussd.phone_number import Support
|
||||
from cic_ussd.processor.util import latest_input, parse_person_metadata
|
||||
from cic_ussd.translation import translation_for
|
||||
|
||||
logg = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MenuProcessor:
|
||||
def __init__(self, account: Account, display_key: str, menu_name: str, session: Session, ussd_session: dict):
|
||||
self.account = account
|
||||
self.display_key = display_key
|
||||
self.identifier = bytes.fromhex(self.account.blockchain_address[2:])
|
||||
self.menu_name = menu_name
|
||||
self.session = session
|
||||
self.ussd_session = ussd_session
|
||||
|
||||
def account_balances(self) -> str:
|
||||
"""
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
available_balance = get_cached_available_balance(self.account.blockchain_address)
|
||||
logg.debug('Requires call to retrieve tax and bonus amounts')
|
||||
tax = ''
|
||||
bonus = ''
|
||||
token_symbol = get_default_token_symbol()
|
||||
preferred_language = get_cached_preferred_language(self.account.blockchain_address)
|
||||
if not preferred_language:
|
||||
preferred_language = i18n.config.get('fallback')
|
||||
return translation_for(
|
||||
key=self.display_key,
|
||||
preferred_language=preferred_language,
|
||||
available_balance=available_balance,
|
||||
tax=tax,
|
||||
bonus=bonus,
|
||||
token_symbol=token_symbol
|
||||
)
|
||||
|
||||
def account_statement(self) -> str:
|
||||
"""
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
cached_statement = get_cached_statement(self.account.blockchain_address)
|
||||
statement = json.loads(cached_statement)
|
||||
statement_transactions = parse_statement_transactions(statement)
|
||||
transaction_sets = [statement_transactions[tx:tx+3] for tx in range(0, len(statement_transactions), 3)]
|
||||
preferred_language = get_cached_preferred_language(self.account.blockchain_address)
|
||||
if not preferred_language:
|
||||
preferred_language = i18n.config.get('fallback')
|
||||
first_transaction_set = []
|
||||
middle_transaction_set = []
|
||||
last_transaction_set = []
|
||||
if transaction_sets:
|
||||
first_transaction_set = statement_transaction_set(preferred_language, transaction_sets[0])
|
||||
if len(transaction_sets) >= 2:
|
||||
middle_transaction_set = statement_transaction_set(preferred_language, transaction_sets[1])
|
||||
if len(transaction_sets) >= 3:
|
||||
last_transaction_set = statement_transaction_set(preferred_language, transaction_sets[2])
|
||||
if self.display_key == 'ussd.kenya.first_transaction_set':
|
||||
return translation_for(
|
||||
self.display_key, preferred_language, first_transaction_set=first_transaction_set
|
||||
)
|
||||
if self.display_key == 'ussd.kenya.middle_transaction_set':
|
||||
return translation_for(
|
||||
self.display_key, preferred_language, middle_transaction_set=middle_transaction_set
|
||||
)
|
||||
if self.display_key == 'ussd.kenya.last_transaction_set':
|
||||
return translation_for(
|
||||
self.display_key, preferred_language, last_transaction_set=last_transaction_set
|
||||
)
|
||||
|
||||
def help(self):
|
||||
preferred_language = get_cached_preferred_language(self.account.blockchain_address)
|
||||
if not preferred_language:
|
||||
preferred_language = i18n.config.get('fallback')
|
||||
return translation_for(self.display_key, preferred_language, support_phone=Support.phone_number)
|
||||
|
||||
def person_metadata(self) -> str:
|
||||
"""
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
person_metadata = PersonMetadata(self.identifier)
|
||||
cached_person_metadata = person_metadata.get_cached_metadata()
|
||||
preferred_language = get_cached_preferred_language(self.account.blockchain_address)
|
||||
if not preferred_language:
|
||||
preferred_language = i18n.config.get('fallback')
|
||||
if cached_person_metadata:
|
||||
return parse_person_metadata(cached_person_metadata, self.display_key, preferred_language)
|
||||
absent = translation_for('helpers.not_provided', preferred_language)
|
||||
return translation_for(
|
||||
self.display_key,
|
||||
preferred_language,
|
||||
full_name=absent,
|
||||
gender=absent,
|
||||
age=absent,
|
||||
location=absent,
|
||||
products=absent
|
||||
)
|
||||
|
||||
def pin_authorization(self, **kwargs) -> str:
|
||||
"""
|
||||
:param kwargs:
|
||||
:type kwargs:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
preferred_language = get_cached_preferred_language(self.account.blockchain_address)
|
||||
if not preferred_language:
|
||||
preferred_language = i18n.config.get('fallback')
|
||||
if self.account.failed_pin_attempts == 0:
|
||||
return translation_for(f'{self.display_key}.first', preferred_language, **kwargs)
|
||||
|
||||
remaining_attempts = 3
|
||||
remaining_attempts -= self.account.failed_pin_attempts
|
||||
retry_pin_entry = translation_for(
|
||||
'ussd.kenya.retry_pin_entry', preferred_language, remaining_attempts=remaining_attempts
|
||||
)
|
||||
return translation_for(
|
||||
f'{self.display_key}.retry', preferred_language, retry_pin_entry=retry_pin_entry
|
||||
)
|
||||
|
||||
def start_menu(self):
|
||||
"""
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
token_symbol = get_default_token_symbol()
|
||||
blockchain_address = self.account.blockchain_address
|
||||
balances = get_balances(blockchain_address, Chain.spec.__str__(), token_symbol, False)[0]
|
||||
key = cache_data_key(self.identifier, ':cic.balances')
|
||||
cache_data(key, json.dumps(balances))
|
||||
available_balance = calculate_available_balance(balances)
|
||||
|
||||
query_statement(blockchain_address)
|
||||
|
||||
preferred_language = get_cached_preferred_language(self.account.blockchain_address)
|
||||
if not preferred_language:
|
||||
preferred_language = i18n.config.get('fallback')
|
||||
return translation_for(
|
||||
self.display_key, preferred_language, account_balance=available_balance, account_token_name=token_symbol
|
||||
)
|
||||
|
||||
def transaction_pin_authorization(self) -> str:
|
||||
"""
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
recipient_phone_number = self.ussd_session.get('data').get('recipient_phone_number')
|
||||
recipient = Account.get_by_phone_number(recipient_phone_number, self.session)
|
||||
tx_recipient_information = recipient.standard_metadata_id()
|
||||
tx_sender_information = self.account.standard_metadata_id()
|
||||
token_symbol = get_default_token_symbol()
|
||||
user_input = self.ussd_session.get('data').get('transaction_amount')
|
||||
transaction_amount = to_wei(value=int(user_input))
|
||||
return self.pin_authorization(
|
||||
recipient_information=tx_recipient_information,
|
||||
transaction_amount=from_wei(transaction_amount),
|
||||
token_symbol=token_symbol,
|
||||
sender_information=tx_sender_information
|
||||
)
|
||||
|
||||
def exit_insufficient_balance(self):
|
||||
"""
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
available_balance = get_cached_available_balance(self.account.blockchain_address)
|
||||
preferred_language = get_cached_preferred_language(self.account.blockchain_address)
|
||||
if not preferred_language:
|
||||
preferred_language = i18n.config.get('fallback')
|
||||
session_data = self.ussd_session.get('data')
|
||||
transaction_amount = session_data.get('transaction_amount')
|
||||
transaction_amount = to_wei(value=int(transaction_amount))
|
||||
token_symbol = get_default_token_symbol()
|
||||
recipient_phone_number = self.ussd_session.get('data').get('recipient_phone_number')
|
||||
recipient = Account.get_by_phone_number(recipient_phone_number, self.session)
|
||||
tx_recipient_information = recipient.standard_metadata_id()
|
||||
return translation_for(
|
||||
self.display_key,
|
||||
preferred_language,
|
||||
amount=from_wei(transaction_amount),
|
||||
token_symbol=token_symbol,
|
||||
recipient_information=tx_recipient_information,
|
||||
token_balance=available_balance
|
||||
)
|
||||
|
||||
def exit_invalid_menu_option(self):
|
||||
preferred_language = get_cached_preferred_language(self.account.blockchain_address)
|
||||
if not preferred_language:
|
||||
preferred_language = i18n.config.get('fallback')
|
||||
return translation_for(self.display_key, preferred_language, support_phone=Support.phone_number)
|
||||
|
||||
def exit_pin_blocked(self):
|
||||
preferred_language = get_cached_preferred_language(self.account.blockchain_address)
|
||||
if not preferred_language:
|
||||
preferred_language = i18n.config.get('fallback')
|
||||
return translation_for('ussd.kenya.exit_pin_blocked', preferred_language, support_phone=Support.phone_number)
|
||||
|
||||
def exit_successful_transaction(self):
|
||||
"""
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
amount = int(self.ussd_session.get('data').get('transaction_amount'))
|
||||
preferred_language = get_cached_preferred_language(self.account.blockchain_address)
|
||||
if not preferred_language:
|
||||
preferred_language = i18n.config.get('fallback')
|
||||
transaction_amount = to_wei(amount)
|
||||
token_symbol = get_default_token_symbol()
|
||||
recipient_phone_number = self.ussd_session.get('data').get('recipient_phone_number')
|
||||
recipient = Account.get_by_phone_number(phone_number=recipient_phone_number, session=self.session)
|
||||
tx_recipient_information = recipient.standard_metadata_id()
|
||||
tx_sender_information = self.account.standard_metadata_id()
|
||||
return translation_for(
|
||||
self.display_key,
|
||||
preferred_language,
|
||||
transaction_amount=from_wei(transaction_amount),
|
||||
token_symbol=token_symbol,
|
||||
recipient_information=tx_recipient_information,
|
||||
sender_information=tx_sender_information
|
||||
)
|
||||
|
||||
|
||||
def response(account: Account, display_key: str, menu_name: str, session: Session, ussd_session: dict) -> str:
|
||||
"""This function extracts the appropriate session data based on the current menu name. It then inserts them as
|
||||
keywords in the i18n function.
|
||||
:param account: The account in a running USSD session.
|
||||
:type account: Account
|
||||
:param display_key: The path in the translation files defining an appropriate ussd response
|
||||
:type display_key: str
|
||||
:param menu_name: The name by which a specific menu can be identified.
|
||||
:type menu_name: str
|
||||
:param session:
|
||||
:type session:
|
||||
:param ussd_session: A JSON serialized in-memory ussd session object
|
||||
:type ussd_session: dict
|
||||
:return: A string value corresponding the ussd menu's text value.
|
||||
:rtype: str
|
||||
"""
|
||||
menu_processor = MenuProcessor(account, display_key, menu_name, session, ussd_session)
|
||||
|
||||
if menu_name == 'start':
|
||||
return menu_processor.start_menu()
|
||||
|
||||
if menu_name == 'help':
|
||||
return menu_processor.help()
|
||||
|
||||
if menu_name == 'transaction_pin_authorization':
|
||||
return menu_processor.transaction_pin_authorization()
|
||||
|
||||
if menu_name == 'exit_insufficient_balance':
|
||||
return menu_processor.exit_insufficient_balance()
|
||||
|
||||
if menu_name == 'exit_successful_transaction':
|
||||
return menu_processor.exit_successful_transaction()
|
||||
|
||||
if menu_name == 'account_balances':
|
||||
return menu_processor.account_balances()
|
||||
|
||||
if 'pin_authorization' in menu_name:
|
||||
return menu_processor.pin_authorization()
|
||||
|
||||
if 'enter_current_pin' in menu_name:
|
||||
return menu_processor.pin_authorization()
|
||||
|
||||
if 'transaction_set' in menu_name:
|
||||
return menu_processor.account_statement()
|
||||
|
||||
if menu_name == 'display_user_metadata':
|
||||
return menu_processor.person_metadata()
|
||||
|
||||
if menu_name == 'exit_invalid_menu_option':
|
||||
return menu_processor.exit_invalid_menu_option()
|
||||
|
||||
if menu_name == 'exit_pin_blocked':
|
||||
return menu_processor.exit_pin_blocked()
|
||||
|
||||
preferred_language = get_cached_preferred_language(account.blockchain_address)
|
||||
|
||||
return translation_for(display_key, preferred_language)
|
185
apps/cic-ussd/cic_ussd/processor/ussd.py
Normal file
185
apps/cic-ussd/cic_ussd/processor/ussd.py
Normal file
@ -0,0 +1,185 @@
|
||||
# standard imports
|
||||
from typing import Optional
|
||||
|
||||
# external imports
|
||||
import celery
|
||||
import i18n
|
||||
from sqlalchemy.orm.session import Session
|
||||
from tinydb.table import Document
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db.models.account import Account, create
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.db.models.ussd_session import UssdSession
|
||||
from cic_ussd.menu.ussd_menu import UssdMenu
|
||||
from cic_ussd.processor.menu import response
|
||||
from cic_ussd.processor.util import latest_input, resume_last_ussd_session
|
||||
from cic_ussd.session.ussd_session import create_or_update_session, persist_ussd_session
|
||||
from cic_ussd.state_machine import UssdStateMachine
|
||||
from cic_ussd.translation import translation_for
|
||||
from cic_ussd.validator import is_valid_response
|
||||
|
||||
|
||||
def handle_menu(account: Account, session: Session) -> Document:
|
||||
"""
|
||||
:param account:
|
||||
:type account:
|
||||
:param session:
|
||||
:type session:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
if account.pin_is_blocked(session):
|
||||
return UssdMenu.find_by_name('exit_pin_blocked')
|
||||
|
||||
if account.has_valid_pin(session):
|
||||
last_ussd_session = UssdSession.last_ussd_session(account.phone_number, session)
|
||||
if last_ussd_session:
|
||||
return resume_last_ussd_session(last_ussd_session.state)
|
||||
|
||||
elif not account.has_preferred_language():
|
||||
return UssdMenu.find_by_name('initial_language_selection')
|
||||
else:
|
||||
return UssdMenu.find_by_name('initial_pin_entry')
|
||||
|
||||
|
||||
def get_menu(account: Account,
|
||||
session: Session,
|
||||
user_input: str,
|
||||
ussd_session: Optional[dict]) -> Document:
|
||||
"""
|
||||
:param account:
|
||||
:type account:
|
||||
:param session:
|
||||
:type session:
|
||||
:param user_input:
|
||||
:type user_input:
|
||||
:param ussd_session:
|
||||
:type ussd_session:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
user_input = latest_input(user_input)
|
||||
if not ussd_session:
|
||||
return handle_menu(account, session)
|
||||
if user_input == '':
|
||||
return UssdMenu.find_by_name(name='exit_invalid_input')
|
||||
if user_input == '0':
|
||||
return UssdMenu.parent_menu(ussd_session.get('state'))
|
||||
session = SessionBase.bind_session(session)
|
||||
state = next_state(account, session, user_input, ussd_session)
|
||||
return UssdMenu.find_by_name(state)
|
||||
|
||||
|
||||
def handle_menu_operations(chain_str: str,
|
||||
external_session_id: str,
|
||||
phone_number: str,
|
||||
queue: str,
|
||||
service_code: str,
|
||||
session,
|
||||
user_input: str):
|
||||
"""
|
||||
:param chain_str:
|
||||
:type chain_str:
|
||||
:param external_session_id:
|
||||
:type external_session_id:
|
||||
:param phone_number:
|
||||
:type phone_number:
|
||||
:param queue:
|
||||
:type queue:
|
||||
:param service_code:
|
||||
:type service_code:
|
||||
:param session:
|
||||
:type session:
|
||||
:param user_input:
|
||||
:type user_input:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
session = SessionBase.bind_session(session=session)
|
||||
account: Account = Account.get_by_phone_number(phone_number, session)
|
||||
if account:
|
||||
return handle_account_menu_operations(account, external_session_id, queue, session, service_code, user_input)
|
||||
create(chain_str, phone_number, session)
|
||||
menu = UssdMenu.find_by_name('account_creation_prompt')
|
||||
preferred_language = i18n.config.get('fallback')
|
||||
create_or_update_session(
|
||||
external_session_id=external_session_id,
|
||||
msisdn=phone_number,
|
||||
service_code=service_code,
|
||||
state=menu.get('name'),
|
||||
session=session,
|
||||
user_input=user_input)
|
||||
persist_ussd_session(external_session_id, queue)
|
||||
return translation_for('ussd.kenya.account_creation_prompt', preferred_language)
|
||||
|
||||
|
||||
def handle_account_menu_operations(account: Account,
|
||||
external_session_id: str,
|
||||
queue: str,
|
||||
session: Session,
|
||||
service_code: str,
|
||||
user_input: str):
|
||||
"""
|
||||
:param account:
|
||||
:type account:
|
||||
:param external_session_id:
|
||||
:type external_session_id:
|
||||
:param queue:
|
||||
:type queue:
|
||||
:param session:
|
||||
:type session:
|
||||
:param service_code:
|
||||
:type service_code:
|
||||
:param user_input:
|
||||
:type user_input:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
phone_number = account.phone_number
|
||||
s_query_person_metadata = celery.signature(
|
||||
'cic_ussd.tasks.metadata.query_person_metadata', [account.blockchain_address], queue='cic-ussd')
|
||||
s_query_person_metadata.apply_async()
|
||||
s_query_preferences_metadata = celery.signature(
|
||||
'cic_ussd.tasks.metadata.query_preferences_metadata', [account.blockchain_address], queue='cic-ussd')
|
||||
s_query_preferences_metadata.apply_async()
|
||||
existing_ussd_session = session.query(UssdSession).filter_by(external_session_id=external_session_id).first()
|
||||
last_ussd_session = UssdSession.last_ussd_session(phone_number, session)
|
||||
if existing_ussd_session:
|
||||
menu = get_menu(account, session, user_input, existing_ussd_session.to_json())
|
||||
else:
|
||||
menu = get_menu(account, session, user_input, None)
|
||||
if last_ussd_session:
|
||||
ussd_session = create_or_update_session(
|
||||
external_session_id, phone_number, service_code, user_input, menu.get('name'), session,
|
||||
last_ussd_session.data
|
||||
)
|
||||
else:
|
||||
ussd_session = create_or_update_session(
|
||||
external_session_id, phone_number, service_code, user_input, menu.get('name'), session, None
|
||||
)
|
||||
menu_response = response(
|
||||
account, menu.get('display_key'), menu.get('name'), session, ussd_session.to_json()
|
||||
)
|
||||
if not is_valid_response(menu_response):
|
||||
raise ValueError(f'Invalid response: {response}')
|
||||
persist_ussd_session(external_session_id, queue)
|
||||
return menu_response
|
||||
|
||||
|
||||
def next_state(account: Account, session, user_input: str, ussd_session: dict) -> str:
|
||||
"""
|
||||
:param account:
|
||||
:type account:
|
||||
:param session:
|
||||
:type session:
|
||||
:param user_input:
|
||||
:type user_input:
|
||||
:param ussd_session:
|
||||
:type ussd_session:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
state_machine = UssdStateMachine(ussd_session=ussd_session)
|
||||
state_machine.scan_data((user_input, ussd_session, account, session))
|
||||
return state_machine.state
|
77
apps/cic-ussd/cic_ussd/processor/util.py
Normal file
77
apps/cic-ussd/cic_ussd/processor/util.py
Normal file
@ -0,0 +1,77 @@
|
||||
# standard imports
|
||||
import datetime
|
||||
import json
|
||||
|
||||
# external imports
|
||||
from cic_types.models.person import get_contact_data_from_vcard
|
||||
from tinydb.table import Document
|
||||
|
||||
# local imports
|
||||
from cic_ussd.menu.ussd_menu import UssdMenu
|
||||
from cic_ussd.translation import translation_for
|
||||
|
||||
|
||||
def latest_input(user_input: str) -> str:
|
||||
"""
|
||||
:param user_input:
|
||||
:type user_input:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
return user_input.split('*')[-1]
|
||||
|
||||
|
||||
def parse_person_metadata(cached_metadata: str, display_key: str, preferred_language: str) -> str:
|
||||
"""This function extracts person metadata formatted to suite display on the ussd interface.
|
||||
:param cached_metadata: Person metadata JSON str.
|
||||
:type cached_metadata: str
|
||||
:param display_key: Path to an entry in menu data in translation files.
|
||||
:type display_key: str
|
||||
:param preferred_language: An account's set preferred language.
|
||||
:type preferred_language: str
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
user_metadata = json.loads(cached_metadata)
|
||||
contact_data = get_contact_data_from_vcard(user_metadata.get('vcard'))
|
||||
full_name = f'{contact_data.get("given")} {contact_data.get("family")}'
|
||||
date_of_birth = user_metadata.get('date_of_birth')
|
||||
year_of_birth = date_of_birth.get('year')
|
||||
present_year = datetime.datetime.now().year
|
||||
age = present_year - year_of_birth
|
||||
gender = user_metadata.get('gender')
|
||||
products = ', '.join(user_metadata.get('products'))
|
||||
location = user_metadata.get('location').get('area_name')
|
||||
|
||||
return translation_for(
|
||||
key=display_key,
|
||||
preferred_language=preferred_language,
|
||||
full_name=full_name,
|
||||
age=age,
|
||||
gender=gender,
|
||||
location=location,
|
||||
products=products
|
||||
)
|
||||
|
||||
|
||||
def resume_last_ussd_session(last_state: str) -> Document:
|
||||
"""
|
||||
:param last_state:
|
||||
:type last_state:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
# TODO [Philip]: This can be cleaned further
|
||||
non_reusable_states = [
|
||||
'account_creation_prompt',
|
||||
'exit',
|
||||
'exit_invalid_pin',
|
||||
'exit_invalid_new_pin',
|
||||
'exit_invalid_request',
|
||||
'exit_pin_blocked',
|
||||
'exit_pin_mismatch',
|
||||
'exit_successful_transaction'
|
||||
]
|
||||
if last_state in non_reusable_states:
|
||||
return UssdMenu.find_by_name('start')
|
||||
return UssdMenu.find_by_name(last_state)
|
@ -1,143 +0,0 @@
|
||||
# standard imports
|
||||
from typing import Optional, Tuple, Union
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional, Union
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
# third-party imports
|
||||
from sqlalchemy import desc
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db.models.account import Account
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.db.enum import AccountStatus
|
||||
from cic_ussd.operations import get_account_status, reset_pin
|
||||
from cic_ussd.validator import check_known_user
|
||||
|
||||
|
||||
logg = logging.getLogger(__file__)
|
||||
|
||||
|
||||
def get_query_parameters(env: dict, query_name: Optional[str] = None) -> Union[dict, str]:
|
||||
"""Gets value of the request query parameters.
|
||||
:param env: Object containing server and request information.
|
||||
:type env: dict
|
||||
:param query_name: The specific query parameter to fetch.
|
||||
:type query_name: str
|
||||
:return: Query parameters from the request.
|
||||
:rtype: dict | str
|
||||
"""
|
||||
parsed_url = urlparse(env.get('REQUEST_URI'))
|
||||
params = parse_qs(parsed_url.query)
|
||||
if query_name:
|
||||
param = params.get(query_name)[0]
|
||||
return param
|
||||
return params
|
||||
|
||||
|
||||
def get_request_endpoint(env: dict) -> str:
|
||||
"""Gets value of the request url path.
|
||||
:param env: Object containing server and request information
|
||||
:type env: dict
|
||||
:return: Endpoint that has been touched by the call
|
||||
:rtype: str
|
||||
"""
|
||||
return env.get('PATH_INFO')
|
||||
|
||||
|
||||
def get_request_method(env: dict) -> str:
|
||||
"""Gets value of the request method.
|
||||
:param env: Object containing server and request information.
|
||||
:type env: dict
|
||||
:return: Request method.
|
||||
:rtype: str
|
||||
"""
|
||||
return env.get('REQUEST_METHOD').upper()
|
||||
|
||||
|
||||
def get_account_creation_callback_request_data(env: dict) -> tuple:
|
||||
"""This function retrieves data from a callback
|
||||
:param env: Object containing server and request information.
|
||||
:type env: dict
|
||||
:return: A tuple containing the status, result and task_id for a celery task spawned to create a blockchain
|
||||
account.
|
||||
:rtype: tuple
|
||||
"""
|
||||
|
||||
callback_data = env.get('wsgi.input')
|
||||
status = callback_data.get('status')
|
||||
task_id = callback_data.get('root_id')
|
||||
result = callback_data.get('result')
|
||||
|
||||
return status, task_id, result
|
||||
|
||||
|
||||
def process_pin_reset_requests(env: dict, phone_number: str, session: Session):
|
||||
"""This function processes requests that are responsible for the pin reset functionality. It processes GET and PUT
|
||||
requests responsible for returning an account's status and
|
||||
:param env: A dictionary of values representing data sent on the api.
|
||||
:type env: dict
|
||||
:param phone_number: The phone of the user whose pin is being reset.
|
||||
:type phone_number: str
|
||||
:param session:
|
||||
:type session:
|
||||
:return: A response denoting the result of the request to reset the user's pin.
|
||||
:rtype: str
|
||||
"""
|
||||
if not check_known_user(phone_number=phone_number, session=session):
|
||||
return f'No user matching {phone_number} was found.', '404 Not Found'
|
||||
|
||||
if get_request_method(env) == 'PUT':
|
||||
return reset_pin(phone_number=phone_number, session=session), '200 OK'
|
||||
|
||||
if get_request_method(env) == 'GET':
|
||||
status = get_account_status(phone_number=phone_number, session=session)
|
||||
response = {
|
||||
'status': f'{status}'
|
||||
}
|
||||
response = json.dumps(response)
|
||||
return response, '200 OK'
|
||||
|
||||
|
||||
def process_locked_accounts_requests(env: dict, session: Session) -> tuple:
|
||||
"""This function authenticates staff requests and returns a serialized JSON formatted list of blockchain addresses
|
||||
of accounts for which the PIN has been locked due to too many failed attempts.
|
||||
:param env: A dictionary of values representing data sent on the api.
|
||||
:type env: dict
|
||||
:param session:
|
||||
:type session:
|
||||
:return: A tuple containing a serialized list of blockchain addresses for locked accounts and corresponding message
|
||||
for the response.
|
||||
:rtype: tuple
|
||||
"""
|
||||
session = SessionBase.bind_session(session=session)
|
||||
response = ''
|
||||
|
||||
if get_request_method(env) == 'GET':
|
||||
offset = 0
|
||||
limit = 100
|
||||
|
||||
locked_accounts_path = r'/accounts/locked/(\d+)?/?(\d+)?'
|
||||
r = re.match(locked_accounts_path, env.get('PATH_INFO'))
|
||||
|
||||
if r:
|
||||
if r.lastindex > 1:
|
||||
offset = r[1]
|
||||
limit = r[2]
|
||||
else:
|
||||
limit = r[1]
|
||||
|
||||
locked_accounts = session.query(Account.blockchain_address).filter(
|
||||
Account.account_status == AccountStatus.LOCKED.value,
|
||||
Account.failed_pin_attempts >= 3).order_by(desc(Account.updated)).offset(offset).limit(limit).all()
|
||||
|
||||
# convert lists to scalar blockchain addresses
|
||||
locked_accounts = [blockchain_address for (blockchain_address, ) in locked_accounts]
|
||||
|
||||
SessionBase.release_session(session=session)
|
||||
response = json.dumps(locked_accounts)
|
||||
return response, '200 OK'
|
||||
return response, '405 Play by the rules'
|
@ -5,7 +5,7 @@ requests offering control of user account states to a staff behind the client.
|
||||
|
||||
# standard imports
|
||||
import logging
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
|
||||
# third-party imports
|
||||
from confini import Config
|
||||
@ -13,12 +13,11 @@ from confini import Config
|
||||
# local imports
|
||||
from cic_ussd.db import dsn_from_config
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.operations import define_response_with_content
|
||||
from cic_ussd.requests import (get_request_endpoint,
|
||||
get_query_parameters,
|
||||
process_pin_reset_requests,
|
||||
process_locked_accounts_requests)
|
||||
from cic_ussd.http.requests import get_request_endpoint
|
||||
from cic_ussd.http.responses import with_content_headers
|
||||
from cic_ussd.http.routes import locked_accounts, handle_pin_requests
|
||||
from cic_ussd.runnable.server_base import exportable_parser, logg
|
||||
|
||||
args = exportable_parser.parse_args()
|
||||
|
||||
# define log levels
|
||||
@ -28,7 +27,7 @@ elif args.v:
|
||||
logging.getLogger().setLevel(logging.INFO)
|
||||
|
||||
# parse config
|
||||
config = Config(args.c, env_prefix=args.env_prefix)
|
||||
config = Config(args.c, args.env_prefix)
|
||||
config.process()
|
||||
config.censor('PASSWORD', 'DATABASE')
|
||||
logg.debug('config loaded from {}:\n{}'.format(args.c, config))
|
||||
@ -56,20 +55,13 @@ def application(env, start_response):
|
||||
session = SessionBase.create_session()
|
||||
|
||||
if get_request_endpoint(env) == '/pin':
|
||||
phone_number = get_query_parameters(env=env, query_name='phoneNumber')
|
||||
phone_number = quote_plus(phone_number)
|
||||
response, message = process_pin_reset_requests(env=env, phone_number=phone_number, session=session)
|
||||
response_bytes, headers = define_response_with_content(headers=errors_headers, response=response)
|
||||
session.commit()
|
||||
session.close()
|
||||
start_response(message, headers)
|
||||
return [response_bytes]
|
||||
return handle_pin_requests(env, session, errors_headers, start_response)
|
||||
|
||||
# handle requests for locked accounts
|
||||
response, message = process_locked_accounts_requests(env=env, session=session)
|
||||
response_bytes, headers = define_response_with_content(headers=headers, response=response)
|
||||
response, message = locked_accounts(env, session)
|
||||
response_bytes, headers = with_content_headers(headers, response)
|
||||
start_response(message, headers)
|
||||
session.commit()
|
||||
session.close()
|
||||
return [response_bytes]
|
||||
|
||||
|
||||
|
@ -12,13 +12,13 @@ from chainlib.chain import ChainSpec
|
||||
from confini import Config
|
||||
|
||||
# local imports
|
||||
from cic_ussd.chain import Chain
|
||||
from cic_ussd.account.chain import Chain
|
||||
from cic_ussd.cache import Cache
|
||||
from cic_ussd.db import dsn_from_config
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.metadata.signer import Signer
|
||||
from cic_ussd.metadata.base import Metadata
|
||||
from cic_ussd.phone_number import Support
|
||||
from cic_ussd.redis import InMemoryStore
|
||||
from cic_ussd.session.ussd_session import UssdSession as InMemoryUssdSession
|
||||
from cic_ussd.validator import validate_presence
|
||||
|
||||
@ -34,7 +34,8 @@ arg_parser.add_argument('-c', type=str, default=config_directory, help='config d
|
||||
arg_parser.add_argument('-q', type=str, default='cic-ussd', help='queue name for worker tasks')
|
||||
arg_parser.add_argument('-v', action='store_true', help='be verbose')
|
||||
arg_parser.add_argument('-vv', action='store_true', help='be more verbose')
|
||||
arg_parser.add_argument('--env-prefix', default=os.environ.get('CONFINI_ENV_PREFIX'), dest='env_prefix', type=str, help='environment prefix for variables to overwrite configuration')
|
||||
arg_parser.add_argument('--env-prefix', default=os.environ.get('CONFINI_ENV_PREFIX'), dest='env_prefix', type=str,
|
||||
help='environment prefix for variables to overwrite configuration')
|
||||
args = arg_parser.parse_args()
|
||||
|
||||
# define log levels
|
||||
@ -52,7 +53,8 @@ logg.debug('config loaded from {}:\n{}'.format(args.c, config))
|
||||
|
||||
# connect to database
|
||||
data_source_name = dsn_from_config(config)
|
||||
SessionBase.connect(data_source_name, pool_size=int(config.get('DATABASE_POOL_SIZE')), debug=config.true('DATABASE_DEBUG'))
|
||||
SessionBase.connect(data_source_name, pool_size=int(config.get('DATABASE_POOL_SIZE')),
|
||||
debug=config.true('DATABASE_DEBUG'))
|
||||
|
||||
# verify database connection with minimal sanity query
|
||||
session = SessionBase.create_session()
|
||||
@ -60,12 +62,12 @@ session.execute('SELECT version_num FROM alembic_version')
|
||||
session.close()
|
||||
|
||||
# define universal redis cache access
|
||||
InMemoryStore.cache = redis.StrictRedis(host=config.get('REDIS_HOSTNAME'),
|
||||
Cache.store = redis.StrictRedis(host=config.get('REDIS_HOST'),
|
||||
port=config.get('REDIS_PORT'),
|
||||
password=config.get('REDIS_PASSWORD'),
|
||||
db=config.get('REDIS_DATABASE'),
|
||||
decode_responses=True)
|
||||
InMemoryUssdSession.redis_cache = InMemoryStore.cache
|
||||
InMemoryUssdSession.store = Cache.store
|
||||
|
||||
# define metadata URL
|
||||
Metadata.base_url = config.get('CIC_META_URL')
|
||||
@ -82,8 +84,8 @@ if key_file_path:
|
||||
Signer.key_file_path = key_file_path
|
||||
|
||||
# set up translations
|
||||
i18n.load_path.append(config.get('APP_LOCALE_PATH'))
|
||||
i18n.set('fallback', config.get('APP_LOCALE_FALLBACK'))
|
||||
i18n.load_path.append(config.get('LOCALE_PATH'))
|
||||
i18n.set('fallback', config.get('LOCALE_FALLBACK'))
|
||||
|
||||
chain_spec = ChainSpec(
|
||||
common_name=config.get('CIC_COMMON_NAME'),
|
||||
@ -92,8 +94,7 @@ chain_spec = ChainSpec(
|
||||
)
|
||||
|
||||
Chain.spec = chain_spec
|
||||
Support.phone_number = config.get('APP_SUPPORT_PHONE_NUMBER')
|
||||
|
||||
Support.phone_number = config.get('OFFICE_SUPPORT_PHONE')
|
||||
# set up celery
|
||||
current_app = celery.Celery(__name__)
|
||||
|
||||
@ -147,4 +148,3 @@ def main():
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
|
@ -14,26 +14,25 @@ from chainlib.chain import ChainSpec
|
||||
from confini import Config
|
||||
|
||||
# local imports
|
||||
from cic_ussd.chain import Chain
|
||||
from cic_ussd.account.chain import Chain
|
||||
from cic_ussd.account.tokens import query_default_token
|
||||
from cic_ussd.cache import cache_data, cache_data_key, Cache
|
||||
from cic_ussd.db import dsn_from_config
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.encoder import PasswordEncoder
|
||||
from cic_ussd.error import InitializationError
|
||||
from cic_ussd.files.local_files import create_local_file_data_stores, json_file_parser
|
||||
from cic_ussd.http.requests import get_request_endpoint, get_request_method
|
||||
from cic_ussd.http.responses import with_content_headers
|
||||
from cic_ussd.menu.ussd_menu import UssdMenu
|
||||
from cic_ussd.metadata.signer import Signer
|
||||
from cic_ussd.metadata.base import Metadata
|
||||
from cic_ussd.operations import (define_response_with_content,
|
||||
process_menu_interaction_requests,
|
||||
define_multilingual_responses)
|
||||
from cic_ussd.metadata.signer import Signer
|
||||
from cic_ussd.phone_number import process_phone_number, Support, E164Format
|
||||
from cic_ussd.processor import get_default_token_data
|
||||
from cic_ussd.redis import cache_data, create_cached_data_key, InMemoryStore
|
||||
from cic_ussd.requests import (get_request_endpoint,
|
||||
get_request_method)
|
||||
from cic_ussd.processor.ussd import handle_menu_operations
|
||||
from cic_ussd.runnable.server_base import exportable_parser, logg
|
||||
from cic_ussd.session.ussd_session import UssdSession as InMemoryUssdSession
|
||||
from cic_ussd.state_machine import UssdStateMachine
|
||||
from cic_ussd.translation import translation_for
|
||||
from cic_ussd.validator import check_ip, check_request_content_length, validate_phone_number, validate_presence
|
||||
|
||||
args = exportable_parser.parse_args()
|
||||
@ -57,8 +56,8 @@ SessionBase.connect(data_source_name,
|
||||
debug=config.true('DATABASE_DEBUG'))
|
||||
|
||||
# set up translations
|
||||
i18n.load_path.append(config.get('APP_LOCALE_PATH'))
|
||||
i18n.set('fallback', config.get('APP_LOCALE_FALLBACK'))
|
||||
i18n.load_path.append(config.get('LOCALE_PATH'))
|
||||
i18n.set('fallback', config.get('LOCALE_FALLBACK'))
|
||||
|
||||
# set Fernet key
|
||||
PasswordEncoder.set_key(config.get('APP_PASSWORD_PEPPER'))
|
||||
@ -69,12 +68,12 @@ ussd_menu_db = create_local_file_data_stores(file_location=config.get('USSD_MENU
|
||||
UssdMenu.ussd_menu_db = ussd_menu_db
|
||||
|
||||
# define universal redis cache access
|
||||
InMemoryStore.cache = redis.StrictRedis(host=config.get('REDIS_HOSTNAME'),
|
||||
Cache.store = redis.StrictRedis(host=config.get('REDIS_HOST'),
|
||||
port=config.get('REDIS_PORT'),
|
||||
password=config.get('REDIS_PASSWORD'),
|
||||
db=config.get('REDIS_DATABASE'),
|
||||
decode_responses=True)
|
||||
InMemoryUssdSession.redis_cache = InMemoryStore.cache
|
||||
InMemoryUssdSession.store = Cache.store
|
||||
|
||||
# define metadata URL
|
||||
Metadata.base_url = config.get('CIC_META_URL')
|
||||
@ -94,8 +93,8 @@ Signer.key_file_path = key_file_path
|
||||
celery.Celery(backend=config.get('CELERY_RESULT_URL'), broker=config.get('CELERY_BROKER_URL'))
|
||||
|
||||
# load states and transitions data
|
||||
states = json_file_parser(filepath=config.get('STATEMACHINE_STATES'))
|
||||
transitions = json_file_parser(filepath=config.get('STATEMACHINE_TRANSITIONS'))
|
||||
states = json_file_parser(filepath=config.get('MACHINE_STATES'))
|
||||
transitions = json_file_parser(filepath=config.get('MACHINE_TRANSITIONS'))
|
||||
|
||||
chain_spec = ChainSpec(
|
||||
common_name=config.get('CIC_COMMON_NAME'),
|
||||
@ -108,24 +107,22 @@ UssdStateMachine.states = states
|
||||
UssdStateMachine.transitions = transitions
|
||||
|
||||
# retrieve default token data
|
||||
default_token_data = get_default_token_data()
|
||||
chain_str = Chain.spec.__str__()
|
||||
default_token_data = query_default_token(chain_str)
|
||||
|
||||
|
||||
# cache default token for re-usability
|
||||
if default_token_data:
|
||||
cache_key = create_cached_data_key(
|
||||
identifier=chain_str.encode('utf-8'),
|
||||
salt=':cic.default_token_data'
|
||||
)
|
||||
cache_key = cache_data_key(chain_str.encode('utf-8'), ':cic.default_token_data')
|
||||
cache_data(key=cache_key, data=json.dumps(default_token_data))
|
||||
else:
|
||||
raise InitializationError(f'Default token data for: {chain_str} not found.')
|
||||
|
||||
|
||||
valid_service_codes = config.get('APP_SERVICE_CODE').split(",")
|
||||
valid_service_codes = config.get('USSD_SERVICE_CODE').split(",")
|
||||
|
||||
E164Format.region = config.get('PHONE_NUMBER_REGION')
|
||||
Support.phone_number = config.get('APP_SUPPORT_PHONE_NUMBER')
|
||||
E164Format.region = config.get('E164_REGION')
|
||||
Support.phone_number = config.get('OFFICE_SUPPORT_PHONE')
|
||||
|
||||
|
||||
def application(env, start_response):
|
||||
@ -168,49 +165,37 @@ def application(env, start_response):
|
||||
except TypeError:
|
||||
user_input = ""
|
||||
|
||||
# add validation for phone number
|
||||
if phone_number:
|
||||
phone_number = process_phone_number(phone_number=phone_number, region=E164Format.region)
|
||||
|
||||
# validate ip address
|
||||
if not check_ip(config=config, env=env):
|
||||
start_response('403 Sneaky, sneaky', errors_headers)
|
||||
return []
|
||||
|
||||
# validate content length
|
||||
if not check_request_content_length(config=config, env=env):
|
||||
start_response('400 Size matters', errors_headers)
|
||||
return []
|
||||
|
||||
# validate service code
|
||||
if service_code not in valid_service_codes:
|
||||
response = define_multilingual_responses(
|
||||
key='ussd.kenya.invalid_service_code',
|
||||
locales=['en', 'sw'],
|
||||
prefix='END',
|
||||
valid_service_code=valid_service_codes[0])
|
||||
response_bytes, headers = define_response_with_content(headers=headers, response=response)
|
||||
response = translation_for(
|
||||
'ussd.kenya.invalid_service_code',
|
||||
i18n.config.get('fallback'),
|
||||
valid_service_code=valid_service_codes[0]
|
||||
)
|
||||
response_bytes, headers = with_content_headers(headers, response)
|
||||
start_response('200 OK', headers)
|
||||
return [response_bytes]
|
||||
|
||||
# validate phone number
|
||||
if not validate_phone_number(phone_number):
|
||||
logg.error('invalid phone number {}'.format(phone_number))
|
||||
start_response('400 Invalid phone number format', errors_headers)
|
||||
return []
|
||||
logg.debug('session {} started for {}'.format(external_session_id, phone_number))
|
||||
|
||||
# handle menu interaction requests
|
||||
chain_str = chain_spec.__str__()
|
||||
response = process_menu_interaction_requests(chain_str=chain_str,
|
||||
external_session_id=external_session_id,
|
||||
phone_number=phone_number,
|
||||
queue=args.q,
|
||||
service_code=service_code,
|
||||
session=session,
|
||||
user_input=user_input)
|
||||
|
||||
response_bytes, headers = define_response_with_content(headers=headers, response=response)
|
||||
response = handle_menu_operations(
|
||||
chain_str, external_session_id, phone_number, args.q, service_code, session, user_input
|
||||
)
|
||||
response_bytes, headers = with_content_headers(headers, response)
|
||||
start_response('200 OK,', headers)
|
||||
session.commit()
|
||||
session.close()
|
||||
@ -223,4 +208,3 @@ def application(env, start_response):
|
||||
session.close()
|
||||
start_response('405 Play by the rules', errors_headers)
|
||||
return []
|
||||
|
||||
|
@ -3,9 +3,15 @@ import logging
|
||||
from typing import Optional
|
||||
import json
|
||||
|
||||
# third party imports
|
||||
# external imports
|
||||
import celery
|
||||
from redis import Redis
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
# local imports
|
||||
from cic_ussd.cache import Cache
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.db.models.ussd_session import UssdSession as DbUssdSession
|
||||
|
||||
logg = logging.getLogger()
|
||||
|
||||
@ -13,18 +19,18 @@ logg = logging.getLogger()
|
||||
class UssdSession:
|
||||
"""
|
||||
This class defines the USSD session object that is called whenever a user interacts with the system.
|
||||
:cvar redis_cache: The in-memory redis cache.
|
||||
:type redis_cache: Redis
|
||||
:cvar store: The in-memory redis cache.
|
||||
:type store: Redis
|
||||
"""
|
||||
redis_cache: Redis = None
|
||||
store: Redis = None
|
||||
|
||||
def __init__(self,
|
||||
external_session_id: str,
|
||||
service_code: str,
|
||||
msisdn: str,
|
||||
user_input: str,
|
||||
service_code: str,
|
||||
state: str,
|
||||
session_data: Optional[dict] = None):
|
||||
user_input: str,
|
||||
data: Optional[dict] = None):
|
||||
"""
|
||||
This function is called whenever a USSD session object is created and saves the instance to a JSON DB.
|
||||
:param external_session_id: The Africa's Talking session ID.
|
||||
@ -37,16 +43,17 @@ class UssdSession:
|
||||
:type user_input: str.
|
||||
:param state: The name of the USSD menu that the user was interacting with.
|
||||
:type state: str.
|
||||
:param session_data: Any additional data that was persisted during the user's interaction with the system.
|
||||
:type session_data: dict.
|
||||
:param data: Any additional data that was persisted during the user's interaction with the system.
|
||||
:type data: dict.
|
||||
"""
|
||||
self.data = data
|
||||
self.external_session_id = external_session_id
|
||||
self.service_code = service_code
|
||||
self.msisdn = msisdn
|
||||
self.user_input = user_input
|
||||
self.service_code = service_code
|
||||
self.state = state
|
||||
self.session_data = session_data
|
||||
session = self.redis_cache.get(external_session_id)
|
||||
self.user_input = user_input
|
||||
|
||||
session = self.store.get(external_session_id)
|
||||
if session:
|
||||
session = json.loads(session)
|
||||
self.version = session.get('version') + 1
|
||||
@ -54,16 +61,16 @@ class UssdSession:
|
||||
self.version = 1
|
||||
|
||||
self.session = {
|
||||
'data': self.data,
|
||||
'external_session_id': self.external_session_id,
|
||||
'service_code': self.service_code,
|
||||
'msisdn': self.msisdn,
|
||||
'user_input': self.user_input,
|
||||
'service_code': self.service_code,
|
||||
'state': self.state,
|
||||
'session_data': self.session_data,
|
||||
'user_input': self.user_input,
|
||||
'version': self.version
|
||||
}
|
||||
self.redis_cache.set(self.external_session_id, json.dumps(self.session))
|
||||
self.redis_cache.persist(self.external_session_id)
|
||||
self.store.set(self.external_session_id, json.dumps(self.session))
|
||||
self.store.persist(self.external_session_id)
|
||||
|
||||
def set_data(self, key: str, value: str) -> None:
|
||||
"""
|
||||
@ -73,10 +80,10 @@ class UssdSession:
|
||||
:param value: The actual data to be stored in the session data.
|
||||
:type value: str.
|
||||
"""
|
||||
if self.session_data is None:
|
||||
self.session_data = {}
|
||||
self.session_data[key] = value
|
||||
self.redis_cache.set(self.external_session_id, json.dumps(self.session))
|
||||
if self.data is None:
|
||||
self.data = {}
|
||||
self.data[key] = value
|
||||
self.store.set(self.external_session_id, json.dumps(self.session))
|
||||
|
||||
def get_data(self, key: str) -> Optional[str]:
|
||||
"""
|
||||
@ -86,8 +93,8 @@ class UssdSession:
|
||||
:return: This function returns the queried data if found, else it doesn't return any value.
|
||||
:rtype: str.
|
||||
"""
|
||||
if self.session_data is not None:
|
||||
return self.session_data.get(key)
|
||||
if self.data is not None:
|
||||
return self.data.get(key)
|
||||
else:
|
||||
return None
|
||||
|
||||
@ -97,11 +104,155 @@ class UssdSession:
|
||||
:rtype: dict
|
||||
"""
|
||||
return {
|
||||
"data": self.data,
|
||||
"external_session_id": self.external_session_id,
|
||||
"service_code": self.service_code,
|
||||
"msisdn": self.msisdn,
|
||||
"user_input": self.user_input,
|
||||
"service_code": self.service_code,
|
||||
"state": self.state,
|
||||
"session_data": self.session_data,
|
||||
"version": self.version
|
||||
}
|
||||
|
||||
|
||||
def create_ussd_session(
|
||||
state: str,
|
||||
external_session_id: str,
|
||||
msisdn: str,
|
||||
service_code: str,
|
||||
user_input: str,
|
||||
data: Optional[dict] = None) -> UssdSession:
|
||||
"""
|
||||
:param state:
|
||||
:type state:
|
||||
:param external_session_id:
|
||||
:type external_session_id:
|
||||
:param msisdn:
|
||||
:type msisdn:
|
||||
:param service_code:
|
||||
:type service_code:
|
||||
:param user_input:
|
||||
:type user_input:
|
||||
:param data:
|
||||
:type data:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
return UssdSession(external_session_id=external_session_id,
|
||||
msisdn=msisdn,
|
||||
user_input=user_input,
|
||||
state=state,
|
||||
service_code=service_code,
|
||||
data=data
|
||||
)
|
||||
|
||||
|
||||
def update_ussd_session(ussd_session: UssdSession,
|
||||
user_input: str,
|
||||
state: str,
|
||||
data: Optional[dict] = None) -> UssdSession:
|
||||
""""""
|
||||
if data is None:
|
||||
data = ussd_session.data
|
||||
|
||||
return UssdSession(
|
||||
external_session_id=ussd_session.external_session_id,
|
||||
msisdn=ussd_session.msisdn,
|
||||
user_input=user_input,
|
||||
state=state,
|
||||
service_code=ussd_session.service_code,
|
||||
data=data
|
||||
)
|
||||
|
||||
|
||||
def create_or_update_session(external_session_id: str,
|
||||
msisdn: str,
|
||||
service_code: str,
|
||||
user_input: str,
|
||||
state: str,
|
||||
session,
|
||||
data: Optional[dict] = None) -> UssdSession:
|
||||
"""
|
||||
:param external_session_id:
|
||||
:type external_session_id:
|
||||
:param msisdn:
|
||||
:type msisdn:
|
||||
:param service_code:
|
||||
:type service_code:
|
||||
:param user_input:
|
||||
:type user_input:
|
||||
:param state:
|
||||
:type state:
|
||||
:param session:
|
||||
:type session:
|
||||
:param data:
|
||||
:type data:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
session = SessionBase.bind_session(session=session)
|
||||
existing_ussd_session = session.query(DbUssdSession).filter_by(
|
||||
external_session_id=external_session_id).first()
|
||||
|
||||
if existing_ussd_session:
|
||||
ussd_session = update_ussd_session(ussd_session=existing_ussd_session,
|
||||
state=state,
|
||||
user_input=user_input,
|
||||
data=data
|
||||
)
|
||||
else:
|
||||
ussd_session = create_ussd_session(external_session_id=external_session_id,
|
||||
msisdn=msisdn,
|
||||
service_code=service_code,
|
||||
user_input=user_input,
|
||||
state=state,
|
||||
data=data
|
||||
)
|
||||
SessionBase.release_session(session=session)
|
||||
return ussd_session
|
||||
|
||||
|
||||
def persist_ussd_session(external_session_id: str, queue: Optional[str]):
|
||||
"""This function asynchronously retrieves a cached ussd session object matching an external ussd session id and adds
|
||||
it to persistent storage.
|
||||
:param external_session_id: Session id value provided by ussd service provided.
|
||||
:type external_session_id: str
|
||||
:param queue: Name of worker queue to submit tasks to.
|
||||
:type queue: str
|
||||
"""
|
||||
s_persist_ussd_session = celery.signature(
|
||||
'cic_ussd.tasks.ussd_session.persist_session_to_db',
|
||||
[external_session_id],
|
||||
queue=queue
|
||||
)
|
||||
s_persist_ussd_session.apply_async()
|
||||
|
||||
|
||||
def save_session_data(queue: Optional[str], session: Session, data: dict, ussd_session: dict):
|
||||
"""This function is stores information to the session data attribute of a cached ussd session object.
|
||||
:param data: A dictionary containing data for a specific ussd session in redis that needs to be saved
|
||||
temporarily.
|
||||
:type data: dict
|
||||
:param queue: The queue on which the celery task should run.
|
||||
:type queue: str
|
||||
:param session: Database session object.
|
||||
:type session: Session
|
||||
:param ussd_session: A ussd session passed to the state machine.
|
||||
:type ussd_session: UssdSession
|
||||
"""
|
||||
cache = Cache.store
|
||||
external_session_id = ussd_session.get('external_session_id')
|
||||
existing_session_data = ussd_session.get('data')
|
||||
if existing_session_data:
|
||||
data = {**existing_session_data, **data}
|
||||
in_redis_ussd_session = cache.get(external_session_id)
|
||||
in_redis_ussd_session = json.loads(in_redis_ussd_session)
|
||||
create_or_update_session(
|
||||
external_session_id=external_session_id,
|
||||
msisdn=in_redis_ussd_session.get('msisdn'),
|
||||
service_code=in_redis_ussd_session.get('service_code'),
|
||||
user_input=in_redis_ussd_session.get('user_input'),
|
||||
state=in_redis_ussd_session.get('state'),
|
||||
session=session,
|
||||
data=data
|
||||
)
|
||||
persist_ussd_session(external_session_id=external_session_id, queue=queue)
|
||||
|
248
apps/cic-ussd/cic_ussd/state_machine/logic/account.py
Normal file
248
apps/cic-ussd/cic_ussd/state_machine/logic/account.py
Normal file
@ -0,0 +1,248 @@
|
||||
# standard imports
|
||||
import json
|
||||
import logging
|
||||
from typing import Tuple
|
||||
|
||||
# third-party imports
|
||||
import celery
|
||||
import i18n
|
||||
from chainlib.hash import strip_0x
|
||||
from cic_types.models.person import get_contact_data_from_vcard, generate_vcard_from_contact_data, manage_identity_data
|
||||
|
||||
# local imports
|
||||
from cic_ussd.account.chain import Chain
|
||||
from cic_ussd.account.maps import gender, language
|
||||
from cic_ussd.account.metadata import get_cached_preferred_language
|
||||
from cic_ussd.db.models.account import Account
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.error import MetadataNotFoundError
|
||||
from cic_ussd.metadata import PersonMetadata
|
||||
from cic_ussd.session.ussd_session import save_session_data
|
||||
from cic_ussd.translation import translation_for
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
logg = logging.getLogger(__file__)
|
||||
|
||||
|
||||
def change_preferred_language(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||
"""
|
||||
:param state_machine_data:
|
||||
:type state_machine_data:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
r_user_input = language().get(user_input)
|
||||
session = SessionBase.bind_session(session)
|
||||
account.preferred_language = r_user_input
|
||||
session.add(account)
|
||||
session.flush()
|
||||
SessionBase.release_session(session)
|
||||
|
||||
preferences_data = {
|
||||
'preferred_language': r_user_input
|
||||
}
|
||||
|
||||
s = celery.signature(
|
||||
'cic_ussd.tasks.metadata.add_preferences_metadata',
|
||||
[account.blockchain_address, preferences_data],
|
||||
queue='cic-ussd'
|
||||
)
|
||||
return s.apply_async()
|
||||
|
||||
|
||||
def update_account_status_to_active(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||
"""This function sets user's account to active.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
"""
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
session = SessionBase.bind_session(session=session)
|
||||
account.activate_account()
|
||||
session.add(account)
|
||||
session.flush()
|
||||
SessionBase.release_session(session=session)
|
||||
|
||||
|
||||
def parse_gender(account: Account, user_input: str):
|
||||
"""
|
||||
:param account:
|
||||
:type account:
|
||||
:param user_input:
|
||||
:type user_input:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
preferred_language = get_cached_preferred_language(account.blockchain_address)
|
||||
if not preferred_language:
|
||||
preferred_language = i18n.config.get('fallback')
|
||||
r_user_input = gender().get(user_input)
|
||||
return translation_for(f'helpers.{r_user_input}', preferred_language)
|
||||
|
||||
|
||||
def save_metadata_attribute_to_session_data(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||
"""This function saves first name data to the ussd session in the redis cache.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
"""
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
session = SessionBase.bind_session(session=session)
|
||||
current_state = ussd_session.get('state')
|
||||
|
||||
key = ''
|
||||
if 'given_name' in current_state:
|
||||
key = 'given_name'
|
||||
|
||||
if 'date_of_birth' in current_state:
|
||||
key = 'date_of_birth'
|
||||
|
||||
if 'family_name' in current_state:
|
||||
key = 'family_name'
|
||||
|
||||
if 'gender' in current_state:
|
||||
key = 'gender'
|
||||
user_input = parse_gender(account, user_input)
|
||||
|
||||
if 'location' in current_state:
|
||||
key = 'location'
|
||||
|
||||
if 'products' in current_state:
|
||||
key = 'products'
|
||||
|
||||
if ussd_session.get('data'):
|
||||
data = ussd_session.get('data')
|
||||
data[key] = user_input
|
||||
else:
|
||||
data = {
|
||||
key: user_input
|
||||
}
|
||||
save_session_data('cic-ussd', session, data, ussd_session)
|
||||
SessionBase.release_session(session)
|
||||
|
||||
|
||||
def parse_person_metadata(account: Account, metadata: dict):
|
||||
"""
|
||||
:param account:
|
||||
:type account:
|
||||
:param metadata:
|
||||
:type metadata:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
set_gender = metadata.get('gender')
|
||||
given_name = metadata.get('given_name')
|
||||
family_name = metadata.get('family_name')
|
||||
email = metadata.get('email')
|
||||
|
||||
if isinstance(metadata.get('date_of_birth'), dict):
|
||||
date_of_birth = metadata.get('date_of_birth')
|
||||
else:
|
||||
date_of_birth = {
|
||||
"year": int(metadata.get('date_of_birth')[:4])
|
||||
}
|
||||
if isinstance(metadata.get('location'), dict):
|
||||
location = metadata.get('location')
|
||||
else:
|
||||
location = {
|
||||
"area_name": metadata.get('location')
|
||||
}
|
||||
if isinstance(metadata.get('products'), list):
|
||||
products = metadata.get('products')
|
||||
else:
|
||||
products = metadata.get('products').split(',')
|
||||
|
||||
phone_number = account.phone_number
|
||||
date_registered = int(account.created.replace().timestamp())
|
||||
blockchain_address = account.blockchain_address
|
||||
chain_spec = f'{Chain.spec.common_name()}:{Chain.spec.engine()}: {Chain.spec.chain_id()}'
|
||||
|
||||
if isinstance(metadata.get('identities'), dict):
|
||||
identities = metadata.get('identities')
|
||||
else:
|
||||
identities = manage_identity_data(
|
||||
blockchain_address=blockchain_address,
|
||||
blockchain_type=Chain.spec.engine(),
|
||||
chain_spec=chain_spec
|
||||
)
|
||||
|
||||
return {
|
||||
"date_registered": date_registered,
|
||||
"date_of_birth": date_of_birth,
|
||||
"gender": set_gender,
|
||||
"identities": identities,
|
||||
"location": location,
|
||||
"products": products,
|
||||
"vcard": generate_vcard_from_contact_data(
|
||||
email=email,
|
||||
family_name=family_name,
|
||||
given_name=given_name,
|
||||
tel=phone_number
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def save_complete_person_metadata(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||
"""This function persists elements of the user metadata stored in session data
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
"""
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
metadata = ussd_session.get('data')
|
||||
person_metadata = parse_person_metadata(account, metadata)
|
||||
blockchain_address = account.blockchain_address
|
||||
s_create_person_metadata = celery.signature(
|
||||
'cic_ussd.tasks.metadata.create_person_metadata', [blockchain_address, person_metadata], queue='cic-ussd')
|
||||
s_create_person_metadata.apply_async()
|
||||
|
||||
|
||||
def edit_user_metadata_attribute(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||
"""
|
||||
:param state_machine_data:
|
||||
:type state_machine_data:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
blockchain_address = account.blockchain_address
|
||||
identifier = bytes.fromhex(strip_0x(blockchain_address))
|
||||
person_metadata = PersonMetadata(identifier)
|
||||
cached_person_metadata = person_metadata.get_cached_metadata()
|
||||
|
||||
if not cached_person_metadata:
|
||||
raise MetadataNotFoundError(f'Expected user metadata but found none in cache for key: {blockchain_address}')
|
||||
|
||||
person_metadata = json.loads(cached_person_metadata)
|
||||
data = ussd_session.get('data')
|
||||
contact_data = {}
|
||||
vcard = person_metadata.get('vcard')
|
||||
if vcard:
|
||||
contact_data = get_contact_data_from_vcard(vcard)
|
||||
person_metadata.pop('vcard')
|
||||
given_name = data.get('given_name') or contact_data.get('given')
|
||||
family_name = data.get('family_name') or contact_data.get('family')
|
||||
date_of_birth = data.get('date_of_birth') or person_metadata.get('date_of_birth')
|
||||
set_gender = data.get('gender') or person_metadata.get('gender')
|
||||
location = data.get('location') or person_metadata.get('location')
|
||||
products = data.get('products') or person_metadata.get('products')
|
||||
if isinstance(date_of_birth, str):
|
||||
year = int(date_of_birth)
|
||||
person_metadata['date_of_birth'] = {'year': year}
|
||||
person_metadata['gender'] = set_gender
|
||||
person_metadata['given_name'] = given_name
|
||||
person_metadata['family_name'] = family_name
|
||||
if isinstance(location, str):
|
||||
location_data = person_metadata.get('location')
|
||||
location_data['area_name'] = location
|
||||
person_metadata['location'] = location_data
|
||||
person_metadata['products'] = products
|
||||
if contact_data:
|
||||
contact_data.pop('given')
|
||||
contact_data.pop('family')
|
||||
contact_data.pop('tel')
|
||||
person_metadata = {**person_metadata, **contact_data}
|
||||
parsed_person_metadata = parse_person_metadata(account, person_metadata)
|
||||
s_edit_person_metadata = celery.signature(
|
||||
'cic_ussd.tasks.metadata.create_person_metadata',
|
||||
[blockchain_address, parsed_person_metadata]
|
||||
)
|
||||
s_edit_person_metadata.apply_async(queue='cic-ussd')
|
@ -1,21 +0,0 @@
|
||||
# standard imports
|
||||
import logging
|
||||
from typing import Tuple
|
||||
|
||||
# third-party imports
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db.models.account import Account
|
||||
|
||||
logg = logging.getLogger(__file__)
|
||||
|
||||
|
||||
def process_mini_statement_request(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||
"""This function compiles a brief statement of a user's last three inbound and outbound transactions and send the
|
||||
same as a message on their selected avenue for notification.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: str
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
logg.debug('This section requires integration with cic-eth. (The last 6 transactions would be sent as an sms.)')
|
@ -5,44 +5,47 @@ ussd menu facilitating the return of appropriate menu responses based on said us
|
||||
# standard imports
|
||||
from typing import Tuple
|
||||
|
||||
# external imports
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db.models.account import Account
|
||||
|
||||
|
||||
def menu_one_selected(state_machine_data: Tuple[str, dict, Account]) -> bool:
|
||||
def menu_one_selected(state_machine_data: Tuple[str, dict, Account, Session]) -> bool:
|
||||
"""This function checks that user input matches a string with value '1'
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: str
|
||||
:return: A user input's match with '1'
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
return user_input == '1'
|
||||
|
||||
|
||||
def menu_two_selected(state_machine_data: Tuple[str, dict, Account]) -> bool:
|
||||
def menu_two_selected(state_machine_data: Tuple[str, dict, Account, Session]) -> bool:
|
||||
"""This function checks that user input matches a string with value '2'
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
:return: A user input's match with '2'
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
return user_input == '2'
|
||||
|
||||
|
||||
def menu_three_selected(state_machine_data: Tuple[str, dict, Account]) -> bool:
|
||||
def menu_three_selected(state_machine_data: Tuple[str, dict, Account, Session]) -> bool:
|
||||
"""This function checks that user input matches a string with value '3'
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
:return: A user input's match with '3'
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
return user_input == '3'
|
||||
|
||||
|
||||
def menu_four_selected(state_machine_data: Tuple[str, dict, Account]) -> bool:
|
||||
def menu_four_selected(state_machine_data: Tuple[str, dict, Account, Session]) -> bool:
|
||||
"""
|
||||
This function checks that user input matches a string with value '4'
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
@ -50,11 +53,11 @@ def menu_four_selected(state_machine_data: Tuple[str, dict, Account]) -> bool:
|
||||
:return: A user input's match with '4'
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
return user_input == '4'
|
||||
|
||||
|
||||
def menu_five_selected(state_machine_data: Tuple[str, dict, Account]) -> bool:
|
||||
def menu_five_selected(state_machine_data: Tuple[str, dict, Account, Session]) -> bool:
|
||||
"""
|
||||
This function checks that user input matches a string with value '5'
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
@ -62,11 +65,11 @@ def menu_five_selected(state_machine_data: Tuple[str, dict, Account]) -> bool:
|
||||
:return: A user input's match with '5'
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
return user_input == '5'
|
||||
|
||||
|
||||
def menu_six_selected(state_machine_data: Tuple[str, dict, Account]) -> bool:
|
||||
def menu_six_selected(state_machine_data: Tuple[str, dict, Account, Session]) -> bool:
|
||||
"""
|
||||
This function checks that user input matches a string with value '6'
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
@ -74,11 +77,11 @@ def menu_six_selected(state_machine_data: Tuple[str, dict, Account]) -> bool:
|
||||
:return: A user input's match with '6'
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
return user_input == '6'
|
||||
|
||||
|
||||
def menu_zero_zero_selected(state_machine_data: Tuple[str, dict, Account]) -> bool:
|
||||
def menu_zero_zero_selected(state_machine_data: Tuple[str, dict, Account, Session]) -> bool:
|
||||
"""
|
||||
This function checks that user input matches a string with value '00'
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
@ -86,11 +89,11 @@ def menu_zero_zero_selected(state_machine_data: Tuple[str, dict, Account]) -> bo
|
||||
:return: A user input's match with '00'
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
return user_input == '00'
|
||||
|
||||
|
||||
def menu_ninety_nine_selected(state_machine_data: Tuple[str, dict, Account]) -> bool:
|
||||
def menu_ninety_nine_selected(state_machine_data: Tuple[str, dict, Account, Session]) -> bool:
|
||||
"""
|
||||
This function checks that user input matches a string with value '99'
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
@ -98,5 +101,5 @@ def menu_ninety_nine_selected(state_machine_data: Tuple[str, dict, Account]) ->
|
||||
:return: A user input's match with '99'
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
return user_input == '99'
|
||||
|
@ -16,8 +16,7 @@ from cic_ussd.db.models.account import Account
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.db.enum import AccountStatus
|
||||
from cic_ussd.encoder import create_password_hash, check_password_hash
|
||||
from cic_ussd.operations import persist_session_to_db_task, create_or_update_session
|
||||
from cic_ussd.redis import InMemoryStore
|
||||
from cic_ussd.session.ussd_session import create_or_update_session, persist_ussd_session
|
||||
|
||||
|
||||
logg = logging.getLogger(__file__)
|
||||
@ -31,7 +30,7 @@ def is_valid_pin(state_machine_data: Tuple[str, dict, Account, Session]) -> bool
|
||||
:return: A pin's validity
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
pin_is_valid = False
|
||||
matcher = r'^\d{4}$'
|
||||
if re.match(matcher, user_input):
|
||||
@ -46,8 +45,11 @@ def is_authorized_pin(state_machine_data: Tuple[str, dict, Account, Session]) ->
|
||||
:return: A match between two pin values.
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
return user.verify_password(password=user_input)
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
is_verified_password = account.verify_password(password=user_input)
|
||||
if not is_verified_password:
|
||||
account.failed_pin_attempts += 1
|
||||
return is_verified_password
|
||||
|
||||
|
||||
def is_locked_account(state_machine_data: Tuple[str, dict, Account, Session]) -> bool:
|
||||
@ -57,8 +59,8 @@ def is_locked_account(state_machine_data: Tuple[str, dict, Account, Session]) ->
|
||||
:return: A match between two pin values.
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
return user.get_account_status() == AccountStatus.LOCKED.name
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
return account.get_status(session) == AccountStatus.LOCKED.name
|
||||
|
||||
|
||||
def save_initial_pin_to_session_data(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||
@ -67,38 +69,25 @@ def save_initial_pin_to_session_data(state_machine_data: Tuple[str, dict, Accoun
|
||||
:type state_machine_data: tuple
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
|
||||
# define redis cache entry point
|
||||
cache = InMemoryStore.cache
|
||||
|
||||
# get external session id
|
||||
external_session_id = ussd_session.get('external_session_id')
|
||||
|
||||
# get corresponding session record
|
||||
in_redis_ussd_session = cache.get(external_session_id)
|
||||
in_redis_ussd_session = json.loads(in_redis_ussd_session)
|
||||
|
||||
# set initial pin data
|
||||
initial_pin = create_password_hash(user_input)
|
||||
if ussd_session.get('session_data'):
|
||||
session_data = ussd_session.get('session_data')
|
||||
session_data['initial_pin'] = initial_pin
|
||||
if ussd_session.get('data'):
|
||||
data = ussd_session.get('data')
|
||||
data['initial_pin'] = initial_pin
|
||||
else:
|
||||
session_data = {
|
||||
data = {
|
||||
'initial_pin': initial_pin
|
||||
}
|
||||
|
||||
# create new in memory ussd session with current ussd session data
|
||||
external_session_id = ussd_session.get('external_session_id')
|
||||
create_or_update_session(
|
||||
external_session_id=external_session_id,
|
||||
phone=in_redis_ussd_session.get('msisdn'),
|
||||
service_code=in_redis_ussd_session.get('service_code'),
|
||||
msisdn=ussd_session.get('msisdn'),
|
||||
service_code=ussd_session.get('service_code'),
|
||||
user_input=user_input,
|
||||
current_menu=in_redis_ussd_session.get('state'),
|
||||
state=ussd_session.get('state'),
|
||||
session=session,
|
||||
session_data=session_data
|
||||
data=data
|
||||
)
|
||||
persist_session_to_db_task(external_session_id=external_session_id, queue='cic-ussd')
|
||||
persist_ussd_session(external_session_id, 'cic-ussd')
|
||||
|
||||
|
||||
def pins_match(state_machine_data: Tuple[str, dict, Account, Session]) -> bool:
|
||||
@ -109,7 +98,7 @@ def pins_match(state_machine_data: Tuple[str, dict, Account, Session]) -> bool:
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
initial_pin = ussd_session.get('session_data').get('initial_pin')
|
||||
initial_pin = ussd_session.get('data').get('initial_pin')
|
||||
return check_password_hash(user_input, initial_pin)
|
||||
|
||||
|
||||
@ -120,7 +109,7 @@ def complete_pin_change(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
session = SessionBase.bind_session(session=session)
|
||||
password_hash = ussd_session.get('session_data').get('initial_pin')
|
||||
password_hash = ussd_session.get('data').get('initial_pin')
|
||||
user.password_hash = password_hash
|
||||
session.add(user)
|
||||
session.flush()
|
||||
@ -134,8 +123,8 @@ def is_blocked_pin(state_machine_data: Tuple[str, dict, Account, Session]) -> bo
|
||||
:return: A match between two pin values.
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
return user.get_account_status() == AccountStatus.LOCKED.name
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
return account.get_status(session) == AccountStatus.LOCKED.name
|
||||
|
||||
|
||||
def is_valid_new_pin(state_machine_data: Tuple[str, dict, Account, Session]) -> bool:
|
||||
|
@ -1,23 +1,28 @@
|
||||
# standard imports
|
||||
import logging
|
||||
from typing import Tuple
|
||||
|
||||
# external imports
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
# local imports
|
||||
from cic_ussd.account.metadata import get_cached_preferred_language
|
||||
from cic_ussd.account.tokens import get_default_token_symbol
|
||||
from cic_ussd.db.models.account import Account
|
||||
|
||||
logg = logging.getLogger()
|
||||
from cic_ussd.notifications import Notifier
|
||||
from cic_ussd.phone_number import Support
|
||||
|
||||
|
||||
def send_terms_to_user_if_required(state_machine_data: Tuple[str, dict, Account]):
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
logg.debug('Requires integration to cic-notify.')
|
||||
|
||||
|
||||
def process_mini_statement_request(state_machine_data: Tuple[str, dict, Account]):
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
logg.debug('Requires integration to cic-notify.')
|
||||
|
||||
|
||||
def upsell_unregistered_recipient(state_machine_data: Tuple[str, dict, Account]):
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
logg.debug('Requires integration to cic-notify.')
|
||||
def upsell_unregistered_recipient(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||
""""""
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
notifier = Notifier()
|
||||
phone_number = ussd_session.get('data')['recipient_phone_number']
|
||||
preferred_language = get_cached_preferred_language(account.blockchain_address)
|
||||
token_symbol = get_default_token_symbol()
|
||||
tx_sender_information = account.standard_metadata_id()
|
||||
notifier.send_sms_notification('sms.upsell_unregistered_recipient',
|
||||
phone_number,
|
||||
preferred_language,
|
||||
tx_sender_information=tx_sender_information,
|
||||
token_symbol=token_symbol,
|
||||
support_phone=Support.phone_number)
|
||||
|
@ -1,24 +1,21 @@
|
||||
# standard imports
|
||||
import json
|
||||
import logging
|
||||
from typing import Tuple
|
||||
|
||||
# third party imports
|
||||
import celery
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
# local imports
|
||||
from cic_ussd.balance import compute_operational_balance
|
||||
from cic_ussd.chain import Chain
|
||||
from cic_ussd.account.balance import get_cached_available_balance
|
||||
from cic_ussd.account.chain import Chain
|
||||
from cic_ussd.account.tokens import get_default_token_symbol
|
||||
from cic_ussd.account.transaction import OutgoingTransaction
|
||||
from cic_ussd.db.enum import AccountStatus
|
||||
from cic_ussd.db.models.account import Account
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.db.enum import AccountStatus
|
||||
from cic_ussd.operations import save_to_in_memory_ussd_session_data
|
||||
from cic_ussd.phone_number import process_phone_number, E164Format
|
||||
from cic_ussd.processor import retrieve_token_symbol
|
||||
from cic_ussd.redis import create_cached_data_key, get_cached_data
|
||||
from cic_ussd.transactions import OutgoingTransactionProcessor
|
||||
|
||||
from cic_ussd.session.ussd_session import save_session_data
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
logg = logging.getLogger(__file__)
|
||||
|
||||
@ -31,13 +28,15 @@ def is_valid_recipient(state_machine_data: Tuple[str, dict, Account, Session]) -
|
||||
:return: A user's validity
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
phone_number = process_phone_number(user_input, E164Format.region)
|
||||
session = SessionBase.bind_session(session=session)
|
||||
recipient = Account.get_by_phone_number(phone_number=phone_number, session=session)
|
||||
SessionBase.release_session(session=session)
|
||||
is_not_initiator = phone_number != user.phone_number
|
||||
has_active_account_status = user.get_account_status() == AccountStatus.ACTIVE.name
|
||||
is_not_initiator = phone_number != account.phone_number
|
||||
has_active_account_status = False
|
||||
if recipient:
|
||||
has_active_account_status = recipient.get_status(session) == AccountStatus.ACTIVE.name
|
||||
return is_not_initiator and has_active_account_status and recipient is not None
|
||||
|
||||
|
||||
@ -49,7 +48,7 @@ def is_valid_transaction_amount(state_machine_data: Tuple[str, dict, Account, Se
|
||||
:return: A transaction amount's validity
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
try:
|
||||
return int(user_input) > 0
|
||||
except ValueError:
|
||||
@ -64,16 +63,8 @@ def has_sufficient_balance(state_machine_data: Tuple[str, dict, Account, Session
|
||||
:return: An account balance's validity
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
# get cached balance
|
||||
key = create_cached_data_key(
|
||||
identifier=bytes.fromhex(user.blockchain_address[2:]),
|
||||
salt=':cic.balances_data'
|
||||
)
|
||||
cached_balance = get_cached_data(key=key)
|
||||
operational_balance = compute_operational_balance(balances=json.loads(cached_balance))
|
||||
|
||||
return int(user_input) <= operational_balance
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
return int(user_input) <= get_cached_available_balance(account.blockchain_address)
|
||||
|
||||
|
||||
def save_recipient_phone_to_session_data(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||
@ -81,17 +72,13 @@ def save_recipient_phone_to_session_data(state_machine_data: Tuple[str, dict, Ac
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: str
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
|
||||
session_data = ussd_session.get('session_data') or {}
|
||||
session_data = ussd_session.get('data') or {}
|
||||
recipient_phone_number = process_phone_number(phone_number=user_input, region=E164Format.region)
|
||||
session_data['recipient_phone_number'] = recipient_phone_number
|
||||
|
||||
save_to_in_memory_ussd_session_data(
|
||||
queue='cic-ussd',
|
||||
session=session,
|
||||
session_data=session_data,
|
||||
ussd_session=ussd_session)
|
||||
save_session_data('cic-ussd', session, session_data, ussd_session)
|
||||
|
||||
|
||||
def retrieve_recipient_metadata(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||
@ -101,18 +88,13 @@ def retrieve_recipient_metadata(state_machine_data: Tuple[str, dict, Account, Se
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
|
||||
recipient_phone_number = process_phone_number(phone_number=user_input, region=E164Format.region)
|
||||
recipient = Account.get_by_phone_number(phone_number=recipient_phone_number, session=session)
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
recipient_phone_number = process_phone_number(user_input, E164Format.region)
|
||||
recipient = Account.get_by_phone_number(recipient_phone_number, session)
|
||||
blockchain_address = recipient.blockchain_address
|
||||
|
||||
# retrieve and cache account's metadata
|
||||
s_query_person_metadata = celery.signature(
|
||||
'cic_ussd.tasks.metadata.query_person_metadata',
|
||||
[blockchain_address]
|
||||
)
|
||||
s_query_person_metadata.apply_async(queue='cic-ussd')
|
||||
'cic_ussd.tasks.metadata.query_person_metadata', [blockchain_address], queue='cic-ussd')
|
||||
s_query_person_metadata.apply_async()
|
||||
|
||||
|
||||
def save_transaction_amount_to_session_data(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||
@ -120,16 +102,11 @@ def save_transaction_amount_to_session_data(state_machine_data: Tuple[str, dict,
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: str
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
|
||||
session_data = ussd_session.get('session_data') or {}
|
||||
session_data = ussd_session.get('data') or {}
|
||||
session_data['transaction_amount'] = user_input
|
||||
|
||||
save_to_in_memory_ussd_session_data(
|
||||
queue='cic-ussd',
|
||||
session=session,
|
||||
session_data=session_data,
|
||||
ussd_session=ussd_session)
|
||||
save_session_data('cic-ussd', session, session_data, ussd_session)
|
||||
|
||||
|
||||
def process_transaction_request(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||
@ -137,20 +114,18 @@ def process_transaction_request(state_machine_data: Tuple[str, dict, Account, Se
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: str
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
|
||||
# retrieve token symbol
|
||||
chain_str = Chain.spec.__str__()
|
||||
|
||||
# get user from phone number
|
||||
recipient_phone_number = ussd_session.get('session_data').get('recipient_phone_number')
|
||||
recipient_phone_number = ussd_session.get('data').get('recipient_phone_number')
|
||||
recipient = Account.get_by_phone_number(phone_number=recipient_phone_number, session=session)
|
||||
to_address = recipient.blockchain_address
|
||||
from_address = user.blockchain_address
|
||||
amount = int(ussd_session.get('session_data').get('transaction_amount'))
|
||||
token_symbol = retrieve_token_symbol(chain_str=chain_str)
|
||||
from_address = account.blockchain_address
|
||||
amount = int(ussd_session.get('data').get('transaction_amount'))
|
||||
token_symbol = get_default_token_symbol()
|
||||
|
||||
outgoing_tx_processor = OutgoingTransactionProcessor(chain_str=chain_str,
|
||||
outgoing_tx_processor = OutgoingTransaction(chain_str=chain_str,
|
||||
from_address=from_address,
|
||||
to_address=to_address)
|
||||
outgoing_tx_processor.process_outgoing_transfer_transaction(amount=amount, token_symbol=token_symbol)
|
||||
outgoing_tx_processor.transfer(amount=amount, token_symbol=token_symbol)
|
||||
|
@ -1,292 +0,0 @@
|
||||
# standard imports
|
||||
import json
|
||||
import logging
|
||||
from typing import Tuple
|
||||
|
||||
# third-party imports
|
||||
import celery
|
||||
from cic_types.models.person import generate_metadata_pointer
|
||||
from cic_types.models.person import generate_vcard_from_contact_data, manage_identity_data
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
# local imports
|
||||
from cic_ussd.chain import Chain
|
||||
from cic_ussd.db.models.account import Account
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.error import MetadataNotFoundError
|
||||
from cic_ussd.metadata import blockchain_address_to_metadata_pointer
|
||||
from cic_ussd.operations import save_to_in_memory_ussd_session_data
|
||||
from cic_ussd.redis import get_cached_data
|
||||
|
||||
logg = logging.getLogger(__file__)
|
||||
|
||||
|
||||
def change_preferred_language_to_en(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||
"""This function changes the user's preferred language to english.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
session = SessionBase.bind_session(session=session)
|
||||
user.preferred_language = 'en'
|
||||
session.add(user)
|
||||
session.flush()
|
||||
SessionBase.release_session(session=session)
|
||||
|
||||
preferences_data = {
|
||||
'preferred_language': 'en'
|
||||
}
|
||||
|
||||
s = celery.signature(
|
||||
'cic_ussd.tasks.metadata.add_preferences_metadata',
|
||||
[user.blockchain_address, preferences_data]
|
||||
)
|
||||
s.apply_async(queue='cic-ussd')
|
||||
|
||||
|
||||
def change_preferred_language_to_sw(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||
"""This function changes the user's preferred language to swahili.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
"""
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
session = SessionBase.bind_session(session=session)
|
||||
account.preferred_language = 'sw'
|
||||
session.add(account)
|
||||
session.flush()
|
||||
SessionBase.release_session(session=session)
|
||||
|
||||
preferences_data = {
|
||||
'preferred_language': 'sw'
|
||||
}
|
||||
|
||||
s = celery.signature(
|
||||
'cic_ussd.tasks.metadata.add_preferences_metadata',
|
||||
[account.blockchain_address, preferences_data]
|
||||
)
|
||||
s.apply_async(queue='cic-ussd')
|
||||
|
||||
|
||||
def update_account_status_to_active(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||
"""This function sets user's account to active.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
"""
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
session = SessionBase.bind_session(session=session)
|
||||
account.activate_account()
|
||||
session.add(account)
|
||||
session.flush()
|
||||
SessionBase.release_session(session=session)
|
||||
|
||||
|
||||
def process_gender_user_input(user: Account, user_input: str):
|
||||
"""
|
||||
:param user:
|
||||
:type user:
|
||||
:param user_input:
|
||||
:type user_input:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
gender = ""
|
||||
if user.preferred_language == 'en':
|
||||
if user_input == '1':
|
||||
gender = 'Male'
|
||||
elif user_input == '2':
|
||||
gender = 'Female'
|
||||
elif user_input == '3':
|
||||
gender = 'Other'
|
||||
else:
|
||||
if user_input == '1':
|
||||
gender = 'Mwanaume'
|
||||
elif user_input == '2':
|
||||
gender = 'Mwanamke'
|
||||
elif user_input == '3':
|
||||
gender = 'Nyingine'
|
||||
return gender
|
||||
|
||||
|
||||
def save_metadata_attribute_to_session_data(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||
"""This function saves first name data to the ussd session in the redis cache.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
session = SessionBase.bind_session(session=session)
|
||||
# get current menu
|
||||
current_state = ussd_session.get('state')
|
||||
|
||||
# define session data key from current state
|
||||
key = ''
|
||||
if 'given_name' in current_state:
|
||||
key = 'given_name'
|
||||
|
||||
if 'date_of_birth' in current_state:
|
||||
key = 'date_of_birth'
|
||||
|
||||
if 'family_name' in current_state:
|
||||
key = 'family_name'
|
||||
|
||||
if 'gender' in current_state:
|
||||
key = 'gender'
|
||||
user_input = process_gender_user_input(user=user, user_input=user_input)
|
||||
|
||||
if 'location' in current_state:
|
||||
key = 'location'
|
||||
|
||||
if 'products' in current_state:
|
||||
key = 'products'
|
||||
|
||||
# check if there is existing session data
|
||||
if ussd_session.get('session_data'):
|
||||
session_data = ussd_session.get('session_data')
|
||||
session_data[key] = user_input
|
||||
else:
|
||||
session_data = {
|
||||
key: user_input
|
||||
}
|
||||
save_to_in_memory_ussd_session_data(
|
||||
queue='cic-ussd',
|
||||
session=session,
|
||||
session_data=session_data,
|
||||
ussd_session=ussd_session)
|
||||
|
||||
|
||||
def format_user_metadata(metadata: dict, user: Account):
|
||||
"""
|
||||
:param metadata:
|
||||
:type metadata:
|
||||
:param user:
|
||||
:type user:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
gender = metadata.get('gender')
|
||||
given_name = metadata.get('given_name')
|
||||
family_name = metadata.get('family_name')
|
||||
|
||||
if isinstance(metadata.get('date_of_birth'), dict):
|
||||
date_of_birth = metadata.get('date_of_birth')
|
||||
else:
|
||||
date_of_birth = {
|
||||
"year": int(metadata.get('date_of_birth')[:4])
|
||||
}
|
||||
|
||||
# check whether there's existing location data
|
||||
if isinstance(metadata.get('location'), dict):
|
||||
location = metadata.get('location')
|
||||
else:
|
||||
location = {
|
||||
"area_name": metadata.get('location')
|
||||
}
|
||||
# check whether it is a list
|
||||
if isinstance(metadata.get('products'), list):
|
||||
products = metadata.get('products')
|
||||
else:
|
||||
products = metadata.get('products').split(',')
|
||||
|
||||
phone_number = user.phone_number
|
||||
date_registered = int(user.created.replace().timestamp())
|
||||
blockchain_address = user.blockchain_address
|
||||
chain_spec = f'{Chain.spec.common_name()}:{Chain.spec.network_id()}'
|
||||
identities = manage_identity_data(
|
||||
blockchain_address=blockchain_address,
|
||||
blockchain_type=Chain.spec.engine(),
|
||||
chain_spec=chain_spec
|
||||
)
|
||||
return {
|
||||
"date_registered": date_registered,
|
||||
"date_of_birth": date_of_birth,
|
||||
"gender": gender,
|
||||
"identities": identities,
|
||||
"location": location,
|
||||
"products": products,
|
||||
"vcard": generate_vcard_from_contact_data(
|
||||
family_name=family_name,
|
||||
given_name=given_name,
|
||||
tel=phone_number
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def save_complete_user_metadata(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||
"""This function persists elements of the user metadata stored in session data
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
|
||||
# get session data
|
||||
metadata = ussd_session.get('session_data')
|
||||
|
||||
# format metadata appropriately
|
||||
user_metadata = format_user_metadata(metadata=metadata, user=user)
|
||||
|
||||
blockchain_address = user.blockchain_address
|
||||
s_create_person_metadata = celery.signature(
|
||||
'cic_ussd.tasks.metadata.create_person_metadata',
|
||||
[blockchain_address, user_metadata]
|
||||
)
|
||||
s_create_person_metadata.apply_async(queue='cic-ussd')
|
||||
|
||||
|
||||
def edit_user_metadata_attribute(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
blockchain_address = user.blockchain_address
|
||||
key = generate_metadata_pointer(
|
||||
identifier=blockchain_address_to_metadata_pointer(blockchain_address=user.blockchain_address),
|
||||
cic_type=':cic.person'
|
||||
)
|
||||
user_metadata = get_cached_data(key=key)
|
||||
|
||||
if not user_metadata:
|
||||
raise MetadataNotFoundError(f'Expected user metadata but found none in cache for key: {blockchain_address}')
|
||||
|
||||
given_name = ussd_session.get('session_data').get('given_name')
|
||||
family_name = ussd_session.get('session_data').get('family_name')
|
||||
date_of_birth = ussd_session.get('session_data').get('date_of_birth')
|
||||
gender = ussd_session.get('session_data').get('gender')
|
||||
location = ussd_session.get('session_data').get('location')
|
||||
products = ussd_session.get('session_data').get('products')
|
||||
|
||||
# validate user metadata
|
||||
user_metadata = json.loads(user_metadata)
|
||||
|
||||
# edit specific metadata attribute
|
||||
if given_name:
|
||||
user_metadata['given_name'] = given_name
|
||||
if family_name:
|
||||
user_metadata['family_name'] = family_name
|
||||
if date_of_birth and len(date_of_birth) == 4:
|
||||
year = int(date_of_birth[:4])
|
||||
user_metadata['date_of_birth'] = {
|
||||
'year': year
|
||||
}
|
||||
if gender:
|
||||
user_metadata['gender'] = gender
|
||||
if location:
|
||||
# get existing location metadata:
|
||||
location_data = user_metadata.get('location')
|
||||
location_data['area_name'] = location
|
||||
user_metadata['location'] = location_data
|
||||
if products:
|
||||
user_metadata['products'] = products
|
||||
|
||||
user_metadata = format_user_metadata(metadata=user_metadata, user=user)
|
||||
|
||||
s_edit_person_metadata = celery.signature(
|
||||
'cic_ussd.tasks.metadata.create_person_metadata',
|
||||
[blockchain_address, user_metadata]
|
||||
)
|
||||
s_edit_person_metadata.apply_async(queue='cic-ussd')
|
||||
|
||||
|
||||
def get_user_metadata(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
blockchain_address = user.blockchain_address
|
||||
s_get_user_metadata = celery.signature(
|
||||
'cic_ussd.tasks.metadata.query_person_metadata',
|
||||
[blockchain_address]
|
||||
)
|
||||
s_get_user_metadata.apply_async(queue='cic-ussd')
|
@ -4,67 +4,58 @@ import re
|
||||
from typing import Tuple
|
||||
|
||||
# third-party imports
|
||||
from cic_types.models.person import generate_metadata_pointer
|
||||
from chainlib.hash import strip_0x
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db.models.account import Account
|
||||
from cic_ussd.metadata import blockchain_address_to_metadata_pointer
|
||||
from cic_ussd.redis import get_cached_data
|
||||
from cic_ussd.metadata import PersonMetadata
|
||||
|
||||
|
||||
logg = logging.getLogger()
|
||||
|
||||
|
||||
def has_cached_user_metadata(state_machine_data: Tuple[str, dict, Account]):
|
||||
def has_cached_person_metadata(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||
"""This function checks whether the attributes of the user's metadata constituting a profile are filled out.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: str
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
# check for user metadata in cache
|
||||
key = generate_metadata_pointer(
|
||||
identifier=blockchain_address_to_metadata_pointer(blockchain_address=user.blockchain_address),
|
||||
cic_type=':cic.person'
|
||||
)
|
||||
user_metadata = get_cached_data(key=key)
|
||||
return user_metadata is not None
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
identifier = bytes.fromhex(strip_0x(account.blockchain_address))
|
||||
metadata_client = PersonMetadata(identifier)
|
||||
return metadata_client.get_cached_metadata() is not None
|
||||
|
||||
|
||||
def is_valid_name(state_machine_data: Tuple[str, dict, Account]):
|
||||
def is_valid_name(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||
"""This function checks that a user provided name is valid
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: str
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
name_matcher = "^[a-zA-Z]+$"
|
||||
valid_name = re.match(name_matcher, user_input)
|
||||
if valid_name:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
return bool(valid_name)
|
||||
|
||||
|
||||
def is_valid_gender_selection(state_machine_data: Tuple[str, dict, Account]):
|
||||
def is_valid_gender_selection(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||
"""
|
||||
:param state_machine_data:
|
||||
:type state_machine_data:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
selection_matcher = "^[1-2]$"
|
||||
if re.match(selection_matcher, user_input):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
selection_matcher = "^[1-3]$"
|
||||
return bool(re.match(selection_matcher, user_input))
|
||||
|
||||
|
||||
def is_valid_date(state_machine_data: Tuple[str, dict, Account]):
|
||||
def is_valid_date(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||
"""
|
||||
:param state_machine_data:
|
||||
:type state_machine_data:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
user_input, ussd_session, user, session = state_machine_data
|
||||
user_input, ussd_session, account, session = state_machine_data
|
||||
# For MVP this value is defaulting to year
|
||||
return len(user_input) == 4 and int(user_input) >= 1900
|
||||
|
@ -1,14 +1,13 @@
|
||||
# standard import
|
||||
|
||||
# third-party imports
|
||||
# this must be included for the package to be recognized as a tasks package
|
||||
import celery
|
||||
|
||||
celery_app = celery.current_app
|
||||
# export external celery task modules
|
||||
from .logger import *
|
||||
from .ussd_session import *
|
||||
from .callback_handler import *
|
||||
from .metadata import *
|
||||
from .notifications import *
|
||||
from .processor import *
|
||||
from .ussd_session import *
|
||||
|
||||
celery_app = celery.current_app
|
||||
|
@ -1,4 +1,5 @@
|
||||
# standard imports
|
||||
import logging
|
||||
|
||||
# third-party imports
|
||||
import celery
|
||||
@ -8,6 +9,8 @@ import sqlalchemy
|
||||
from cic_ussd.error import MetadataStoreError
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
|
||||
logg = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseTask(celery.Task):
|
||||
|
||||
|
@ -1,19 +1,22 @@
|
||||
# standard imports
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
|
||||
# third-party imports
|
||||
import celery
|
||||
from chainlib.hash import strip_0x
|
||||
|
||||
# local imports
|
||||
from cic_ussd.balance import compute_operational_balance, get_balances
|
||||
from cic_ussd.chain import Chain
|
||||
from cic_ussd.conversions import from_wei
|
||||
from cic_ussd.account.balance import get_balances, calculate_available_balance
|
||||
from cic_ussd.account.statement import generate
|
||||
from cic_ussd.cache import Cache, cache_data, cache_data_key, get_cached_data
|
||||
from cic_ussd.account.chain import Chain
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.db.models.account import Account
|
||||
from cic_ussd.error import ActionDataNotFoundError
|
||||
from cic_ussd.redis import InMemoryStore, cache_data, create_cached_data_key, get_cached_data
|
||||
from cic_ussd.account.statement import filter_statement_transactions
|
||||
from cic_ussd.account.transaction import transaction_actors
|
||||
from cic_ussd.error import AccountCreationDataNotFound
|
||||
from cic_ussd.tasks.base import CriticalSQLAlchemyTask
|
||||
|
||||
logg = logging.getLogger(__file__)
|
||||
@ -21,8 +24,10 @@ celery_app = celery.current_app
|
||||
|
||||
|
||||
@celery_app.task(bind=True, base=CriticalSQLAlchemyTask)
|
||||
def process_account_creation_callback(self, result: str, url: str, status_code: int):
|
||||
def account_creation_callback(self, result: str, url: str, status_code: int):
|
||||
"""This function defines a task that creates a user and
|
||||
:param self: Reference providing access to the callback task instance.
|
||||
:type self: celery.Task
|
||||
:param result: The blockchain address for the created account
|
||||
:type result: str
|
||||
:param url: URL provided to callback task in cic-eth should http be used for callback.
|
||||
@ -30,69 +35,147 @@ def process_account_creation_callback(self, result: str, url: str, status_code:
|
||||
:param status_code: The status of the task to create an account
|
||||
:type status_code: int
|
||||
"""
|
||||
session = SessionBase.create_session()
|
||||
cache = InMemoryStore.cache
|
||||
task_id = self.request.root_id
|
||||
task_uuid = self.request.root_id
|
||||
cached_account_creation_data = get_cached_data(task_uuid)
|
||||
|
||||
# get account creation status
|
||||
account_creation_data = cache.get(task_id)
|
||||
if not cached_account_creation_data:
|
||||
raise AccountCreationDataNotFound(f'No account creation data found for task id: {task_uuid}')
|
||||
|
||||
# check status
|
||||
if account_creation_data:
|
||||
account_creation_data = json.loads(account_creation_data)
|
||||
if status_code == 0:
|
||||
# update redis data
|
||||
if status_code != 0:
|
||||
raise ValueError(f'Unexpected status code: {status_code}')
|
||||
|
||||
account_creation_data = json.loads(cached_account_creation_data)
|
||||
account_creation_data['status'] = 'CREATED'
|
||||
cache.set(name=task_id, value=json.dumps(account_creation_data))
|
||||
cache.persist(task_id)
|
||||
cache_data(task_uuid, json.dumps(account_creation_data))
|
||||
|
||||
phone_number = account_creation_data.get('phone_number')
|
||||
|
||||
# create user
|
||||
user = Account(blockchain_address=result, phone_number=phone_number)
|
||||
session.add(user)
|
||||
session = SessionBase.create_session()
|
||||
account = Account(blockchain_address=result, phone_number=phone_number)
|
||||
session.add(account)
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
queue = self.request.delivery_info.get('routing_key')
|
||||
|
||||
# add phone number metadata lookup
|
||||
s_phone_pointer = celery.signature(
|
||||
'cic_ussd.tasks.metadata.add_phone_pointer',
|
||||
[result, phone_number]
|
||||
'cic_ussd.tasks.metadata.add_phone_pointer', [result, phone_number], queue=queue
|
||||
)
|
||||
s_phone_pointer.apply_async(queue=queue)
|
||||
s_phone_pointer.apply_async()
|
||||
|
||||
# add custom metadata tags
|
||||
custom_metadata = {
|
||||
"tags": ["ussd", "individual"]
|
||||
}
|
||||
custom_metadata = {"tags": ["ussd", "individual"]}
|
||||
s_custom_metadata = celery.signature(
|
||||
'cic_ussd.tasks.metadata.add_custom_metadata',
|
||||
[result, custom_metadata]
|
||||
'cic_ussd.tasks.metadata.add_custom_metadata', [result, custom_metadata], queue=queue
|
||||
)
|
||||
s_custom_metadata.apply_async(queue=queue)
|
||||
s_custom_metadata.apply_async()
|
||||
Cache.store.expire(task_uuid, timedelta(seconds=180))
|
||||
|
||||
# expire cache
|
||||
cache.expire(task_id, timedelta(seconds=180))
|
||||
|
||||
else:
|
||||
session.close()
|
||||
cache.expire(task_id, timedelta(seconds=180))
|
||||
@celery_app.task
|
||||
def balances_callback(result: list, param: str, status_code: int):
|
||||
"""
|
||||
:param result:
|
||||
:type result:
|
||||
:param param:
|
||||
:type param:
|
||||
:param status_code:
|
||||
:type status_code:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
if status_code != 0:
|
||||
raise ValueError(f'Unexpected status code: {status_code}.')
|
||||
|
||||
else:
|
||||
session.close()
|
||||
raise ActionDataNotFoundError(f'Account creation task: {task_id}, returned unexpected response: {status_code}')
|
||||
|
||||
session.close()
|
||||
balances = result[0]
|
||||
identifier = bytes.fromhex(strip_0x(param))
|
||||
key = cache_data_key(identifier, ':cic.balances')
|
||||
cache_data(key, json.dumps(balances))
|
||||
|
||||
|
||||
@celery_app.task(bind=True)
|
||||
def process_transaction_callback(self, result: dict, param: str, status_code: int):
|
||||
if status_code == 0:
|
||||
chain_str = Chain.spec.__str__()
|
||||
def statement_callback(self, result, param: str, status_code: int):
|
||||
"""
|
||||
:param self:
|
||||
:type self:
|
||||
:param result:
|
||||
:type result:
|
||||
:param param:
|
||||
:type param:
|
||||
:param status_code:
|
||||
:type status_code:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
if status_code != 0:
|
||||
raise ValueError(f'Unexpected status code: {status_code}.')
|
||||
|
||||
# collect transaction metadata
|
||||
queue = self.request.delivery_info.get('routing_key')
|
||||
statement_transactions = filter_statement_transactions(result)
|
||||
for transaction in statement_transactions:
|
||||
recipient_transaction, sender_transaction = transaction_actors(transaction)
|
||||
if recipient_transaction.get('blockchain_address') == param:
|
||||
generate(param, queue, recipient_transaction)
|
||||
if sender_transaction.get('blockchain_address') == param:
|
||||
generate(param, queue, sender_transaction)
|
||||
|
||||
|
||||
@celery_app.task(bind=True)
|
||||
def transaction_balances_callback(self, result: list, param: dict, status_code: int):
|
||||
"""
|
||||
:param self:
|
||||
:type self:
|
||||
:param result:
|
||||
:type result:
|
||||
:param param:
|
||||
:type param:
|
||||
:param status_code:
|
||||
:type status_code:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
if status_code != 0:
|
||||
raise ValueError(f'Unexpected status code: {status_code}.')
|
||||
|
||||
balances_data = result[0]
|
||||
available_balance = calculate_available_balance(balances_data)
|
||||
transaction = param
|
||||
blockchain_address = param.get('blockchain_address')
|
||||
transaction['available_balance'] = available_balance
|
||||
queue = self.request.delivery_info.get('routing_key')
|
||||
|
||||
s_preferences_metadata = celery.signature(
|
||||
'cic_ussd.tasks.metadata.query_preferences_metadata', [blockchain_address], queue=queue
|
||||
)
|
||||
s_process_account_metadata = celery.signature(
|
||||
'cic_ussd.tasks.processor.parse_transaction', [transaction], queue=queue
|
||||
)
|
||||
s_notify_account = celery.signature('cic_ussd.tasks.notifications.transaction', queue=queue)
|
||||
|
||||
if param.get('transaction_type') == 'transfer':
|
||||
celery.chain(s_preferences_metadata, s_process_account_metadata, s_notify_account).apply_async()
|
||||
|
||||
if param.get('transaction_type') == 'tokengift':
|
||||
s_process_account_metadata = celery.signature(
|
||||
'cic_ussd.tasks.processor.parse_transaction', [{}, transaction], queue=queue
|
||||
)
|
||||
celery.chain(s_process_account_metadata, s_notify_account).apply_async()
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def transaction_callback(result: dict, param: str, status_code: int):
|
||||
"""
|
||||
:param result:
|
||||
:type result:
|
||||
:param param:
|
||||
:type param:
|
||||
:param status_code:
|
||||
:type status_code:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
if status_code != 0:
|
||||
raise ValueError(f'Unexpected status code: {status_code}.')
|
||||
|
||||
chain_str = Chain.spec.__str__()
|
||||
destination_token_symbol = result.get('destination_token_symbol')
|
||||
destination_token_value = result.get('destination_token_value')
|
||||
recipient_blockchain_address = result.get('recipient')
|
||||
@ -100,196 +183,35 @@ def process_transaction_callback(self, result: dict, param: str, status_code: in
|
||||
source_token_symbol = result.get('source_token_symbol')
|
||||
source_token_value = result.get('source_token_value')
|
||||
|
||||
# build stakeholder callback params
|
||||
recipient_metadata = {
|
||||
"token_symbol": destination_token_symbol,
|
||||
"token_value": destination_token_value,
|
||||
"blockchain_address": recipient_blockchain_address,
|
||||
"tag": "recipient",
|
||||
"tx_param": param
|
||||
"role": "recipient",
|
||||
"transaction_type": param
|
||||
}
|
||||
|
||||
# retrieve account balances
|
||||
get_balances(
|
||||
address=recipient_blockchain_address,
|
||||
callback_param=recipient_metadata,
|
||||
chain_str=chain_str,
|
||||
callback_task='cic_ussd.tasks.callback_handler.process_transaction_balances_callback',
|
||||
callback_task='cic_ussd.tasks.callback_handler.transaction_balances_callback',
|
||||
token_symbol=destination_token_symbol,
|
||||
asynchronous=True)
|
||||
|
||||
# only retrieve sender if transaction is a transfer
|
||||
if param == 'transfer':
|
||||
sender_metadata = {
|
||||
"blockchain_address": sender_blockchain_address,
|
||||
"token_symbol": source_token_symbol,
|
||||
"token_value": source_token_value,
|
||||
"tag": "sender",
|
||||
"tx_param": param
|
||||
"role": "sender",
|
||||
"transaction_type": param
|
||||
}
|
||||
|
||||
get_balances(
|
||||
address=sender_blockchain_address,
|
||||
callback_param=sender_metadata,
|
||||
chain_str=chain_str,
|
||||
callback_task='cic_ussd.tasks.callback_handler.process_transaction_balances_callback',
|
||||
callback_task='cic_ussd.tasks.callback_handler.transaction_balances_callback',
|
||||
token_symbol=source_token_symbol,
|
||||
asynchronous=True)
|
||||
else:
|
||||
raise ValueError(f'Unexpected status code: {status_code}.')
|
||||
|
||||
|
||||
@celery_app.task(bind=True)
|
||||
def process_transaction_balances_callback(self, result: list, param: dict, status_code: int):
|
||||
queue = self.request.delivery_info.get('routing_key')
|
||||
if status_code == 0:
|
||||
# retrieve balance data
|
||||
balances_data = result[0]
|
||||
operational_balance = compute_operational_balance(balances=balances_data)
|
||||
|
||||
# retrieve account's address
|
||||
blockchain_address = param.get('blockchain_address')
|
||||
|
||||
# append balance to transaction metadata
|
||||
transaction_metadata = param
|
||||
transaction_metadata['operational_balance'] = operational_balance
|
||||
|
||||
# retrieve account's preferences
|
||||
s_preferences_metadata = celery.signature(
|
||||
'cic_ussd.tasks.metadata.query_preferences_metadata',
|
||||
[blockchain_address],
|
||||
queue=queue
|
||||
)
|
||||
|
||||
# parse metadata and run validations
|
||||
s_process_account_metadata = celery.signature(
|
||||
'cic_ussd.tasks.processor.process_tx_metadata_for_notification',
|
||||
[transaction_metadata],
|
||||
queue=queue
|
||||
)
|
||||
|
||||
# issue notification of transaction
|
||||
s_notify_account = celery.signature(
|
||||
'cic_ussd.tasks.notifications.notify_account_of_transaction',
|
||||
queue=queue
|
||||
)
|
||||
|
||||
if param.get('tx_param') == 'transfer':
|
||||
celery.chain(s_preferences_metadata, s_process_account_metadata, s_notify_account).apply_async()
|
||||
|
||||
if param.get('tx_param') == 'tokengift':
|
||||
s_process_account_metadata = celery.signature(
|
||||
'cic_ussd.tasks.processor.process_tx_metadata_for_notification',
|
||||
[{}, transaction_metadata],
|
||||
queue=queue
|
||||
)
|
||||
celery.chain(s_process_account_metadata, s_notify_account).apply_async()
|
||||
else:
|
||||
raise ValueError(f'Unexpected status code: {status_code}.')
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def process_balances_callback(result: list, param: str, status_code: int):
|
||||
if status_code == 0:
|
||||
balances_data = result[0]
|
||||
blockchain_address = balances_data.get('address')
|
||||
key = create_cached_data_key(
|
||||
identifier=bytes.fromhex(blockchain_address[2:]),
|
||||
salt=':cic.balances_data'
|
||||
)
|
||||
cache_data(key=key, data=json.dumps(balances_data))
|
||||
logg.debug(f'caching: {balances_data} with key: {key}')
|
||||
else:
|
||||
raise ValueError(f'Unexpected status code: {status_code}.')
|
||||
|
||||
|
||||
# TODO: clean up this handler
|
||||
def define_transaction_action_tag(
|
||||
preferred_language: str,
|
||||
sender_blockchain_address: str,
|
||||
param: str):
|
||||
# check if out going ot incoming transaction
|
||||
if sender_blockchain_address == param:
|
||||
# check preferred language
|
||||
if preferred_language == 'en':
|
||||
action_tag = 'SENT'
|
||||
direction = 'TO'
|
||||
else:
|
||||
action_tag = 'ULITUMA'
|
||||
direction = 'KWA'
|
||||
else:
|
||||
if preferred_language == 'en':
|
||||
action_tag = 'RECEIVED'
|
||||
direction = 'FROM'
|
||||
else:
|
||||
action_tag = 'ULIPOKEA'
|
||||
direction = 'KUTOKA'
|
||||
return action_tag, direction
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def process_statement_callback(result, param: str, status_code: int):
|
||||
if status_code == 0:
|
||||
# create session
|
||||
processed_transactions = []
|
||||
|
||||
# process transaction data to cache
|
||||
for transaction in result:
|
||||
sender_blockchain_address = transaction.get('sender')
|
||||
recipient_address = transaction.get('recipient')
|
||||
source_token = transaction.get('source_token')
|
||||
|
||||
# filter out any transactions that are "gassy"
|
||||
if '0x0000000000000000000000000000000000000000' in source_token:
|
||||
pass
|
||||
else:
|
||||
session = SessionBase.create_session()
|
||||
# describe a processed transaction
|
||||
processed_transaction = {}
|
||||
|
||||
# check if sender is in the system
|
||||
sender: Account = session.query(Account).filter_by(blockchain_address=sender_blockchain_address).first()
|
||||
owner: Account = session.query(Account).filter_by(blockchain_address=param).first()
|
||||
if sender:
|
||||
processed_transaction['sender_phone_number'] = sender.phone_number
|
||||
|
||||
action_tag, direction = define_transaction_action_tag(
|
||||
preferred_language=owner.preferred_language,
|
||||
sender_blockchain_address=sender_blockchain_address,
|
||||
param=param
|
||||
)
|
||||
processed_transaction['action_tag'] = action_tag
|
||||
processed_transaction['direction'] = direction
|
||||
|
||||
else:
|
||||
processed_transaction['sender_phone_number'] = 'GRASSROOTS ECONOMICS'
|
||||
|
||||
# check if recipient is in the system
|
||||
recipient: Account = session.query(Account).filter_by(blockchain_address=recipient_address).first()
|
||||
if recipient:
|
||||
processed_transaction['recipient_phone_number'] = recipient.phone_number
|
||||
|
||||
else:
|
||||
logg.warning(f'Tx with recipient not found in cic-ussd')
|
||||
|
||||
session.close()
|
||||
|
||||
# add transaction values
|
||||
processed_transaction['to_value'] = from_wei(value=transaction.get('to_value')).__str__()
|
||||
processed_transaction['from_value'] = from_wei(value=transaction.get('from_value')).__str__()
|
||||
|
||||
raw_timestamp = transaction.get('timestamp')
|
||||
timestamp = datetime.utcfromtimestamp(raw_timestamp).strftime('%d/%m/%y, %H:%M')
|
||||
processed_transaction['timestamp'] = timestamp
|
||||
|
||||
processed_transactions.append(processed_transaction)
|
||||
|
||||
# cache account statement
|
||||
identifier = bytes.fromhex(param[2:])
|
||||
key = create_cached_data_key(identifier=identifier, salt=':cic.statement')
|
||||
data = json.dumps(processed_transactions)
|
||||
|
||||
# cache statement data
|
||||
cache_data(key=key, data=data)
|
||||
else:
|
||||
raise ValueError(f'Unexpected status code: {status_code}.')
|
||||
|
@ -1,11 +0,0 @@
|
||||
# third-party imports
|
||||
import celery
|
||||
import logging
|
||||
|
||||
celery_app = celery.current_app
|
||||
logg = logging.getLogger()
|
||||
|
||||
|
||||
@celery_app.task()
|
||||
def log_it_plz(whatever):
|
||||
logg.info('logged it plz: {}'.format(whatever))
|
@ -6,11 +6,7 @@ import celery
|
||||
from hexathon import strip_0x
|
||||
|
||||
# local imports
|
||||
from cic_ussd.metadata import blockchain_address_to_metadata_pointer
|
||||
from cic_ussd.metadata.custom import CustomMetadata
|
||||
from cic_ussd.metadata.person import PersonMetadata
|
||||
from cic_ussd.metadata.phone import PhonePointerMetadata
|
||||
from cic_ussd.metadata.preferences import PreferencesMetadata
|
||||
from cic_ussd.metadata import CustomMetadata, PersonMetadata, PhonePointerMetadata, PreferencesMetadata
|
||||
from cic_ussd.tasks.base import CriticalMetadataTask
|
||||
|
||||
celery_app = celery.current_app
|
||||
@ -25,8 +21,7 @@ def query_person_metadata(blockchain_address: str):
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
identifier = blockchain_address_to_metadata_pointer(blockchain_address=blockchain_address)
|
||||
logg.debug(f'Retrieving person metadata for address: {blockchain_address}.')
|
||||
identifier = bytes.fromhex(strip_0x(blockchain_address))
|
||||
person_metadata_client = PersonMetadata(identifier=identifier)
|
||||
person_metadata_client.query()
|
||||
|
||||
@ -41,14 +36,14 @@ def create_person_metadata(blockchain_address: str, data: dict):
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
identifier = blockchain_address_to_metadata_pointer(blockchain_address=blockchain_address)
|
||||
identifier = bytes.fromhex(strip_0x(blockchain_address))
|
||||
person_metadata_client = PersonMetadata(identifier=identifier)
|
||||
person_metadata_client.create(data=data)
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def edit_person_metadata(blockchain_address: str, data: dict):
|
||||
identifier = blockchain_address_to_metadata_pointer(blockchain_address=blockchain_address)
|
||||
identifier = bytes.fromhex(strip_0x(blockchain_address))
|
||||
person_metadata_client = PersonMetadata(identifier=identifier)
|
||||
person_metadata_client.edit(data=data)
|
||||
|
||||
@ -63,16 +58,16 @@ def add_phone_pointer(self, blockchain_address: str, phone_number: str):
|
||||
|
||||
@celery_app.task()
|
||||
def add_custom_metadata(blockchain_address: str, data: dict):
|
||||
identifier = blockchain_address_to_metadata_pointer(blockchain_address=blockchain_address)
|
||||
identifier = bytes.fromhex(strip_0x(blockchain_address))
|
||||
custom_metadata_client = CustomMetadata(identifier=identifier)
|
||||
custom_metadata_client.create(data=data)
|
||||
|
||||
|
||||
@celery_app.task()
|
||||
def add_preferences_metadata(blockchain_address: str, data: dict):
|
||||
identifier = blockchain_address_to_metadata_pointer(blockchain_address=blockchain_address)
|
||||
custom_metadata_client = PreferencesMetadata(identifier=identifier)
|
||||
custom_metadata_client.create(data=data)
|
||||
identifier = bytes.fromhex(strip_0x(blockchain_address))
|
||||
preferences_metadata_client = PreferencesMetadata(identifier=identifier)
|
||||
preferences_metadata_client.create(data=data)
|
||||
|
||||
|
||||
@celery_app.task()
|
||||
@ -81,7 +76,7 @@ def query_preferences_metadata(blockchain_address: str):
|
||||
:param blockchain_address: Blockchain address of an account.
|
||||
:type blockchain_address: str | Ox-hex
|
||||
"""
|
||||
identifier = blockchain_address_to_metadata_pointer(blockchain_address=blockchain_address)
|
||||
identifier = bytes.fromhex(strip_0x(blockchain_address))
|
||||
logg.debug(f'Retrieving preferences metadata for address: {blockchain_address}.')
|
||||
person_metadata_client = PreferencesMetadata(identifier=identifier)
|
||||
return person_metadata_client.query()
|
||||
|
@ -6,6 +6,7 @@ import logging
|
||||
import celery
|
||||
|
||||
# local imports
|
||||
from cic_ussd.account.transaction import from_wei
|
||||
from cic_ussd.notifications import Notifier
|
||||
from cic_ussd.phone_number import Support
|
||||
|
||||
@ -15,56 +16,46 @@ notifier = Notifier()
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def notify_account_of_transaction(notification_data: dict):
|
||||
def transaction(notification_data: dict):
|
||||
"""
|
||||
:param notification_data:
|
||||
:type notification_data:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
|
||||
account_tx_role = notification_data.get('account_tx_role')
|
||||
amount = notification_data.get('amount')
|
||||
balance = notification_data.get('balance')
|
||||
role = notification_data.get('role')
|
||||
amount = from_wei(notification_data.get('token_value'))
|
||||
balance = notification_data.get('available_balance')
|
||||
phone_number = notification_data.get('phone_number')
|
||||
preferred_language = notification_data.get('preferred_language')
|
||||
token_symbol = notification_data.get('token_symbol')
|
||||
transaction_account_metadata = notification_data.get('transaction_account_metadata')
|
||||
transaction_account_metadata = notification_data.get('metadata_id')
|
||||
transaction_type = notification_data.get('transaction_type')
|
||||
|
||||
timestamp = datetime.datetime.now().strftime('%d-%m-%y, %H:%M %p')
|
||||
|
||||
if transaction_type == 'tokengift':
|
||||
support_phone = Support.phone_number
|
||||
notifier.send_sms_notification(
|
||||
key='sms.account_successfully_created',
|
||||
phone_number=phone_number,
|
||||
preferred_language=preferred_language,
|
||||
balance=balance,
|
||||
support_phone=support_phone,
|
||||
token_symbol=token_symbol
|
||||
)
|
||||
support_phone=Support.phone_number)
|
||||
|
||||
if transaction_type == 'transfer':
|
||||
if account_tx_role == 'recipient':
|
||||
notifier.send_sms_notification(
|
||||
key='sms.received_tokens',
|
||||
if role == 'recipient':
|
||||
notifier.send_sms_notification('sms.received_tokens',
|
||||
phone_number=phone_number,
|
||||
preferred_language=preferred_language,
|
||||
amount=amount,
|
||||
token_symbol=token_symbol,
|
||||
tx_sender_information=transaction_account_metadata,
|
||||
timestamp=timestamp,
|
||||
balance=balance
|
||||
)
|
||||
else:
|
||||
notifier.send_sms_notification(
|
||||
key='sms.sent_tokens',
|
||||
balance=balance)
|
||||
if role == 'sender':
|
||||
notifier.send_sms_notification('sms.sent_tokens',
|
||||
phone_number=phone_number,
|
||||
preferred_language=preferred_language,
|
||||
amount=amount,
|
||||
token_symbol=token_symbol,
|
||||
tx_recipient_information=transaction_account_metadata,
|
||||
timestamp=timestamp,
|
||||
balance=balance
|
||||
)
|
||||
balance=balance)
|
||||
|
@ -1,88 +1,84 @@
|
||||
# standard imports
|
||||
import json
|
||||
import logging
|
||||
|
||||
# third-party imports
|
||||
import celery
|
||||
from i18n import config
|
||||
import i18n
|
||||
from chainlib.hash import strip_0x
|
||||
|
||||
# local imports
|
||||
from cic_ussd.account import define_account_tx_metadata
|
||||
from cic_ussd.db.models.account import Account
|
||||
from cic_ussd.account.statement import get_cached_statement
|
||||
from cic_ussd.account.transaction import aux_transaction_data, validate_transaction_account
|
||||
from cic_ussd.cache import cache_data, cache_data_key
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.error import UnknownUssdRecipient
|
||||
from cic_ussd.transactions import from_wei
|
||||
|
||||
|
||||
celery_app = celery.current_app
|
||||
logg = logging.getLogger(__file__)
|
||||
|
||||
|
||||
@celery_app.task(bind=True)
|
||||
def generate_statement(self, querying_party: str, transaction: dict):
|
||||
""""""
|
||||
queue = self.request.delivery_info.get('routing_key')
|
||||
|
||||
s_preferences = celery.signature(
|
||||
'cic_ussd.tasks.metadata.query_preferences_metadata', [querying_party], queue=queue
|
||||
)
|
||||
s_parse_transaction = celery.signature(
|
||||
'cic_ussd.tasks.processor.parse_transaction', [transaction], queue=queue
|
||||
)
|
||||
s_cache_statement = celery.signature(
|
||||
'cic_ussd.tasks.processor.cache_statement', [querying_party], queue=queue
|
||||
)
|
||||
celery.chain(s_preferences, s_parse_transaction, s_cache_statement).apply_async()
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def process_tx_metadata_for_notification(result: celery.Task, transaction_metadata: dict):
|
||||
def cache_statement(parsed_transaction: dict, querying_party: str):
|
||||
"""
|
||||
:param result:
|
||||
:type result:
|
||||
:param transaction_metadata:
|
||||
:type transaction_metadata:
|
||||
:param parsed_transaction:
|
||||
:type parsed_transaction:
|
||||
:param querying_party:
|
||||
:type querying_party:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
notification_data = {}
|
||||
cached_statement = get_cached_statement(querying_party)
|
||||
statement_transactions = []
|
||||
if cached_statement:
|
||||
statement_transactions = json.loads(cached_statement)
|
||||
statement_transactions.append(parsed_transaction)
|
||||
data = json.dumps(statement_transactions)
|
||||
identifier = bytes.fromhex(strip_0x(querying_party))
|
||||
key = cache_data_key(identifier, ':cic.statement')
|
||||
cache_data(key, data)
|
||||
|
||||
# get preferred language
|
||||
preferred_language = result.get('preferred_language')
|
||||
|
||||
@celery_app.task
|
||||
def parse_transaction(preferences: dict, transaction: dict) -> dict:
|
||||
"""This function parses transaction objects and collates all relevant data for system use i.e:
|
||||
- An account's set preferred language.
|
||||
- Account identifier that facilitates notification.
|
||||
- Contextual tags i.e action and direction tags.
|
||||
:param preferences: An account's set preferences.
|
||||
:type preferences: dict
|
||||
:param transaction: Transaction object.
|
||||
:type transaction: dict
|
||||
:return: Transaction object with contextual data for use in the system.
|
||||
:rtype: dict
|
||||
"""
|
||||
preferred_language = preferences.get('preferred_language')
|
||||
if not preferred_language:
|
||||
preferred_language = config.get('fallback')
|
||||
notification_data['preferred_language'] = preferred_language
|
||||
preferred_language = i18n.config.get('fallback')
|
||||
|
||||
# validate account information against present ussd storage data.
|
||||
transaction = aux_transaction_data(preferred_language, transaction)
|
||||
session = SessionBase.create_session()
|
||||
blockchain_address = transaction_metadata.get('blockchain_address')
|
||||
tag = transaction_metadata.get('tag')
|
||||
account = session.query(Account).filter_by(blockchain_address=blockchain_address).first()
|
||||
if not account and tag == 'recipient':
|
||||
account = validate_transaction_account(session, transaction)
|
||||
metadata_id = account.standard_metadata_id()
|
||||
transaction['metadata_id'] = metadata_id
|
||||
transaction['phone_number'] = account.phone_number
|
||||
session.commit()
|
||||
session.close()
|
||||
raise UnknownUssdRecipient(
|
||||
f'Tx for recipient: {blockchain_address} was received but has no matching user in the system.'
|
||||
)
|
||||
|
||||
# get phone number associated with account
|
||||
phone_number = account.phone_number
|
||||
notification_data['phone_number'] = phone_number
|
||||
|
||||
# get account's role in transaction i.e sender / recipient
|
||||
tx_param = transaction_metadata.get('tx_param')
|
||||
notification_data['transaction_type'] = tx_param
|
||||
|
||||
# get token amount and symbol
|
||||
if tag == 'recipient':
|
||||
account_tx_role = tag
|
||||
amount = transaction_metadata.get('token_value')
|
||||
amount = from_wei(value=amount)
|
||||
token_symbol = transaction_metadata.get('token_symbol')
|
||||
else:
|
||||
account_tx_role = tag
|
||||
amount = transaction_metadata.get('token_value')
|
||||
amount = from_wei(value=amount)
|
||||
token_symbol = transaction_metadata.get('token_symbol')
|
||||
notification_data['account_tx_role'] = account_tx_role
|
||||
notification_data['amount'] = amount
|
||||
notification_data['token_symbol'] = token_symbol
|
||||
|
||||
# get account's standard ussd identification pattern
|
||||
if tx_param == 'transfer':
|
||||
tx_account_metadata = define_account_tx_metadata(user=account)
|
||||
notification_data['transaction_account_metadata'] = tx_account_metadata
|
||||
|
||||
if tag == 'recipient':
|
||||
notification_data['notification_key'] = 'sms.received_tokens'
|
||||
else:
|
||||
notification_data['notification_key'] = 'sms.sent_tokens'
|
||||
|
||||
if tx_param == 'tokengift':
|
||||
notification_data['notification_key'] = 'sms.account_successfully_created'
|
||||
|
||||
# get account's balance
|
||||
notification_data['balance'] = transaction_metadata.get('operational_balance')
|
||||
|
||||
return notification_data
|
||||
return transaction
|
||||
|
@ -7,10 +7,10 @@ import celery
|
||||
from celery.utils.log import get_logger
|
||||
|
||||
# local imports
|
||||
from cic_ussd.cache import Cache, get_cached_data
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.db.models.ussd_session import UssdSession
|
||||
from cic_ussd.error import SessionNotFoundError
|
||||
from cic_ussd.session.ussd_session import UssdSession as InMemoryUssdSession
|
||||
from cic_ussd.tasks.base import CriticalSQLAlchemyTask
|
||||
|
||||
celery_app = celery.current_app
|
||||
@ -28,46 +28,36 @@ def persist_session_to_db(external_session_id: str):
|
||||
:raises SessionNotFoundError: If the session object is not found in memory.
|
||||
:raises VersionTooLowError: If the session's version doesn't match the latest version.
|
||||
"""
|
||||
# create session
|
||||
session = SessionBase.create_session()
|
||||
|
||||
# get ussd session in redis cache
|
||||
in_memory_session = InMemoryUssdSession.redis_cache.get(external_session_id)
|
||||
|
||||
# process persistence to db
|
||||
if in_memory_session:
|
||||
in_memory_session = json.loads(in_memory_session)
|
||||
in_db_ussd_session = session.query(UssdSession).filter_by(external_session_id=external_session_id).first()
|
||||
if in_db_ussd_session:
|
||||
in_db_ussd_session.update(
|
||||
cached_ussd_session = get_cached_data(external_session_id)
|
||||
if cached_ussd_session:
|
||||
cached_ussd_session = json.loads(cached_ussd_session)
|
||||
ussd_session = session.query(UssdSession).filter_by(external_session_id=external_session_id).first()
|
||||
if ussd_session:
|
||||
ussd_session.update(
|
||||
session=session,
|
||||
user_input=in_memory_session.get('user_input'),
|
||||
state=in_memory_session.get('state'),
|
||||
version=in_memory_session.get('version'),
|
||||
user_input=cached_ussd_session.get('user_input'),
|
||||
state=cached_ussd_session.get('state'),
|
||||
version=cached_ussd_session.get('version'),
|
||||
)
|
||||
else:
|
||||
in_db_ussd_session = UssdSession(
|
||||
ussd_session = UssdSession(
|
||||
external_session_id=external_session_id,
|
||||
service_code=in_memory_session.get('service_code'),
|
||||
msisdn=in_memory_session.get('msisdn'),
|
||||
user_input=in_memory_session.get('user_input'),
|
||||
state=in_memory_session.get('state'),
|
||||
version=in_memory_session.get('version'),
|
||||
service_code=cached_ussd_session.get('service_code'),
|
||||
msisdn=cached_ussd_session.get('msisdn'),
|
||||
user_input=cached_ussd_session.get('user_input'),
|
||||
state=cached_ussd_session.get('state'),
|
||||
version=cached_ussd_session.get('version'),
|
||||
)
|
||||
|
||||
# handle the updating of session data for persistence to db
|
||||
session_data = in_memory_session.get('session_data')
|
||||
|
||||
if session_data:
|
||||
for key, value in session_data.items():
|
||||
in_db_ussd_session.set_data(key=key, value=value, session=session)
|
||||
|
||||
session.add(in_db_ussd_session)
|
||||
data = cached_ussd_session.get('data')
|
||||
if data:
|
||||
for key, value in data.items():
|
||||
ussd_session.set_data(key=key, value=value, session=session)
|
||||
session.add(ussd_session)
|
||||
session.commit()
|
||||
session.close()
|
||||
InMemoryUssdSession.redis_cache.expire(external_session_id, timedelta(minutes=1))
|
||||
Cache.store.expire(external_session_id, timedelta(minutes=1))
|
||||
else:
|
||||
session.close()
|
||||
raise SessionNotFoundError('Session does not exist!')
|
||||
|
||||
session.close()
|
||||
|
@ -1,79 +0,0 @@
|
||||
# standard imports
|
||||
import decimal
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
# third-party imports
|
||||
from cic_eth.api import Api
|
||||
|
||||
# local imports
|
||||
from cic_ussd.balance import get_balances, get_cached_operational_balance
|
||||
from cic_ussd.notifications import Notifier
|
||||
from cic_ussd.phone_number import Support
|
||||
|
||||
logg = logging.getLogger()
|
||||
notifier = Notifier()
|
||||
|
||||
|
||||
def truncate(value: float, decimals: int):
|
||||
"""This function truncates a value to a specified number of decimals places.
|
||||
:param value: The value to be truncated.
|
||||
:type value: float
|
||||
:param decimals: The number of decimals for the value to be truncated to
|
||||
:type decimals: int
|
||||
:return: The truncated value.
|
||||
:rtype: int
|
||||
"""
|
||||
decimal.getcontext().rounding = decimal.ROUND_DOWN
|
||||
contextualized_value = decimal.Decimal(value)
|
||||
return round(contextualized_value, decimals)
|
||||
|
||||
|
||||
def from_wei(value: int) -> float:
|
||||
"""This function converts values in Wei to a token in the cic network.
|
||||
:param value: Value in Wei
|
||||
:type value: int
|
||||
:return: SRF equivalent of value in Wei
|
||||
:rtype: float
|
||||
"""
|
||||
value = float(value) / 1e+6
|
||||
return truncate(value=value, decimals=2)
|
||||
|
||||
|
||||
def to_wei(value: int) -> int:
|
||||
"""This functions converts values from a token in the cic network to Wei.
|
||||
:param value: Value in SRF
|
||||
:type value: int
|
||||
:return: Wei equivalent of value in SRF
|
||||
:rtype: int
|
||||
"""
|
||||
return int(value * 1e+6)
|
||||
|
||||
|
||||
class OutgoingTransactionProcessor:
|
||||
|
||||
def __init__(self, chain_str: str, from_address: str, to_address: str):
|
||||
"""
|
||||
:param chain_str: The chain name and network id.
|
||||
:type chain_str: str
|
||||
:param from_address: Ethereum address of the sender
|
||||
:type from_address: str, 0x-hex
|
||||
:param to_address: Ethereum address of the recipient
|
||||
:type to_address: str, 0x-hex
|
||||
"""
|
||||
self.chain_str = chain_str
|
||||
self.cic_eth_api = Api(chain_str=chain_str)
|
||||
self.from_address = from_address
|
||||
self.to_address = to_address
|
||||
|
||||
def process_outgoing_transfer_transaction(self, amount: int, token_symbol: str):
|
||||
"""This function initiates standard transfers between one account to another
|
||||
:param amount: The amount of tokens to be sent
|
||||
:type amount: int
|
||||
:param token_symbol: ERC20 token symbol of token to send
|
||||
:type token_symbol: str
|
||||
"""
|
||||
self.cic_eth_api.transfer(from_address=self.from_address,
|
||||
to_address=self.to_address,
|
||||
value=to_wei(value=amount),
|
||||
token_symbol=token_symbol)
|
@ -1,15 +1,13 @@
|
||||
# standard imports
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import ipaddress
|
||||
|
||||
# third-party imports
|
||||
from confini import Config
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db.models.account import Account
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
|
||||
logg = logging.getLogger(__file__)
|
||||
|
||||
@ -46,23 +44,6 @@ def check_request_content_length(config: Config, env: dict):
|
||||
config.get('APP_MAX_BODY_LENGTH'))
|
||||
|
||||
|
||||
def check_known_user(phone_number: str, session):
|
||||
"""This method attempts to ascertain whether the user already exists and is known to the system.
|
||||
It sends a get request to the platform application and attempts to retrieve the user's data which it persists in
|
||||
memory.
|
||||
:param phone_number: A valid phone number
|
||||
:type phone_number: str
|
||||
:param session:
|
||||
:type session:
|
||||
:return: Is known phone number
|
||||
:rtype: boolean
|
||||
"""
|
||||
session = SessionBase.bind_session(session=session)
|
||||
account = session.query(Account).filter_by(phone_number=phone_number).first()
|
||||
SessionBase.release_session(session=session)
|
||||
return account is not None
|
||||
|
||||
|
||||
def check_request_method(env: dict):
|
||||
"""
|
||||
Checks whether request method is POST
|
||||
@ -74,17 +55,6 @@ def check_request_method(env: dict):
|
||||
return env.get('REQUEST_METHOD').upper() == 'POST'
|
||||
|
||||
|
||||
def check_session_id(session_id: str):
|
||||
"""
|
||||
Checks whether session id is present
|
||||
:param session_id: Session id value provided by AT
|
||||
:type session_id: str
|
||||
:return: Session id presence
|
||||
:rtype: boolean
|
||||
"""
|
||||
return session_id is not None
|
||||
|
||||
|
||||
def validate_phone_number(phone: str):
|
||||
"""
|
||||
Check if phone number is in the correct format.
|
||||
@ -93,12 +63,10 @@ def validate_phone_number(phone: str):
|
||||
:return: Whether the phone number is of the correct format.
|
||||
:rtype: bool
|
||||
"""
|
||||
if phone and re.match('[+]?[0-9]{10,12}$', phone):
|
||||
return True
|
||||
return False
|
||||
return bool(phone and re.match('[+]?[0-9]{10,12}$', phone))
|
||||
|
||||
|
||||
def validate_response_type(processor_response: str) -> bool:
|
||||
def is_valid_response(processor_response: str) -> bool:
|
||||
"""
|
||||
This function checks the prefix for a corresponding menu's text from the response offered by the Ussd Processor and
|
||||
determines whether the response should prompt the end of a ussd session or the
|
||||
@ -111,9 +79,7 @@ def validate_response_type(processor_response: str) -> bool:
|
||||
if len(processor_response) > 164:
|
||||
logg.warning(f'Warning, text has length {len(processor_response)}, display may be truncated')
|
||||
|
||||
if re.match(matcher, processor_response):
|
||||
return True
|
||||
return False
|
||||
return bool(re.match(matcher, processor_response))
|
||||
|
||||
|
||||
def validate_presence(path: str):
|
||||
|
13
apps/cic-ussd/config/app.ini
Normal file
13
apps/cic-ussd/config/app.ini
Normal file
@ -0,0 +1,13 @@
|
||||
[app]
|
||||
allowed_ip=0.0.0.0/0
|
||||
max_body_length=1024
|
||||
password_pepper=
|
||||
|
||||
[machine]
|
||||
states=states/
|
||||
transitions=transitions/
|
||||
|
||||
[client]
|
||||
host =
|
||||
port =
|
||||
ssl =
|
3
apps/cic-ussd/config/celery.ini
Normal file
3
apps/cic-ussd/config/celery.ini
Normal file
@ -0,0 +1,3 @@
|
||||
[celery]
|
||||
broker_url=redis://
|
||||
result_url=redis://
|
10
apps/cic-ussd/config/database.ini
Normal file
10
apps/cic-ussd/config/database.ini
Normal file
@ -0,0 +1,10 @@
|
||||
[database]
|
||||
name=cic_ussd
|
||||
user=postgres
|
||||
password=
|
||||
host=localhost
|
||||
port=5432
|
||||
engine=postgresql
|
||||
driver=psycopg2
|
||||
debug=0
|
||||
pool_size=1
|
5
apps/cic-ussd/config/phone.ini
Normal file
5
apps/cic-ussd/config/phone.ini
Normal file
@ -0,0 +1,5 @@
|
||||
[e164]
|
||||
region=KE
|
||||
|
||||
[office]
|
||||
support_phone=0757628885
|
5
apps/cic-ussd/config/redis.ini
Normal file
5
apps/cic-ussd/config/redis.ini
Normal file
@ -0,0 +1,5 @@
|
||||
[redis]
|
||||
host=redis
|
||||
database=0
|
||||
password=
|
||||
port=6379
|
8
apps/cic-ussd/config/test/app.ini
Normal file
8
apps/cic-ussd/config/test/app.ini
Normal file
@ -0,0 +1,8 @@
|
||||
[app]
|
||||
allowed_ip=127.0.0.1
|
||||
max_body_length=1024
|
||||
password_pepper=QYbzKff6NhiQzY3ygl2BkiKOpER8RE/Upqs/5aZWW+I=
|
||||
|
||||
[machine]
|
||||
states=states/
|
||||
transitions=transitions/
|
3
apps/cic-ussd/config/test/celery.ini
Normal file
3
apps/cic-ussd/config/test/celery.ini
Normal file
@ -0,0 +1,3 @@
|
||||
[celery]
|
||||
broker_url = filesystem://
|
||||
result_url = filesystem://
|
@ -2,4 +2,4 @@
|
||||
engine = evm
|
||||
common_name = bloxberg
|
||||
network_id = 8996
|
||||
meta_url = http://localhost:63380
|
||||
meta_url = http://test-meta.io
|
9
apps/cic-ussd/config/test/database.ini
Normal file
9
apps/cic-ussd/config/test/database.ini
Normal file
@ -0,0 +1,9 @@
|
||||
[database]
|
||||
name=cic_ussd_test
|
||||
user=postgres
|
||||
password=
|
||||
host=localhost
|
||||
port=5432
|
||||
engine=sqlite
|
||||
driver=pysqlite
|
||||
debug=
|
5
apps/cic-ussd/config/test/pgp.ini
Normal file
5
apps/cic-ussd/config/test/pgp.ini
Normal file
@ -0,0 +1,5 @@
|
||||
[pgp]
|
||||
export_dir =
|
||||
keys_path =tests/data/pgp
|
||||
private_keys = privatekeys_meta.asc
|
||||
passphrase = merman
|
5
apps/cic-ussd/config/test/phone.ini
Normal file
5
apps/cic-ussd/config/test/phone.ini
Normal file
@ -0,0 +1,5 @@
|
||||
[e164]
|
||||
region=KE
|
||||
|
||||
[office]
|
||||
support_phone=0757628885
|
5
apps/cic-ussd/config/test/redis.ini
Normal file
5
apps/cic-ussd/config/test/redis.ini
Normal file
@ -0,0 +1,5 @@
|
||||
[redis]
|
||||
host=localhost
|
||||
database=0
|
||||
password=
|
||||
port=6379
|
3
apps/cic-ussd/config/test/translations.ini
Normal file
3
apps/cic-ussd/config/test/translations.ini
Normal file
@ -0,0 +1,3 @@
|
||||
[locale]
|
||||
fallback=sw
|
||||
path=var/lib/locale/
|
5
apps/cic-ussd/config/test/ussd.ini
Normal file
5
apps/cic-ussd/config/test/ussd.ini
Normal file
@ -0,0 +1,5 @@
|
||||
[ussd]
|
||||
menu_file=cic_ussd/db/ussd_menu.json
|
||||
service_code=*483*46#,*483*061#,*384*96#
|
||||
user =
|
||||
pass =
|
3
apps/cic-ussd/config/translations.ini
Normal file
3
apps/cic-ussd/config/translations.ini
Normal file
@ -0,0 +1,3 @@
|
||||
[locale]
|
||||
fallback=sw
|
||||
path=var/lib/locale/
|
5
apps/cic-ussd/config/ussd.ini
Normal file
5
apps/cic-ussd/config/ussd.ini
Normal file
@ -0,0 +1,5 @@
|
||||
[ussd]
|
||||
menu_file=data/ussd_menu.json
|
||||
service_code=*483*46#,*483*061#,*384*96#
|
||||
user =
|
||||
pass =
|
@ -1,6 +1,6 @@
|
||||
# syntax = docker/dockerfile:1.2
|
||||
FROM registry.gitlab.com/grassrootseconomics/cic-base-images:python-3.8.6-dev-55da5f4e as dev
|
||||
|
||||
RUN apt-get install -y redis-server
|
||||
# create secrets directory
|
||||
RUN mkdir -vp pgp/keys
|
||||
|
||||
@ -26,7 +26,7 @@ COPY docker/*.sh .
|
||||
RUN chmod +x /root/*.sh
|
||||
|
||||
# copy config and migration files to definitive file so they can be referenced in path definitions for running scripts
|
||||
COPY .config/ /usr/local/etc/cic-ussd/
|
||||
COPY config/ /usr/local/etc/cic-ussd/
|
||||
COPY cic_ussd/db/migrations/ /usr/local/share/cic-ussd/alembic
|
||||
|
||||
ENTRYPOINT []
|
||||
|
@ -1,6 +1,6 @@
|
||||
# syntax = docker/dockerfile:1.2
|
||||
FROM registry.gitlab.com/grassrootseconomics/cic-base-images:python-3.8.6-dev-55da5f4e as dev
|
||||
|
||||
RUN apt-get install -y redis-server
|
||||
|
||||
# create secrets directory
|
||||
RUN mkdir -vp pgp/keys
|
||||
@ -26,7 +26,7 @@ COPY docker/*.sh .
|
||||
RUN chmod +x /root/*.sh
|
||||
|
||||
# copy config and migration files to definitive file so they can be referenced in path definitions for running scripts
|
||||
COPY .config/ /usr/local/etc/cic-ussd/
|
||||
COPY config/ /usr/local/etc/cic-ussd/
|
||||
COPY cic_ussd/db/migrations/ /usr/local/share/cic-ussd/alembic
|
||||
|
||||
ENTRYPOINT []
|
||||
|
@ -1,17 +1,17 @@
|
||||
cic-eth~=0.12.2a3
|
||||
alembic==1.4.2
|
||||
bcrypt==3.2.0
|
||||
celery==4.4.7
|
||||
cic-eth[services]==0.12.2a3
|
||||
cic-notify~=0.4.0a10
|
||||
cic-types~=0.1.0a14
|
||||
confini~=0.4.1a1
|
||||
semver==2.13.0
|
||||
alembic==1.4.2
|
||||
SQLAlchemy==1.3.20
|
||||
psycopg2==2.8.6
|
||||
tinydb==4.2.0
|
||||
phonenumbers==8.12.12
|
||||
redis==3.5.3
|
||||
celery==4.4.7
|
||||
psycopg2==2.8.6
|
||||
python-i18n[YAML]==0.3.9
|
||||
pyxdg==0.27
|
||||
bcrypt==3.2.0
|
||||
uWSGI==2.0.19.1
|
||||
redis==3.5.3
|
||||
semver==2.13.0
|
||||
SQLAlchemy==1.3.20
|
||||
tinydb==4.2.0
|
||||
transitions==0.8.4
|
||||
uWSGI==2.0.19.1
|
@ -19,7 +19,7 @@ root_directory = os.path.dirname(os.path.dirname(__file__))
|
||||
db_directory = os.path.join(root_directory, 'cic_ussd', 'db')
|
||||
migrationsdir = os.path.join(db_directory, 'migrations')
|
||||
|
||||
config_directory = os.path.join(root_directory, '.config')
|
||||
config_directory = os.path.join(root_directory, 'config')
|
||||
|
||||
arg_parser = argparse.ArgumentParser()
|
||||
arg_parser.add_argument('-c', type=str, default=config_directory, help='config file')
|
||||
@ -51,4 +51,3 @@ ac.set_main_option('sqlalchemy.url', dsn)
|
||||
ac.set_main_option('script_location', migrations_dir)
|
||||
|
||||
alembic.command.upgrade(ac, 'head')
|
||||
|
||||
|
@ -29,11 +29,14 @@ licence_files =
|
||||
python_requires = >= 3.6
|
||||
packages =
|
||||
cic_ussd
|
||||
cic_ussd.account
|
||||
cic_ussd.db
|
||||
cic_ussd.db.models
|
||||
cic_ussd.files
|
||||
cic_ussd.http
|
||||
cic_ussd.menu
|
||||
cic_ussd.metadata
|
||||
cic_ussd.processor
|
||||
cic_ussd.runnable
|
||||
cic_ussd.runnable.daemons
|
||||
cic_ussd.session
|
||||
|
@ -3,5 +3,6 @@
|
||||
"scan_data",
|
||||
"initial_language_selection",
|
||||
"initial_pin_entry",
|
||||
"initial_pin_confirmation"
|
||||
"initial_pin_confirmation",
|
||||
"change_preferred_language"
|
||||
]
|
||||
|
68
apps/cic-ussd/tests/cic_ussd/account/test_balance.py
Normal file
68
apps/cic-ussd/tests/cic_ussd/account/test_balance.py
Normal file
@ -0,0 +1,68 @@
|
||||
# standard imports
|
||||
|
||||
# external imports
|
||||
import pytest
|
||||
|
||||
# local imports
|
||||
from cic_ussd.account.balance import calculate_available_balance, get_balances, get_cached_available_balance
|
||||
from cic_ussd.account.chain import Chain
|
||||
from cic_ussd.error import CachedDataNotFoundError
|
||||
|
||||
# test imports
|
||||
from tests.helpers.accounts import blockchain_address
|
||||
|
||||
|
||||
def test_async_get_balances(activated_account,
|
||||
celery_session_worker,
|
||||
load_chain_spec,
|
||||
load_config,
|
||||
mock_async_balance_api_query):
|
||||
blockchain_address = activated_account.blockchain_address
|
||||
chain_str = Chain.spec.__str__()
|
||||
token_symbol = load_config.get('TEST_TOKEN_SYMBOL')
|
||||
get_balances(blockchain_address, chain_str, token_symbol, asynchronous=True)
|
||||
assert mock_async_balance_api_query.get('address') == blockchain_address
|
||||
assert mock_async_balance_api_query.get('token_symbol') == token_symbol
|
||||
|
||||
|
||||
def test_sync_get_balances(activated_account,
|
||||
balances,
|
||||
celery_session_worker,
|
||||
load_chain_spec,
|
||||
load_config,
|
||||
mock_sync_balance_api_query):
|
||||
blockchain_address = activated_account.blockchain_address
|
||||
chain_str = Chain.spec.__str__()
|
||||
token_symbol = load_config.get('TEST_TOKEN_SYMBOL')
|
||||
res = get_balances(blockchain_address, chain_str, token_symbol, asynchronous=False)
|
||||
assert res == balances
|
||||
|
||||
|
||||
@pytest.mark.parametrize('balance_incoming, balance_network, balance_outgoing, available_balance', [
|
||||
(0, 50000000, 0, 50.00),
|
||||
(5000000, 89000000, 67000000, 27.00)
|
||||
])
|
||||
def test_calculate_available_balance(activated_account,
|
||||
balance_incoming,
|
||||
balance_network,
|
||||
balance_outgoing,
|
||||
available_balance):
|
||||
balances = {
|
||||
'address': activated_account.blockchain_address,
|
||||
'converters': [],
|
||||
'balance_network': balance_network,
|
||||
'balance_outgoing': balance_outgoing,
|
||||
'balance_incoming': balance_incoming
|
||||
}
|
||||
assert calculate_available_balance(balances) == available_balance
|
||||
|
||||
|
||||
def test_get_cached_available_balance(activated_account, cache_balances, balances):
|
||||
cached_available_balance = get_cached_available_balance(activated_account.blockchain_address)
|
||||
available_balance = calculate_available_balance(balances[0])
|
||||
assert cached_available_balance == available_balance
|
||||
address = blockchain_address()
|
||||
with pytest.raises(CachedDataNotFoundError) as error:
|
||||
cached_available_balance = get_cached_available_balance(address)
|
||||
assert cached_available_balance is None
|
||||
assert str(error.value) == f'No cached available balance for address: {address}'
|
28
apps/cic-ussd/tests/cic_ussd/account/test_maps.py
Normal file
28
apps/cic-ussd/tests/cic_ussd/account/test_maps.py
Normal file
@ -0,0 +1,28 @@
|
||||
# standard imports
|
||||
|
||||
# external imports
|
||||
import pytest
|
||||
|
||||
# local imports
|
||||
from cic_ussd.account.maps import gender, language
|
||||
|
||||
# test imports
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key, expected_value', [
|
||||
('1', 'male'),
|
||||
('2', 'female'),
|
||||
('3', 'other')
|
||||
])
|
||||
def test_gender(key, expected_value):
|
||||
g_map = gender()
|
||||
assert g_map[key] == expected_value
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key, expected_value', [
|
||||
('1', 'en'),
|
||||
('2', 'sw'),
|
||||
])
|
||||
def test_language(key, expected_value):
|
||||
l_map = language()
|
||||
assert l_map[key] == expected_value
|
28
apps/cic-ussd/tests/cic_ussd/account/test_metadata.py
Normal file
28
apps/cic-ussd/tests/cic_ussd/account/test_metadata.py
Normal file
@ -0,0 +1,28 @@
|
||||
# standard imports
|
||||
import json
|
||||
|
||||
# external imports
|
||||
from cic_types.models.person import get_contact_data_from_vcard
|
||||
|
||||
# local imports
|
||||
from cic_ussd.account.metadata import get_cached_preferred_language, parse_account_metadata
|
||||
|
||||
# test imports
|
||||
from tests.helpers.accounts import blockchain_address
|
||||
|
||||
|
||||
def test_get_cached_preferred_language(activated_account, cache_preferences, preferences):
|
||||
cached_preferred_language = get_cached_preferred_language(activated_account.blockchain_address)
|
||||
assert cached_preferred_language == preferences.get('preferred_language')
|
||||
cached_preferred_language = get_cached_preferred_language(blockchain_address())
|
||||
assert cached_preferred_language is None
|
||||
|
||||
|
||||
def test_parse_account_metadata(person_metadata):
|
||||
contact_information = get_contact_data_from_vcard(person_metadata.get('vcard'))
|
||||
given_name = contact_information.get('given')
|
||||
family_name = contact_information.get('family')
|
||||
phone_number = contact_information.get('tel')
|
||||
parsed_account_metadata = f'{given_name} {family_name} {phone_number}'
|
||||
assert parse_account_metadata(person_metadata) == parsed_account_metadata
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user