Merge branch 'master' into lash/remove-cic-base

This commit is contained in:
nolash 2021-08-12 18:27:39 +02:00
commit c90b6196e6
205 changed files with 5806 additions and 5000 deletions

View File

@ -147,7 +147,7 @@ function handleClientMergeGet(db, digest, keystore) {
doh(e); doh(e);
}); });
}).catch((e) => { }).catch((e) => {
console.error('mesage', e); console.error('message', e);
doh(e); doh(e);
}); });
}); });

View File

@ -205,7 +205,7 @@ async function processRequest(req, res) {
if (content === undefined) { if (content === undefined) {
console.error('empty content', data); console.error('empty content', data);
res.writeHead(400, {"Content-Type": "text/plain"}); res.writeHead(404, {"Content-Type": "text/plain"});
res.end(); res.end();
return; return;
} }

View File

@ -12,14 +12,11 @@ RUN --mount=type=cache,mode=0755,target=/root/.cache/pip \
--extra-index-url $GITLAB_PYTHON_REGISTRY --extra-index-url $EXTRA_INDEX_URL \ --extra-index-url $GITLAB_PYTHON_REGISTRY --extra-index-url $EXTRA_INDEX_URL \
-r requirements.txt -r requirements.txt
COPY . . COPY . .
RUN python setup.py install RUN python setup.py install
# TODO please review..can this go into requirements?
RUN pip install $pip_extra_index_url_flag .[africastalking,notifylog]
COPY docker/*.sh . COPY docker/*.sh .
RUN chmod +x *.sh
# ini files in config directory defines the configurable parameters for the application # ini files in config directory defines the configurable parameters for the application
# they can all be overridden by environment variables # they can all be overridden by environment variables
@ -27,4 +24,5 @@ COPY docker/*.sh .
COPY .config/ /usr/local/etc/cic-notify/ COPY .config/ /usr/local/etc/cic-notify/
COPY cic_notify/db/migrations/ /usr/local/share/cic-notify/alembic/ COPY cic_notify/db/migrations/ /usr/local/share/cic-notify/alembic/
ENTRYPOINT [] ENTRYPOINT []

View File

@ -11,14 +11,12 @@ RUN pip install --index-url https://pypi.org/simple \
--extra-index-url $GITLAB_PYTHON_REGISTRY --extra-index-url $EXTRA_INDEX_URL \ --extra-index-url $GITLAB_PYTHON_REGISTRY --extra-index-url $EXTRA_INDEX_URL \
-r requirements.txt -r requirements.txt
COPY . . COPY . .
RUN python setup.py install RUN python setup.py install
# TODO please review..can this go into requirements?
RUN pip install $pip_extra_index_url_flag .[africastalking,notifylog]
COPY docker/*.sh . COPY docker/*.sh .
RUN chmod +x *.sh
# ini files in config directory defines the configurable parameters for the application # ini files in config directory defines the configurable parameters for the application
# they can all be overridden by environment variables # they can all be overridden by environment variables

View File

@ -1,3 +1,3 @@
#!/bin/bash #!/bin/bash
set -e
migrate.py -c /usr/local/etc/cic-notify --migrations-dir /usr/local/share/cic-notify/alembic -vv python scripts/migrate.py -c /usr/local/etc/cic-notify --migrations-dir /usr/local/share/cic-notify/alembic -vv

View File

@ -1,5 +1,5 @@
#!/bin/bash #!/bin/bash
. ./db.sh . /root/db.sh
/usr/local/bin/cic-notify-tasker -vv $@ /usr/local/bin/cic-notify-tasker -vv $@

View File

@ -29,18 +29,11 @@ packages =
cic_notify.db cic_notify.db
cic_notify.db.models cic_notify.db.models
cic_notify.ext cic_notify.ext
cic_notify.tasks
cic_notify.tasks.sms cic_notify.tasks.sms
cic_notify.runnable cic_notify.runnable
scripts = scripts =
scripts/migrate.py ./scripts/migrate.py
[options.extras_require]
africastalking = africastalking==1.2.3
notifylog = psycopg2==2.8.6
testing =
pytest==6.0.1
pytest-celery==0.0.0a1
pytest-mock==3.3.1
pysqlite3==0.4.3
[options.entry_points] [options.entry_points]
console_scripts = console_scripts =

View File

@ -1,25 +0,0 @@
[app]
ALLOWED_IP=0.0.0.0/0
LOCALE_FALLBACK=en
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 =

View File

@ -1,10 +0,0 @@
[database]
NAME=cic_ussd
USER=postgres
PASSWORD=
HOST=localhost
PORT=5432
ENGINE=postgresql
DRIVER=psycopg2
DEBUG=0
POOL_SIZE=1

View File

@ -1,9 +0,0 @@
[celery]
BROKER_URL=redis://
RESULT_URL=redis://
[redis]
HOSTNAME=redis
PASSWORD=
PORT=6379
DATABASE=0

View File

@ -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/

View File

@ -1,8 +0,0 @@
[database]
NAME=cic_ussd_test
USER=postgres
PASSWORD=
HOST=localhost
PORT=5432
ENGINE=sqlite
DRIVER=pysqlite

View File

@ -1,5 +0,0 @@
[pgp]
export_dir = /usr/src/pgp/keys/
keys_path = /usr/src/secrets/
private_keys = privatekeys_meta.asc
passphrase =

View File

@ -1,9 +0,0 @@
[celery]
BROKER_URL = filesystem://
RESULT_URL = filesystem://
[redis]
HOSTNAME=localhost
PASSWORD=
PORT=6379
DATABASE=0

View File

@ -31,7 +31,7 @@ test-mr-cic-ussd:
pip install --extra-index-url https://pip.grassrootseconomics.net:8433 pip install --extra-index-url https://pip.grassrootseconomics.net:8433
--extra-index-url https://gitlab.com/api/v4/projects/27624814/packages/pypi/simple --extra-index-url https://gitlab.com/api/v4/projects/27624814/packages/pypi/simple
-r test_requirements.txt -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"] needs: ["build-mr-cic-ussd"]
rules: rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_PIPELINE_SOURCE == "merge_request_event"

View File

@ -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)

View 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}')

View 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'
}

View 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}'

View 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)

View 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()

View 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)

View File

@ -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.')

View File

@ -8,8 +8,8 @@ from redis import Redis
logg = logging.getLogger() logg = logging.getLogger()
class InMemoryStore: class Cache:
cache: Redis = None store: Redis = None
def cache_data(key: str, data: str): def cache_data(key: str, data: str):
@ -21,9 +21,10 @@ def cache_data(key: str, data: str):
:return: :return:
:rtype: :rtype:
""" """
cache = InMemoryStore.cache cache = Cache.store
cache.set(name=key, value=data) cache.set(name=key, value=data)
cache.persist(name=key) cache.persist(name=key)
logg.debug(f'caching: {data} with key: {key}.')
def get_cached_data(key: str): def get_cached_data(key: str):
@ -33,11 +34,11 @@ def get_cached_data(key: str):
:return: :return:
:rtype: :rtype:
""" """
cache = InMemoryStore.cache cache = Cache.store
return cache.get(name=key) return cache.get(name=key)
def create_cached_data_key(identifier: bytes, salt: str): def cache_data_key(identifier: bytes, salt: str):
""" """
:param identifier: :param identifier:
:type identifier: :type identifier:

View File

@ -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)

View File

@ -26,7 +26,7 @@ def upgrade():
sa.Column('msisdn', sa.String(), nullable=False), sa.Column('msisdn', sa.String(), nullable=False),
sa.Column('user_input', sa.String(), nullable=True), sa.Column('user_input', sa.String(), nullable=True),
sa.Column('state', sa.String(), nullable=False), 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.Column('version', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )

View File

@ -24,7 +24,7 @@ def upgrade():
sa.Column('preferred_language', sa.String(), nullable=True), sa.Column('preferred_language', sa.String(), nullable=True),
sa.Column('password_hash', sa.String(), nullable=True), sa.Column('password_hash', sa.String(), nullable=True),
sa.Column('failed_pin_attempts', sa.Integer(), nullable=False), 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('created', sa.DateTime(), nullable=False),
sa.Column('updated', sa.DateTime(), nullable=False), sa.Column('updated', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')

View File

@ -1,11 +1,17 @@
# standard imports # standard imports
import json
# external imports
from chainlib.hash import strip_0x
from cic_eth.api import Api
# local imports # 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.enum import AccountStatus
from cic_ussd.db.models.base import SessionBase 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 from cic_ussd.encoder import check_password_hash, create_password_hash
# third party imports
from sqlalchemy import Column, Integer, String from sqlalchemy import Column, Integer, String
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
@ -21,9 +27,32 @@ class Account(SessionBase):
phone_number = Column(String) phone_number = Column(String)
password_hash = Column(String) password_hash = Column(String)
failed_pin_attempts = Column(Integer) failed_pin_attempts = Column(Integer)
account_status = Column(Integer) status = Column(Integer)
preferred_language = Column(String) 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 @staticmethod
def get_by_phone_number(phone_number: str, session: Session): def get_by_phone_number(phone_number: str, session: Session):
"""Retrieves an account from a phone number. """Retrieves an account from a phone number.
@ -39,23 +68,68 @@ class Account(SessionBase):
SessionBase.release_session(session=session) SessionBase.release_session(session=session)
return account return account
def __init__(self, blockchain_address, phone_number): def has_preferred_language(self) -> bool:
self.blockchain_address = blockchain_address return get_cached_preferred_language(self.blockchain_address) is not None
self.phone_number = phone_number
self.password_hash = None
self.failed_pin_attempts = 0
self.account_status = AccountStatus.PENDING.value
def __repr__(self): def has_valid_pin(self, session: Session):
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
""" """
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): def verify_password(self, password):
"""This method takes a password value and compares it to the user's corresponding `hashed_password` value to """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) 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): def create(chain_str: str, phone_number: str, session: Session):
"""This method checks whether the account is past the allowed number of failed pin attempts. """
If so, it changes the accounts status to Locked. :param chain_str:
:return: The account status for a user object :type chain_str:
:rtype: str :param phone_number:
""" :type phone_number:
if self.failed_pin_attempts > 2: :param session:
self.account_status = AccountStatus.LOCKED.value :type session:
return AccountStatus(self.account_status).name :return:
:rtype:
"""
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 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): def cache_creation_task_uuid(phone_number: str, task_uuid: str):
"""This method checks whether the user's account status and if a pin hash is present which implies """This function stores the task id that is returned from a task spawned to create a blockchain account in the redis
pin validity. cache.
:return: The presence of a valid pin and status of the account being active. :param phone_number: The phone number for the user whose account is being created.
:rtype: bool :type phone_number: str
""" :param task_uuid: A celery task id
valid_pin = None :type task_uuid: str
if self.get_account_status() == 'ACTIVE' and self.password_hash is not None: """
valid_pin = True cache = Cache.store
return valid_pin 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)

View File

@ -8,11 +8,11 @@ from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import ( from sqlalchemy.pool import (
StaticPool, StaticPool,
QueuePool, QueuePool,
AssertionPool, AssertionPool,
NullPool, NullPool,
) )
logg = logging.getLogger().getChild(__name__) logg = logging.getLogger().getChild(__name__)
@ -42,14 +42,12 @@ class SessionBase(Model):
localsessions = {} localsessions = {}
"""Contains dictionary of sessions initiated by db model components""" """Contains dictionary of sessions initiated by db model components"""
@staticmethod @staticmethod
def create_session(): def create_session():
"""Creates a new database session. """Creates a new database session.
""" """
return SessionBase.sessionmaker() return SessionBase.sessionmaker()
@staticmethod @staticmethod
def _set_engine(engine): def _set_engine(engine):
"""Sets the database engine static property """Sets the database engine static property
@ -57,7 +55,6 @@ class SessionBase(Model):
SessionBase.engine = engine SessionBase.engine = engine
SessionBase.sessionmaker = sessionmaker(bind=SessionBase.engine) SessionBase.sessionmaker = sessionmaker(bind=SessionBase.engine)
@staticmethod @staticmethod
def connect(dsn, pool_size=16, debug=False): def connect(dsn, pool_size=16, debug=False):
"""Create new database connection engine and connect to database backend. """Create new database connection engine and connect to database backend.
@ -71,14 +68,14 @@ class SessionBase(Model):
if pool_size > 1: if pool_size > 1:
logg.info('db using queue pool') logg.info('db using queue pool')
e = create_engine( e = create_engine(
dsn, dsn,
max_overflow=pool_size*3, max_overflow=pool_size * 3,
pool_pre_ping=True, pool_pre_ping=True,
pool_size=pool_size, pool_size=pool_size,
pool_recycle=60, pool_recycle=60,
poolclass=poolclass, poolclass=poolclass,
echo=debug, echo=debug,
) )
else: else:
if pool_size == 0: if pool_size == 0:
poolclass = NullPool poolclass = NullPool
@ -87,20 +84,19 @@ class SessionBase(Model):
else: else:
poolclass = StaticPool poolclass = StaticPool
e = create_engine( e = create_engine(
dsn, dsn,
poolclass=poolclass, poolclass=poolclass,
echo=debug, echo=debug,
) )
else: else:
logg.info('db connection not poolable') logg.info('db connection not poolable')
e = create_engine( e = create_engine(
dsn, dsn,
echo=debug, echo=debug,
) )
SessionBase._set_engine(e) SessionBase._set_engine(e)
@staticmethod @staticmethod
def disconnect(): def disconnect():
"""Disconnect from database and free resources. """Disconnect from database and free resources.
@ -108,18 +104,16 @@ class SessionBase(Model):
SessionBase.engine.dispose() SessionBase.engine.dispose()
SessionBase.engine = None SessionBase.engine = None
@staticmethod @staticmethod
def bind_session(session=None): def bind_session(session=None):
localsession = session localsession = session
if localsession == None: if localsession is None:
localsession = SessionBase.create_session() localsession = SessionBase.create_session()
localsession_key = str(id(localsession)) localsession_key = str(id(localsession))
logg.debug('creating new session {}'.format(localsession_key)) logg.debug('creating new session {}'.format(localsession_key))
SessionBase.localsessions[localsession_key] = localsession SessionBase.localsessions[localsession_key] = localsession
return localsession return localsession
@staticmethod @staticmethod
def release_session(session=None): def release_session(session=None):
session_key = str(id(session)) session_key = str(id(session))

View File

@ -3,6 +3,7 @@ import logging
# third-party imports # third-party imports
from sqlalchemy import Column, String from sqlalchemy import Column, String
from sqlalchemy.orm.session import Session
# local imports # local imports
from cic_ussd.db.models.base import SessionBase from cic_ussd.db.models.base import SessionBase
@ -17,3 +18,17 @@ class TaskTracker(SessionBase):
self.task_uuid = task_uuid self.task_uuid = task_uuid
task_uuid = Column(String, nullable=False) 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)

View File

@ -2,9 +2,10 @@
import logging import logging
# third-party imports # third-party imports
from sqlalchemy import Column, String, Integer from sqlalchemy import Column, desc, Integer, String
from sqlalchemy.dialects.postgresql import JSON from sqlalchemy.dialects.postgresql import JSON
from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy.orm.session import Session
# local imports # local imports
from cic_ussd.db.models.base import SessionBase from cic_ussd.db.models.base import SessionBase
@ -16,26 +17,26 @@ logg = logging.getLogger(__name__)
class UssdSession(SessionBase): class UssdSession(SessionBase):
__tablename__ = 'ussd_session' __tablename__ = 'ussd_session'
data = Column(JSON)
external_session_id = Column(String, nullable=False, index=True, unique=True) external_session_id = Column(String, nullable=False, index=True, unique=True)
service_code = Column(String, nullable=False)
msisdn = Column(String, nullable=False) msisdn = Column(String, nullable=False)
user_input = Column(String) service_code = Column(String, nullable=False)
state = Column(String, nullable=False) state = Column(String, nullable=False)
session_data = Column(JSON) user_input = Column(String)
version = Column(Integer, nullable=False) version = Column(Integer, nullable=False)
def set_data(self, key, session, value): def set_data(self, key, session, value):
if self.session_data is None: if self.data is None:
self.session_data = {} self.data = {}
self.session_data[key] = value self.data[key] = value
# https://stackoverflow.com/questions/42559434/updates-to-json-field-dont-persist-to-db # 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) session.add(self)
def get_data(self, key): def get_data(self, key):
if self.session_data is not None: if self.data is not None:
return self.session_data.get(key) return self.data.get(key)
else: else:
return None return None
@ -51,9 +52,37 @@ class UssdSession(SessionBase):
session.add(self) session.add(self)
@staticmethod @staticmethod
def have_session_for_phone(phone): def has_record_for_phone_number(phone_number: str, session: Session):
r = UssdSession.session.query(UssdSession).filter_by(msisdn=phone).first() """
return r is not None :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): def to_json(self):
""" This function serializes the in db ussd session object to a JSON object """ This function serializes the in db ussd session object to a JSON object
@ -61,11 +90,11 @@ class UssdSession(SessionBase):
:rtype: dict :rtype: dict
""" """
return { return {
"data": self.data,
"external_session_id": self.external_session_id, "external_session_id": self.external_session_id,
"service_code": self.service_code,
"msisdn": self.msisdn, "msisdn": self.msisdn,
"user_input": self.user_input, "service_code": self.service_code,
"state": self.state, "state": self.state,
"session_data": self.session_data, "user_input": self.user_input,
"version": self.version "version": self.version
} }

View File

@ -8,27 +8,27 @@ class SessionNotFoundError(Exception):
pass pass
class InvalidFileFormatError(OSError): class InvalidFileFormatError(Exception):
"""Raised when the file format is invalid.""" """Raised when the file format is invalid."""
pass pass
class ActionDataNotFoundError(OSError): class AccountCreationDataNotFound(Exception):
"""Raised when action data matching a specific task uuid is not found in the redis cache""" """Raised when account creation data matching a specific task uuid is not found in the redis cache"""
pass pass
class MetadataNotFoundError(OSError): class MetadataNotFoundError(Exception):
"""Raised when metadata is expected but not available in cache.""" """Raised when metadata is expected but not available in cache."""
pass pass
class UnsupportedMethodError(OSError): class UnsupportedMethodError(Exception):
"""Raised when the method passed to the make request function is unsupported.""" """Raised when the method passed to the make request function is unsupported."""
pass pass
class CachedDataNotFoundError(OSError): class CachedDataNotFoundError(Exception):
"""Raised when the method passed to the make request function is unsupported.""" """Raised when the method passed to the make request function is unsupported."""
pass pass
@ -51,3 +51,5 @@ class InitializationError(Exception):
class UnknownUssdRecipient(Exception): class UnknownUssdRecipient(Exception):
"""Raised when a recipient of a transaction is not known to the ussd application.""" """Raised when a recipient of a transaction is not known to the ussd application."""

View File

View 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

View 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

View 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]

View File

@ -65,11 +65,10 @@ class UssdMenu:
:rtype: Document. :rtype: Document.
""" """
menu = UssdMenu.ussd_menu_db.get(UssdMenu.Menu.name == name) menu = UssdMenu.ussd_menu_db.get(UssdMenu.Menu.name == name)
if not menu: if 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 return menu
logg.error("No USSD Menu with name {}".format(name))
return UssdMenu.ussd_menu_db.get(UssdMenu.Menu.name == 'exit_invalid_request')
@staticmethod @staticmethod
def set_description(name: str, description: str): def set_description(name: str, description: str):

View File

@ -1,46 +1,10 @@
# standard imports # standard imports
# third-party imports # external imports
import requests
from chainlib.eth.address import to_checksum
from hexathon import (
add_0x,
strip_0x,
)
# local imports # local imports
from cic_ussd.error import UnsupportedMethodError from .base import Metadata
from .custom import CustomMetadata
from .person import PersonMetadata
def make_request(method: str, url: str, data: any = None, headers: dict = None): from .phone import PhonePointerMetadata
""" from .preferences import PreferencesMetadata
: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))

View File

@ -5,17 +5,14 @@ import os
from typing import Dict, Union from typing import Dict, Union
# third-part imports # third-part imports
import requests
from cic_types.models.person import generate_metadata_pointer, Person from cic_types.models.person import generate_metadata_pointer, Person
# local imports # 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.metadata.signer import Signer
from cic_ussd.redis import cache_data
from cic_ussd.error import MetadataStoreError
logg = logging.getLogger(__file__)
logg = logging.getLogger().getChild(__name__)
class Metadata: class Metadata:
@ -27,37 +24,10 @@ class Metadata:
base_url = None 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): class MetadataRequestsHandler(Metadata):
def __init__(self, cic_type: str, identifier: bytes, engine: str = 'pgp'): 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.cic_type = cic_type
self.engine = engine self.engine = engine
self.headers = { self.headers = {
@ -73,22 +43,16 @@ class MetadataRequestsHandler(Metadata):
self.url = os.path.join(self.base_url, self.metadata_pointer) self.url = os.path.join(self.base_url, self.metadata_pointer)
def create(self, data: Union[Dict, str]): 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) data = json.dumps(data)
result = make_request(method='POST', url=self.url, data=data, headers=self.headers) 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() metadata = result.json()
self.edit(data=metadata) return self.edit(data=metadata)
def edit(self, data: Union[Dict, str]): 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() cic_meta_signer = Signer()
signature = cic_meta_signer.sign_digest(data=data) signature = cic_meta_signer.sign_digest(data=data)
algorithm = cic_meta_signer.get_operational_key().get('algo') algorithm = cic_meta_signer.get_operational_key().get('algo')
@ -104,42 +68,34 @@ class MetadataRequestsHandler(Metadata):
formatted_data = json.dumps(formatted_data) formatted_data = json.dumps(formatted_data)
result = make_request(method='PUT', url=self.url, data=formatted_data, headers=self.headers) result = make_request(method='PUT', url=self.url, data=formatted_data, headers=self.headers)
logg.info(f'signed metadata submission status: {result.status_code}.') logg.info(f'signed metadata submission status: {result.status_code}.')
metadata_http_error_handler(result=result) error_handler(result=result)
try: try:
decoded_identifier = self.identifier.decode("utf-8") decoded_identifier = self.identifier.decode("utf-8")
except UnicodeDecodeError: except UnicodeDecodeError:
decoded_identifier = self.identifier.hex() decoded_identifier = self.identifier.hex()
logg.info(f'identifier: {decoded_identifier}. metadata pointer: {self.metadata_pointer} set to: {data}.') logg.info(f'identifier: {decoded_identifier}. metadata pointer: {self.metadata_pointer} set to: {data}.')
return result
def query(self): def query(self):
""" """"""
:return:
:rtype:
"""
# retrieve the metadata
result = make_request(method='GET', url=self.url) result = make_request(method='GET', url=self.url)
metadata_http_error_handler(result=result) error_handler(result=result)
# json serialize retrieved data
result_data = result.json() result_data = result.json()
# validate result data format
if not isinstance(result_data, dict): if not isinstance(result_data, dict):
raise ValueError(f'Invalid result data object: {result_data}.') raise ValueError(f'Invalid result data object: {result_data}.')
if result.status_code == 200: if result.status_code == 200:
if self.cic_type == ':cic.person': if self.cic_type == ':cic.person':
# validate person metadata
person = Person() person = Person()
person_data = person.deserialize(person_data=result_data) person_data = person.deserialize(person_data=result_data)
# format new person data for caching
serialized_person_data = person_data.serialize() serialized_person_data = person_data.serialize()
data = json.dumps(serialized_person_data) data = json.dumps(serialized_person_data)
else: else:
data = json.dumps(result_data) data = json.dumps(result_data)
# cache metadata
cache_data(key=self.metadata_pointer, data=data) cache_data(key=self.metadata_pointer, data=data)
logg.debug(f'caching: {data} with key: {self.metadata_pointer}') logg.debug(f'caching: {data} with key: {self.metadata_pointer}')
return result_data return result_data
def get_cached_metadata(self):
""""""
key = generate_metadata_pointer(self.identifier, self.cic_type)
return get_cached_data(key)

View File

@ -1,6 +1,6 @@
# standard imports # standard imports
# third-party imports # external imports
# local imports # local imports
from .base import MetadataRequestsHandler from .base import MetadataRequestsHandler

View File

@ -29,10 +29,8 @@ class Signer:
def __init__(self): def __init__(self):
self.gpg = gnupg.GPG(gnupghome=self.gpg_path) self.gpg = gnupg.GPG(gnupghome=self.gpg_path)
# parse key file data with open(self.key_file_path, 'r') as key_file:
key_file = open(self.key_file_path, 'r') self.key_data = key_file.read()
self.key_data = key_file.read()
key_file.close()
def get_operational_key(self): def get_operational_key(self):
""" """

View File

@ -21,9 +21,6 @@ class Notifier:
:param preferred_language: A notification recipient's preferred language. :param preferred_language: A notification recipient's preferred language.
:type preferred_language: str :type preferred_language: str
""" """
if self.queue is False: notify_api = Api() if self.queue is False else Api(queue=self.queue)
notify_api = Api()
else:
notify_api = Api(queue=self.queue)
message = translation_for(key=key, preferred_language=preferred_language, **kwargs) message = translation_for(key=key, preferred_language=preferred_language, **kwargs)
notify_api.sms(recipient=phone_number, message=message) notify_api.sms(recipient=phone_number, message=message)

View File

@ -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)

View File

@ -29,9 +29,10 @@ def process_phone_number(phone_number: str, region: str):
pass pass
phone_number_object = phonenumbers.parse(phone_number, region) 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: class Support:
phone_number = None phone_number = None

View File

@ -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)

View 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)

View 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

View 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)

View File

@ -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'

View File

@ -5,7 +5,7 @@ requests offering control of user account states to a staff behind the client.
# standard imports # standard imports
import logging import logging
from urllib.parse import quote_plus
# third-party imports # third-party imports
from confini import Config from confini import Config
@ -13,12 +13,11 @@ from confini import Config
# local imports # local imports
from cic_ussd.db import dsn_from_config from cic_ussd.db import dsn_from_config
from cic_ussd.db.models.base import SessionBase from cic_ussd.db.models.base import SessionBase
from cic_ussd.operations import define_response_with_content from cic_ussd.http.requests import get_request_endpoint
from cic_ussd.requests import (get_request_endpoint, from cic_ussd.http.responses import with_content_headers
get_query_parameters, from cic_ussd.http.routes import locked_accounts, handle_pin_requests
process_pin_reset_requests,
process_locked_accounts_requests)
from cic_ussd.runnable.server_base import exportable_parser, logg from cic_ussd.runnable.server_base import exportable_parser, logg
args = exportable_parser.parse_args() args = exportable_parser.parse_args()
# define log levels # define log levels
@ -28,7 +27,7 @@ elif args.v:
logging.getLogger().setLevel(logging.INFO) logging.getLogger().setLevel(logging.INFO)
# parse config # parse config
config = Config(args.c, env_prefix=args.env_prefix) config = Config(args.c, args.env_prefix)
config.process() config.process()
config.censor('PASSWORD', 'DATABASE') config.censor('PASSWORD', 'DATABASE')
logg.debug('config loaded from {}:\n{}'.format(args.c, config)) logg.debug('config loaded from {}:\n{}'.format(args.c, config))
@ -56,20 +55,13 @@ def application(env, start_response):
session = SessionBase.create_session() session = SessionBase.create_session()
if get_request_endpoint(env) == '/pin': if get_request_endpoint(env) == '/pin':
phone_number = get_query_parameters(env=env, query_name='phoneNumber') return handle_pin_requests(env, session, errors_headers, start_response)
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]
# handle requests for locked accounts response, message = locked_accounts(env, session)
response, message = process_locked_accounts_requests(env=env, session=session) response_bytes, headers = with_content_headers(headers, response)
response_bytes, headers = define_response_with_content(headers=headers, response=response)
start_response(message, headers) start_response(message, headers)
session.commit() session.commit()
session.close() session.close()
return [response_bytes] return [response_bytes]

View File

@ -12,13 +12,13 @@ from chainlib.chain import ChainSpec
from confini import Config from confini import Config
# local imports # 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 import dsn_from_config
from cic_ussd.db.models.base import SessionBase from cic_ussd.db.models.base import SessionBase
from cic_ussd.metadata.signer import Signer from cic_ussd.metadata.signer import Signer
from cic_ussd.metadata.base import Metadata from cic_ussd.metadata.base import Metadata
from cic_ussd.phone_number import Support 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.session.ussd_session import UssdSession as InMemoryUssdSession
from cic_ussd.validator import validate_presence 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('-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('-v', action='store_true', help='be verbose')
arg_parser.add_argument('-vv', action='store_true', help='be more 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() args = arg_parser.parse_args()
# define log levels # define log levels
@ -52,7 +53,8 @@ logg.debug('config loaded from {}:\n{}'.format(args.c, config))
# connect to database # connect to database
data_source_name = dsn_from_config(config) 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 # verify database connection with minimal sanity query
session = SessionBase.create_session() session = SessionBase.create_session()
@ -60,12 +62,12 @@ session.execute('SELECT version_num FROM alembic_version')
session.close() session.close()
# define universal redis cache access # 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'), port=config.get('REDIS_PORT'),
password=config.get('REDIS_PASSWORD'), password=config.get('REDIS_PASSWORD'),
db=config.get('REDIS_DATABASE'), db=config.get('REDIS_DATABASE'),
decode_responses=True) decode_responses=True)
InMemoryUssdSession.redis_cache = InMemoryStore.cache InMemoryUssdSession.store = Cache.store
# define metadata URL # define metadata URL
Metadata.base_url = config.get('CIC_META_URL') Metadata.base_url = config.get('CIC_META_URL')
@ -82,8 +84,8 @@ if key_file_path:
Signer.key_file_path = key_file_path Signer.key_file_path = key_file_path
# set up translations # set up translations
i18n.load_path.append(config.get('APP_LOCALE_PATH')) i18n.load_path.append(config.get('LOCALE_PATH'))
i18n.set('fallback', config.get('APP_LOCALE_FALLBACK')) i18n.set('fallback', config.get('LOCALE_FALLBACK'))
chain_spec = ChainSpec( chain_spec = ChainSpec(
common_name=config.get('CIC_COMMON_NAME'), common_name=config.get('CIC_COMMON_NAME'),
@ -92,8 +94,7 @@ chain_spec = ChainSpec(
) )
Chain.spec = chain_spec Chain.spec = chain_spec
Support.phone_number = config.get('APP_SUPPORT_PHONE_NUMBER') Support.phone_number = config.get('OFFICE_SUPPORT_PHONE')
# set up celery # set up celery
current_app = celery.Celery(__name__) current_app = celery.Celery(__name__)
@ -122,12 +123,12 @@ if result[:4] == 'file':
result_queue = tempfile.mkdtemp() result_queue = tempfile.mkdtemp()
current_app.conf.update({ current_app.conf.update({
'result_backend': 'file://{}'.format(result_queue), 'result_backend': 'file://{}'.format(result_queue),
}) })
logg.warning('celery backend store dir {} created, will NOT be deleted on shutdown'.format(result_queue)) logg.warning('celery backend store dir {} created, will NOT be deleted on shutdown'.format(result_queue))
else: else:
current_app.conf.update({ current_app.conf.update({
'result_backend': result, 'result_backend': result,
}) })
import cic_ussd.tasks import cic_ussd.tasks
@ -147,4 +148,3 @@ def main():
if __name__ == '__main__': if __name__ == '__main__':
main() main()

View File

@ -14,26 +14,25 @@ from chainlib.chain import ChainSpec
from confini import Config from confini import Config
# local imports # 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 import dsn_from_config
from cic_ussd.db.models.base import SessionBase from cic_ussd.db.models.base import SessionBase
from cic_ussd.encoder import PasswordEncoder from cic_ussd.encoder import PasswordEncoder
from cic_ussd.error import InitializationError from cic_ussd.error import InitializationError
from cic_ussd.files.local_files import create_local_file_data_stores, json_file_parser 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.menu.ussd_menu import UssdMenu
from cic_ussd.metadata.signer import Signer
from cic_ussd.metadata.base import Metadata from cic_ussd.metadata.base import Metadata
from cic_ussd.operations import (define_response_with_content, from cic_ussd.metadata.signer import Signer
process_menu_interaction_requests,
define_multilingual_responses)
from cic_ussd.phone_number import process_phone_number, Support, E164Format from cic_ussd.phone_number import process_phone_number, Support, E164Format
from cic_ussd.processor import get_default_token_data from cic_ussd.processor.ussd import handle_menu_operations
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.runnable.server_base import exportable_parser, logg from cic_ussd.runnable.server_base import exportable_parser, logg
from cic_ussd.session.ussd_session import UssdSession as InMemoryUssdSession from cic_ussd.session.ussd_session import UssdSession as InMemoryUssdSession
from cic_ussd.state_machine import UssdStateMachine 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 from cic_ussd.validator import check_ip, check_request_content_length, validate_phone_number, validate_presence
args = exportable_parser.parse_args() args = exportable_parser.parse_args()
@ -57,8 +56,8 @@ SessionBase.connect(data_source_name,
debug=config.true('DATABASE_DEBUG')) debug=config.true('DATABASE_DEBUG'))
# set up translations # set up translations
i18n.load_path.append(config.get('APP_LOCALE_PATH')) i18n.load_path.append(config.get('LOCALE_PATH'))
i18n.set('fallback', config.get('APP_LOCALE_FALLBACK')) i18n.set('fallback', config.get('LOCALE_FALLBACK'))
# set Fernet key # set Fernet key
PasswordEncoder.set_key(config.get('APP_PASSWORD_PEPPER')) 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 UssdMenu.ussd_menu_db = ussd_menu_db
# define universal redis cache access # 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'), port=config.get('REDIS_PORT'),
password=config.get('REDIS_PASSWORD'), password=config.get('REDIS_PASSWORD'),
db=config.get('REDIS_DATABASE'), db=config.get('REDIS_DATABASE'),
decode_responses=True) decode_responses=True)
InMemoryUssdSession.redis_cache = InMemoryStore.cache InMemoryUssdSession.store = Cache.store
# define metadata URL # define metadata URL
Metadata.base_url = config.get('CIC_META_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')) celery.Celery(backend=config.get('CELERY_RESULT_URL'), broker=config.get('CELERY_BROKER_URL'))
# load states and transitions data # load states and transitions data
states = json_file_parser(filepath=config.get('STATEMACHINE_STATES')) states = json_file_parser(filepath=config.get('MACHINE_STATES'))
transitions = json_file_parser(filepath=config.get('STATEMACHINE_TRANSITIONS')) transitions = json_file_parser(filepath=config.get('MACHINE_TRANSITIONS'))
chain_spec = ChainSpec( chain_spec = ChainSpec(
common_name=config.get('CIC_COMMON_NAME'), common_name=config.get('CIC_COMMON_NAME'),
@ -108,24 +107,22 @@ UssdStateMachine.states = states
UssdStateMachine.transitions = transitions UssdStateMachine.transitions = transitions
# retrieve default token data # retrieve default token data
default_token_data = get_default_token_data()
chain_str = Chain.spec.__str__() chain_str = Chain.spec.__str__()
default_token_data = query_default_token(chain_str)
# cache default token for re-usability # cache default token for re-usability
if default_token_data: if default_token_data:
cache_key = create_cached_data_key( cache_key = cache_data_key(chain_str.encode('utf-8'), ':cic.default_token_data')
identifier=chain_str.encode('utf-8'),
salt=':cic.default_token_data'
)
cache_data(key=cache_key, data=json.dumps(default_token_data)) cache_data(key=cache_key, data=json.dumps(default_token_data))
else: else:
raise InitializationError(f'Default token data for: {chain_str} not found.') 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') E164Format.region = config.get('E164_REGION')
Support.phone_number = config.get('APP_SUPPORT_PHONE_NUMBER') Support.phone_number = config.get('OFFICE_SUPPORT_PHONE')
def application(env, start_response): def application(env, start_response):
@ -168,49 +165,37 @@ def application(env, start_response):
except TypeError: except TypeError:
user_input = "" user_input = ""
# add validation for phone number
if phone_number: if phone_number:
phone_number = process_phone_number(phone_number=phone_number, region=E164Format.region) phone_number = process_phone_number(phone_number=phone_number, region=E164Format.region)
# validate ip address
if not check_ip(config=config, env=env): if not check_ip(config=config, env=env):
start_response('403 Sneaky, sneaky', errors_headers) start_response('403 Sneaky, sneaky', errors_headers)
return [] return []
# validate content length
if not check_request_content_length(config=config, env=env): if not check_request_content_length(config=config, env=env):
start_response('400 Size matters', errors_headers) start_response('400 Size matters', errors_headers)
return [] return []
# validate service code
if service_code not in valid_service_codes: if service_code not in valid_service_codes:
response = define_multilingual_responses( response = translation_for(
key='ussd.kenya.invalid_service_code', 'ussd.kenya.invalid_service_code',
locales=['en', 'sw'], i18n.config.get('fallback'),
prefix='END', valid_service_code=valid_service_codes[0]
valid_service_code=valid_service_codes[0]) )
response_bytes, headers = define_response_with_content(headers=headers, response=response) response_bytes, headers = with_content_headers(headers, response)
start_response('200 OK', headers) start_response('200 OK', headers)
return [response_bytes] return [response_bytes]
# validate phone number
if not validate_phone_number(phone_number): if not validate_phone_number(phone_number):
logg.error('invalid phone number {}'.format(phone_number)) logg.error('invalid phone number {}'.format(phone_number))
start_response('400 Invalid phone number format', errors_headers) start_response('400 Invalid phone number format', errors_headers)
return [] return []
logg.debug('session {} started for {}'.format(external_session_id, phone_number)) logg.debug('session {} started for {}'.format(external_session_id, phone_number))
# handle menu interaction requests response = handle_menu_operations(
chain_str = chain_spec.__str__() chain_str, external_session_id, phone_number, args.q, service_code, session, user_input
response = process_menu_interaction_requests(chain_str=chain_str, )
external_session_id=external_session_id, response_bytes, headers = with_content_headers(headers, response)
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)
start_response('200 OK,', headers) start_response('200 OK,', headers)
session.commit() session.commit()
session.close() session.close()
@ -223,4 +208,3 @@ def application(env, start_response):
session.close() session.close()
start_response('405 Play by the rules', errors_headers) start_response('405 Play by the rules', errors_headers)
return [] return []

View File

@ -3,9 +3,15 @@ import logging
from typing import Optional from typing import Optional
import json import json
# third party imports # external imports
import celery
from redis import Redis 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() logg = logging.getLogger()
@ -13,18 +19,18 @@ logg = logging.getLogger()
class UssdSession: class UssdSession:
""" """
This class defines the USSD session object that is called whenever a user interacts with the system. 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. :cvar store: The in-memory redis cache.
:type redis_cache: Redis :type store: Redis
""" """
redis_cache: Redis = None store: Redis = None
def __init__(self, def __init__(self,
external_session_id: str, external_session_id: str,
service_code: str,
msisdn: str, msisdn: str,
user_input: str, service_code: str,
state: 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. 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. :param external_session_id: The Africa's Talking session ID.
@ -37,16 +43,17 @@ class UssdSession:
:type user_input: str. :type user_input: str.
:param state: The name of the USSD menu that the user was interacting with. :param state: The name of the USSD menu that the user was interacting with.
:type state: str. :type state: str.
:param session_data: Any additional data that was persisted during the user's interaction with the system. :param data: Any additional data that was persisted during the user's interaction with the system.
:type session_data: dict. :type data: dict.
""" """
self.data = data
self.external_session_id = external_session_id self.external_session_id = external_session_id
self.service_code = service_code
self.msisdn = msisdn self.msisdn = msisdn
self.user_input = user_input self.service_code = service_code
self.state = state self.state = state
self.session_data = session_data self.user_input = user_input
session = self.redis_cache.get(external_session_id)
session = self.store.get(external_session_id)
if session: if session:
session = json.loads(session) session = json.loads(session)
self.version = session.get('version') + 1 self.version = session.get('version') + 1
@ -54,16 +61,16 @@ class UssdSession:
self.version = 1 self.version = 1
self.session = { self.session = {
'data': self.data,
'external_session_id': self.external_session_id, 'external_session_id': self.external_session_id,
'service_code': self.service_code,
'msisdn': self.msisdn, 'msisdn': self.msisdn,
'user_input': self.user_input, 'service_code': self.service_code,
'state': self.state, 'state': self.state,
'session_data': self.session_data, 'user_input': self.user_input,
'version': self.version 'version': self.version
} }
self.redis_cache.set(self.external_session_id, json.dumps(self.session)) self.store.set(self.external_session_id, json.dumps(self.session))
self.redis_cache.persist(self.external_session_id) self.store.persist(self.external_session_id)
def set_data(self, key: str, value: str) -> None: 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. :param value: The actual data to be stored in the session data.
:type value: str. :type value: str.
""" """
if self.session_data is None: if self.data is None:
self.session_data = {} self.data = {}
self.session_data[key] = value self.data[key] = value
self.redis_cache.set(self.external_session_id, json.dumps(self.session)) self.store.set(self.external_session_id, json.dumps(self.session))
def get_data(self, key: str) -> Optional[str]: 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. :return: This function returns the queried data if found, else it doesn't return any value.
:rtype: str. :rtype: str.
""" """
if self.session_data is not None: if self.data is not None:
return self.session_data.get(key) return self.data.get(key)
else: else:
return None return None
@ -97,11 +104,155 @@ class UssdSession:
:rtype: dict :rtype: dict
""" """
return { return {
"data": self.data,
"external_session_id": self.external_session_id, "external_session_id": self.external_session_id,
"service_code": self.service_code,
"msisdn": self.msisdn, "msisdn": self.msisdn,
"user_input": self.user_input, "user_input": self.user_input,
"service_code": self.service_code,
"state": self.state, "state": self.state,
"session_data": self.session_data,
"version": self.version "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)

View 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')

View File

@ -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.)')

View File

@ -5,44 +5,47 @@ ussd menu facilitating the return of appropriate menu responses based on said us
# standard imports # standard imports
from typing import Tuple from typing import Tuple
# external imports
from sqlalchemy.orm.session import Session
# local imports # local imports
from cic_ussd.db.models.account import Account 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' """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. :param state_machine_data: A tuple containing user input, a ussd session and user object.
:type state_machine_data: str :type state_machine_data: str
:return: A user input's match with '1' :return: A user input's match with '1'
:rtype: bool :rtype: bool
""" """
user_input, ussd_session, user, session = state_machine_data user_input, ussd_session, account, session = state_machine_data
return user_input == '1' 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' """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. :param state_machine_data: A tuple containing user input, a ussd session and user object.
:type state_machine_data: tuple :type state_machine_data: tuple
:return: A user input's match with '2' :return: A user input's match with '2'
:rtype: bool :rtype: bool
""" """
user_input, ussd_session, user, session = state_machine_data user_input, ussd_session, account, session = state_machine_data
return user_input == '2' 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' """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. :param state_machine_data: A tuple containing user input, a ussd session and user object.
:type state_machine_data: tuple :type state_machine_data: tuple
:return: A user input's match with '3' :return: A user input's match with '3'
:rtype: bool :rtype: bool
""" """
user_input, ussd_session, user, session = state_machine_data user_input, ussd_session, account, session = state_machine_data
return user_input == '3' 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' 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. :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' :return: A user input's match with '4'
:rtype: bool :rtype: bool
""" """
user_input, ussd_session, user, session = state_machine_data user_input, ussd_session, account, session = state_machine_data
return user_input == '4' 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' 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. :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' :return: A user input's match with '5'
:rtype: bool :rtype: bool
""" """
user_input, ussd_session, user, session = state_machine_data user_input, ussd_session, account, session = state_machine_data
return user_input == '5' 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' 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. :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' :return: A user input's match with '6'
:rtype: bool :rtype: bool
""" """
user_input, ussd_session, user, session = state_machine_data user_input, ussd_session, account, session = state_machine_data
return user_input == '6' 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' 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. :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' :return: A user input's match with '00'
:rtype: bool :rtype: bool
""" """
user_input, ussd_session, user, session = state_machine_data user_input, ussd_session, account, session = state_machine_data
return user_input == '00' 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' 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. :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' :return: A user input's match with '99'
:rtype: bool :rtype: bool
""" """
user_input, ussd_session, user, session = state_machine_data user_input, ussd_session, account, session = state_machine_data
return user_input == '99' return user_input == '99'

View File

@ -16,8 +16,7 @@ from cic_ussd.db.models.account import Account
from cic_ussd.db.models.base import SessionBase from cic_ussd.db.models.base import SessionBase
from cic_ussd.db.enum import AccountStatus from cic_ussd.db.enum import AccountStatus
from cic_ussd.encoder import create_password_hash, check_password_hash 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.session.ussd_session import create_or_update_session, persist_ussd_session
from cic_ussd.redis import InMemoryStore
logg = logging.getLogger(__file__) 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 :return: A pin's validity
:rtype: bool :rtype: bool
""" """
user_input, ussd_session, user, session = state_machine_data user_input, ussd_session, account, session = state_machine_data
pin_is_valid = False pin_is_valid = False
matcher = r'^\d{4}$' matcher = r'^\d{4}$'
if re.match(matcher, user_input): 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. :return: A match between two pin values.
:rtype: bool :rtype: bool
""" """
user_input, ussd_session, user, session = state_machine_data user_input, ussd_session, account, session = state_machine_data
return user.verify_password(password=user_input) 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: 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. :return: A match between two pin values.
:rtype: bool :rtype: bool
""" """
user_input, ussd_session, user, session = state_machine_data user_input, ussd_session, account, session = state_machine_data
return user.get_account_status() == AccountStatus.LOCKED.name return account.get_status(session) == AccountStatus.LOCKED.name
def save_initial_pin_to_session_data(state_machine_data: Tuple[str, dict, Account, Session]): 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 :type state_machine_data: tuple
""" """
user_input, ussd_session, user, session = state_machine_data 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) initial_pin = create_password_hash(user_input)
if ussd_session.get('session_data'): if ussd_session.get('data'):
session_data = ussd_session.get('session_data') data = ussd_session.get('data')
session_data['initial_pin'] = initial_pin data['initial_pin'] = initial_pin
else: else:
session_data = { data = {
'initial_pin': initial_pin 'initial_pin': initial_pin
} }
external_session_id = ussd_session.get('external_session_id')
# create new in memory ussd session with current ussd session data
create_or_update_session( create_or_update_session(
external_session_id=external_session_id, external_session_id=external_session_id,
phone=in_redis_ussd_session.get('msisdn'), msisdn=ussd_session.get('msisdn'),
service_code=in_redis_ussd_session.get('service_code'), service_code=ussd_session.get('service_code'),
user_input=user_input, user_input=user_input,
current_menu=in_redis_ussd_session.get('state'), state=ussd_session.get('state'),
session=session, 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: 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 :rtype: bool
""" """
user_input, ussd_session, user, session = state_machine_data 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) 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 user_input, ussd_session, user, session = state_machine_data
session = SessionBase.bind_session(session=session) 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 user.password_hash = password_hash
session.add(user) session.add(user)
session.flush() 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. :return: A match between two pin values.
:rtype: bool :rtype: bool
""" """
user_input, ussd_session, user, session = state_machine_data user_input, ussd_session, account, session = state_machine_data
return user.get_account_status() == AccountStatus.LOCKED.name return account.get_status(session) == AccountStatus.LOCKED.name
def is_valid_new_pin(state_machine_data: Tuple[str, dict, Account, Session]) -> bool: def is_valid_new_pin(state_machine_data: Tuple[str, dict, Account, Session]) -> bool:

View File

@ -1,23 +1,28 @@
# standard imports # standard imports
import logging
from typing import Tuple from typing import Tuple
# external imports
from sqlalchemy.orm.session import Session
# local imports # 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 from cic_ussd.db.models.account import Account
from cic_ussd.notifications import Notifier
logg = logging.getLogger() from cic_ussd.phone_number import Support
def send_terms_to_user_if_required(state_machine_data: Tuple[str, dict, Account]): def upsell_unregistered_recipient(state_machine_data: Tuple[str, dict, Account, Session]):
user_input, ussd_session, user, session = state_machine_data """"""
logg.debug('Requires integration to cic-notify.') user_input, ussd_session, account, session = state_machine_data
notifier = Notifier()
phone_number = ussd_session.get('data')['recipient_phone_number']
def process_mini_statement_request(state_machine_data: Tuple[str, dict, Account]): preferred_language = get_cached_preferred_language(account.blockchain_address)
user_input, ussd_session, user, session = state_machine_data token_symbol = get_default_token_symbol()
logg.debug('Requires integration to cic-notify.') tx_sender_information = account.standard_metadata_id()
notifier.send_sms_notification('sms.upsell_unregistered_recipient',
phone_number,
def upsell_unregistered_recipient(state_machine_data: Tuple[str, dict, Account]): preferred_language,
user_input, ussd_session, user, session = state_machine_data tx_sender_information=tx_sender_information,
logg.debug('Requires integration to cic-notify.') token_symbol=token_symbol,
support_phone=Support.phone_number)

View File

@ -1,24 +1,21 @@
# standard imports # standard imports
import json
import logging import logging
from typing import Tuple from typing import Tuple
# third party imports # third party imports
import celery import celery
from sqlalchemy.orm.session import Session
# local imports # local imports
from cic_ussd.balance import compute_operational_balance from cic_ussd.account.balance import get_cached_available_balance
from cic_ussd.chain import Chain 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.account import Account
from cic_ussd.db.models.base import SessionBase 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.phone_number import process_phone_number, E164Format
from cic_ussd.processor import retrieve_token_symbol from cic_ussd.session.ussd_session import save_session_data
from cic_ussd.redis import create_cached_data_key, get_cached_data from sqlalchemy.orm.session import Session
from cic_ussd.transactions import OutgoingTransactionProcessor
logg = logging.getLogger(__file__) 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 :return: A user's validity
:rtype: bool :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) phone_number = process_phone_number(user_input, E164Format.region)
session = SessionBase.bind_session(session=session) session = SessionBase.bind_session(session=session)
recipient = Account.get_by_phone_number(phone_number=phone_number, session=session) recipient = Account.get_by_phone_number(phone_number=phone_number, session=session)
SessionBase.release_session(session=session) SessionBase.release_session(session=session)
is_not_initiator = phone_number != user.phone_number is_not_initiator = phone_number != account.phone_number
has_active_account_status = user.get_account_status() == AccountStatus.ACTIVE.name 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 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 :return: A transaction amount's validity
:rtype: bool :rtype: bool
""" """
user_input, ussd_session, user, session = state_machine_data user_input, ussd_session, account, session = state_machine_data
try: try:
return int(user_input) > 0 return int(user_input) > 0
except ValueError: except ValueError:
@ -64,16 +63,8 @@ def has_sufficient_balance(state_machine_data: Tuple[str, dict, Account, Session
:return: An account balance's validity :return: An account balance's validity
:rtype: bool :rtype: bool
""" """
user_input, ussd_session, user, session = state_machine_data user_input, ussd_session, account, session = state_machine_data
# get cached balance return int(user_input) <= get_cached_available_balance(account.blockchain_address)
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
def save_recipient_phone_to_session_data(state_machine_data: Tuple[str, dict, Account, Session]): 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. :param state_machine_data: A tuple containing user input, a ussd session and user object.
:type state_machine_data: str :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) recipient_phone_number = process_phone_number(phone_number=user_input, region=E164Format.region)
session_data['recipient_phone_number'] = recipient_phone_number session_data['recipient_phone_number'] = recipient_phone_number
save_to_in_memory_ussd_session_data( save_session_data('cic-ussd', session, session_data, ussd_session)
queue='cic-ussd',
session=session,
session_data=session_data,
ussd_session=ussd_session)
def retrieve_recipient_metadata(state_machine_data: Tuple[str, dict, Account, 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: :return:
:rtype: :rtype:
""" """
user_input, ussd_session, user, session = state_machine_data user_input, ussd_session, account, session = state_machine_data
recipient_phone_number = process_phone_number(user_input, E164Format.region)
recipient_phone_number = process_phone_number(phone_number=user_input, region=E164Format.region) recipient = Account.get_by_phone_number(recipient_phone_number, session)
recipient = Account.get_by_phone_number(phone_number=recipient_phone_number, session=session)
blockchain_address = recipient.blockchain_address blockchain_address = recipient.blockchain_address
# retrieve and cache account's metadata
s_query_person_metadata = celery.signature( s_query_person_metadata = celery.signature(
'cic_ussd.tasks.metadata.query_person_metadata', 'cic_ussd.tasks.metadata.query_person_metadata', [blockchain_address], queue='cic-ussd')
[blockchain_address] s_query_person_metadata.apply_async()
)
s_query_person_metadata.apply_async(queue='cic-ussd')
def save_transaction_amount_to_session_data(state_machine_data: Tuple[str, dict, Account, Session]): 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. :param state_machine_data: A tuple containing user input, a ussd session and user object.
:type state_machine_data: str :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 session_data['transaction_amount'] = user_input
save_session_data('cic-ussd', session, session_data, ussd_session)
save_to_in_memory_ussd_session_data(
queue='cic-ussd',
session=session,
session_data=session_data,
ussd_session=ussd_session)
def process_transaction_request(state_machine_data: Tuple[str, dict, Account, 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. :param state_machine_data: A tuple containing user input, a ussd session and user object.
:type state_machine_data: str :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__() chain_str = Chain.spec.__str__()
# get user from phone number recipient_phone_number = ussd_session.get('data').get('recipient_phone_number')
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) recipient = Account.get_by_phone_number(phone_number=recipient_phone_number, session=session)
to_address = recipient.blockchain_address to_address = recipient.blockchain_address
from_address = user.blockchain_address from_address = account.blockchain_address
amount = int(ussd_session.get('session_data').get('transaction_amount')) amount = int(ussd_session.get('data').get('transaction_amount'))
token_symbol = retrieve_token_symbol(chain_str=chain_str) 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, from_address=from_address,
to_address=to_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)

View File

@ -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')

View File

@ -4,67 +4,58 @@ import re
from typing import Tuple from typing import Tuple
# third-party imports # 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 # local imports
from cic_ussd.db.models.account import Account from cic_ussd.db.models.account import Account
from cic_ussd.metadata import blockchain_address_to_metadata_pointer from cic_ussd.metadata import PersonMetadata
from cic_ussd.redis import get_cached_data
logg = logging.getLogger() 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. """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. :param state_machine_data: A tuple containing user input, a ussd session and user object.
:type state_machine_data: str :type state_machine_data: str
""" """
user_input, ussd_session, user, session = state_machine_data user_input, ussd_session, account, session = state_machine_data
# check for user metadata in cache identifier = bytes.fromhex(strip_0x(account.blockchain_address))
key = generate_metadata_pointer( metadata_client = PersonMetadata(identifier)
identifier=blockchain_address_to_metadata_pointer(blockchain_address=user.blockchain_address), return metadata_client.get_cached_metadata() is not None
cic_type=':cic.person'
)
user_metadata = get_cached_data(key=key)
return user_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 """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. :param state_machine_data: A tuple containing user input, a ussd session and user object.
:type state_machine_data: str :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]+$" name_matcher = "^[a-zA-Z]+$"
valid_name = re.match(name_matcher, user_input) valid_name = re.match(name_matcher, user_input)
if valid_name: return bool(valid_name)
return True
else:
return False
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: :param state_machine_data:
:type state_machine_data: :type state_machine_data:
:return: :return:
:rtype: :rtype:
""" """
user_input, ussd_session, user, session = state_machine_data user_input, ussd_session, account, session = state_machine_data
selection_matcher = "^[1-2]$" selection_matcher = "^[1-3]$"
if re.match(selection_matcher, user_input): return bool(re.match(selection_matcher, user_input))
return True
else:
return False
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: :param state_machine_data:
:type state_machine_data: :type state_machine_data:
:return: :return:
:rtype: :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 # For MVP this value is defaulting to year
return len(user_input) == 4 and int(user_input) >= 1900 return len(user_input) == 4 and int(user_input) >= 1900

View File

@ -1,14 +1,13 @@
# standard import # standard import
# third-party imports # third-party imports
# this must be included for the package to be recognized as a tasks package
import celery import celery
celery_app = celery.current_app
# export external celery task modules # export external celery task modules
from .logger import *
from .ussd_session import *
from .callback_handler import * from .callback_handler import *
from .metadata import * from .metadata import *
from .notifications import * from .notifications import *
from .processor import * from .processor import *
from .ussd_session import *
celery_app = celery.current_app

View File

@ -1,4 +1,5 @@
# standard imports # standard imports
import logging
# third-party imports # third-party imports
import celery import celery
@ -8,6 +9,8 @@ import sqlalchemy
from cic_ussd.error import MetadataStoreError from cic_ussd.error import MetadataStoreError
from cic_ussd.db.models.base import SessionBase from cic_ussd.db.models.base import SessionBase
logg = logging.getLogger(__name__)
class BaseTask(celery.Task): class BaseTask(celery.Task):

View File

@ -1,19 +1,22 @@
# standard imports # standard imports
import json import json
import logging import logging
from datetime import datetime, timedelta from datetime import timedelta
# third-party imports # third-party imports
import celery import celery
from chainlib.hash import strip_0x
# local imports # local imports
from cic_ussd.balance import compute_operational_balance, get_balances from cic_ussd.account.balance import get_balances, calculate_available_balance
from cic_ussd.chain import Chain from cic_ussd.account.statement import generate
from cic_ussd.conversions import from_wei 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.base import SessionBase
from cic_ussd.db.models.account import Account from cic_ussd.db.models.account import Account
from cic_ussd.error import ActionDataNotFoundError from cic_ussd.account.statement import filter_statement_transactions
from cic_ussd.redis import InMemoryStore, cache_data, create_cached_data_key, get_cached_data from cic_ussd.account.transaction import transaction_actors
from cic_ussd.error import AccountCreationDataNotFound
from cic_ussd.tasks.base import CriticalSQLAlchemyTask from cic_ussd.tasks.base import CriticalSQLAlchemyTask
logg = logging.getLogger(__file__) logg = logging.getLogger(__file__)
@ -21,8 +24,10 @@ celery_app = celery.current_app
@celery_app.task(bind=True, base=CriticalSQLAlchemyTask) @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 """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 :param result: The blockchain address for the created account
:type result: str :type result: str
:param url: URL provided to callback task in cic-eth should http be used for callback. :param url: URL provided to callback task in cic-eth should http be used for callback.
@ -30,266 +35,183 @@ def process_account_creation_callback(self, result: str, url: str, status_code:
:param status_code: The status of the task to create an account :param status_code: The status of the task to create an account
:type status_code: int :type status_code: int
""" """
task_uuid = self.request.root_id
cached_account_creation_data = get_cached_data(task_uuid)
if not cached_account_creation_data:
raise AccountCreationDataNotFound(f'No account creation data found for task id: {task_uuid}')
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_data(task_uuid, json.dumps(account_creation_data))
phone_number = account_creation_data.get('phone_number')
session = SessionBase.create_session() session = SessionBase.create_session()
cache = InMemoryStore.cache account = Account(blockchain_address=result, phone_number=phone_number)
task_id = self.request.root_id session.add(account)
session.commit()
# get account creation status
account_creation_data = cache.get(task_id)
# check status
if account_creation_data:
account_creation_data = json.loads(account_creation_data)
if status_code == 0:
# update redis data
account_creation_data['status'] = 'CREATED'
cache.set(name=task_id, value=json.dumps(account_creation_data))
cache.persist(task_id)
phone_number = account_creation_data.get('phone_number')
# create user
user = Account(blockchain_address=result, phone_number=phone_number)
session.add(user)
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]
)
s_phone_pointer.apply_async(queue=queue)
# add custom metadata tags
custom_metadata = {
"tags": ["ussd", "individual"]
}
s_custom_metadata = celery.signature(
'cic_ussd.tasks.metadata.add_custom_metadata',
[result, custom_metadata]
)
s_custom_metadata.apply_async(queue=queue)
# expire cache
cache.expire(task_id, timedelta(seconds=180))
else:
session.close()
cache.expire(task_id, timedelta(seconds=180))
else:
session.close()
raise ActionDataNotFoundError(f'Account creation task: {task_id}, returned unexpected response: {status_code}')
session.close() session.close()
queue = self.request.delivery_info.get('routing_key')
s_phone_pointer = celery.signature(
'cic_ussd.tasks.metadata.add_phone_pointer', [result, phone_number], queue=queue
)
s_phone_pointer.apply_async()
custom_metadata = {"tags": ["ussd", "individual"]}
s_custom_metadata = celery.signature(
'cic_ussd.tasks.metadata.add_custom_metadata', [result, custom_metadata], queue=queue
)
s_custom_metadata.apply_async()
Cache.store.expire(task_uuid, 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}.')
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) @celery_app.task(bind=True)
def process_transaction_callback(self, result: dict, param: str, status_code: int): def statement_callback(self, result, param: str, status_code: int):
if status_code == 0: """
chain_str = Chain.spec.__str__() :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')
destination_token_symbol = result.get('destination_token_symbol') statement_transactions = filter_statement_transactions(result)
destination_token_value = result.get('destination_token_value') for transaction in statement_transactions:
recipient_blockchain_address = result.get('recipient') recipient_transaction, sender_transaction = transaction_actors(transaction)
sender_blockchain_address = result.get('sender') if recipient_transaction.get('blockchain_address') == param:
source_token_symbol = result.get('source_token_symbol') generate(param, queue, recipient_transaction)
source_token_value = result.get('source_token_value') if sender_transaction.get('blockchain_address') == param:
generate(param, queue, sender_transaction)
# build stakeholder callback params
recipient_metadata = { @celery_app.task(bind=True)
"token_symbol": destination_token_symbol, def transaction_balances_callback(self, result: list, param: dict, status_code: int):
"token_value": destination_token_value, """
"blockchain_address": recipient_blockchain_address, :param self:
"tag": "recipient", :type self:
"tx_param": param :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')
sender_blockchain_address = result.get('sender')
source_token_symbol = result.get('source_token_symbol')
source_token_value = result.get('source_token_value')
recipient_metadata = {
"token_symbol": destination_token_symbol,
"token_value": destination_token_value,
"blockchain_address": recipient_blockchain_address,
"role": "recipient",
"transaction_type": param
}
get_balances(
address=recipient_blockchain_address,
callback_param=recipient_metadata,
chain_str=chain_str,
callback_task='cic_ussd.tasks.callback_handler.transaction_balances_callback',
token_symbol=destination_token_symbol,
asynchronous=True)
if param == 'transfer':
sender_metadata = {
"blockchain_address": sender_blockchain_address,
"token_symbol": source_token_symbol,
"token_value": source_token_value,
"role": "sender",
"transaction_type": param
} }
# retrieve account balances
get_balances( get_balances(
address=recipient_blockchain_address, address=sender_blockchain_address,
callback_param=recipient_metadata, callback_param=sender_metadata,
chain_str=chain_str, 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, token_symbol=source_token_symbol,
asynchronous=True) 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
}
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',
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}.')

View File

@ -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))

View File

@ -6,11 +6,7 @@ import celery
from hexathon import strip_0x from hexathon import strip_0x
# local imports # local imports
from cic_ussd.metadata import blockchain_address_to_metadata_pointer from cic_ussd.metadata import CustomMetadata, PersonMetadata, PhonePointerMetadata, PreferencesMetadata
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.tasks.base import CriticalMetadataTask from cic_ussd.tasks.base import CriticalMetadataTask
celery_app = celery.current_app celery_app = celery.current_app
@ -25,8 +21,7 @@ def query_person_metadata(blockchain_address: str):
:return: :return:
:rtype: :rtype:
""" """
identifier = blockchain_address_to_metadata_pointer(blockchain_address=blockchain_address) identifier = bytes.fromhex(strip_0x(blockchain_address))
logg.debug(f'Retrieving person metadata for address: {blockchain_address}.')
person_metadata_client = PersonMetadata(identifier=identifier) person_metadata_client = PersonMetadata(identifier=identifier)
person_metadata_client.query() person_metadata_client.query()
@ -41,14 +36,14 @@ def create_person_metadata(blockchain_address: str, data: dict):
:return: :return:
:rtype: :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 = PersonMetadata(identifier=identifier)
person_metadata_client.create(data=data) person_metadata_client.create(data=data)
@celery_app.task @celery_app.task
def edit_person_metadata(blockchain_address: str, data: dict): 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 = PersonMetadata(identifier=identifier)
person_metadata_client.edit(data=data) person_metadata_client.edit(data=data)
@ -63,16 +58,16 @@ def add_phone_pointer(self, blockchain_address: str, phone_number: str):
@celery_app.task() @celery_app.task()
def add_custom_metadata(blockchain_address: str, data: dict): 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 = CustomMetadata(identifier=identifier)
custom_metadata_client.create(data=data) custom_metadata_client.create(data=data)
@celery_app.task() @celery_app.task()
def add_preferences_metadata(blockchain_address: str, data: dict): def add_preferences_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 = PreferencesMetadata(identifier=identifier) preferences_metadata_client = PreferencesMetadata(identifier=identifier)
custom_metadata_client.create(data=data) preferences_metadata_client.create(data=data)
@celery_app.task() @celery_app.task()
@ -81,7 +76,7 @@ def query_preferences_metadata(blockchain_address: str):
:param blockchain_address: Blockchain address of an account. :param blockchain_address: Blockchain address of an account.
:type blockchain_address: str | Ox-hex :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}.') logg.debug(f'Retrieving preferences metadata for address: {blockchain_address}.')
person_metadata_client = PreferencesMetadata(identifier=identifier) person_metadata_client = PreferencesMetadata(identifier=identifier)
return person_metadata_client.query() return person_metadata_client.query()

View File

@ -6,6 +6,7 @@ import logging
import celery import celery
# local imports # local imports
from cic_ussd.account.transaction import from_wei
from cic_ussd.notifications import Notifier from cic_ussd.notifications import Notifier
from cic_ussd.phone_number import Support from cic_ussd.phone_number import Support
@ -15,56 +16,46 @@ notifier = Notifier()
@celery_app.task @celery_app.task
def notify_account_of_transaction(notification_data: dict): def transaction(notification_data: dict):
""" """
:param notification_data: :param notification_data:
:type notification_data: :type notification_data:
:return: :return:
:rtype: :rtype:
""" """
role = notification_data.get('role')
account_tx_role = notification_data.get('account_tx_role') amount = from_wei(notification_data.get('token_value'))
amount = notification_data.get('amount') balance = notification_data.get('available_balance')
balance = notification_data.get('balance')
phone_number = notification_data.get('phone_number') phone_number = notification_data.get('phone_number')
preferred_language = notification_data.get('preferred_language') preferred_language = notification_data.get('preferred_language')
token_symbol = notification_data.get('token_symbol') 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') transaction_type = notification_data.get('transaction_type')
timestamp = datetime.datetime.now().strftime('%d-%m-%y, %H:%M %p') timestamp = datetime.datetime.now().strftime('%d-%m-%y, %H:%M %p')
if transaction_type == 'tokengift': if transaction_type == 'tokengift':
support_phone = Support.phone_number
notifier.send_sms_notification( notifier.send_sms_notification(
key='sms.account_successfully_created', key='sms.account_successfully_created',
phone_number=phone_number, phone_number=phone_number,
preferred_language=preferred_language, preferred_language=preferred_language,
balance=balance, support_phone=Support.phone_number)
support_phone=support_phone,
token_symbol=token_symbol
)
if transaction_type == 'transfer': if transaction_type == 'transfer':
if account_tx_role == 'recipient': if role == 'recipient':
notifier.send_sms_notification( notifier.send_sms_notification('sms.received_tokens',
key='sms.received_tokens', phone_number=phone_number,
phone_number=phone_number, preferred_language=preferred_language,
preferred_language=preferred_language, amount=amount,
amount=amount, token_symbol=token_symbol,
token_symbol=token_symbol, tx_sender_information=transaction_account_metadata,
tx_sender_information=transaction_account_metadata, timestamp=timestamp,
timestamp=timestamp, balance=balance)
balance=balance if role == 'sender':
) notifier.send_sms_notification('sms.sent_tokens',
else: phone_number=phone_number,
notifier.send_sms_notification( preferred_language=preferred_language,
key='sms.sent_tokens', amount=amount,
phone_number=phone_number, token_symbol=token_symbol,
preferred_language=preferred_language, tx_recipient_information=transaction_account_metadata,
amount=amount, timestamp=timestamp,
token_symbol=token_symbol, balance=balance)
tx_recipient_information=transaction_account_metadata,
timestamp=timestamp,
balance=balance
)

View File

@ -1,88 +1,84 @@
# standard imports # standard imports
import json
import logging import logging
# third-party imports # third-party imports
import celery import celery
from i18n import config import i18n
from chainlib.hash import strip_0x
# local imports # local imports
from cic_ussd.account import define_account_tx_metadata from cic_ussd.account.statement import get_cached_statement
from cic_ussd.db.models.account import Account 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.db.models.base import SessionBase
from cic_ussd.error import UnknownUssdRecipient
from cic_ussd.transactions import from_wei
celery_app = celery.current_app celery_app = celery.current_app
logg = logging.getLogger(__file__) 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 @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: :param parsed_transaction:
:type result: :type parsed_transaction:
:param transaction_metadata: :param querying_party:
:type transaction_metadata: :type querying_party:
:return: :return:
:rtype: :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: if not preferred_language:
preferred_language = config.get('fallback') preferred_language = i18n.config.get('fallback')
notification_data['preferred_language'] = preferred_language
# validate account information against present ussd storage data. transaction = aux_transaction_data(preferred_language, transaction)
session = SessionBase.create_session() session = SessionBase.create_session()
blockchain_address = transaction_metadata.get('blockchain_address') account = validate_transaction_account(session, transaction)
tag = transaction_metadata.get('tag') metadata_id = account.standard_metadata_id()
account = session.query(Account).filter_by(blockchain_address=blockchain_address).first() transaction['metadata_id'] = metadata_id
if not account and tag == 'recipient': transaction['phone_number'] = account.phone_number
session.close() session.commit()
raise UnknownUssdRecipient( session.close()
f'Tx for recipient: {blockchain_address} was received but has no matching user in the system.' return transaction
)
# 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

View File

@ -7,10 +7,10 @@ import celery
from celery.utils.log import get_logger from celery.utils.log import get_logger
# local imports # local imports
from cic_ussd.cache import Cache, get_cached_data
from cic_ussd.db.models.base import SessionBase from cic_ussd.db.models.base import SessionBase
from cic_ussd.db.models.ussd_session import UssdSession from cic_ussd.db.models.ussd_session import UssdSession
from cic_ussd.error import SessionNotFoundError from cic_ussd.error import SessionNotFoundError
from cic_ussd.session.ussd_session import UssdSession as InMemoryUssdSession
from cic_ussd.tasks.base import CriticalSQLAlchemyTask from cic_ussd.tasks.base import CriticalSQLAlchemyTask
celery_app = celery.current_app 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 SessionNotFoundError: If the session object is not found in memory.
:raises VersionTooLowError: If the session's version doesn't match the latest version. :raises VersionTooLowError: If the session's version doesn't match the latest version.
""" """
# create session
session = SessionBase.create_session() session = SessionBase.create_session()
cached_ussd_session = get_cached_data(external_session_id)
# get ussd session in redis cache if cached_ussd_session:
in_memory_session = InMemoryUssdSession.redis_cache.get(external_session_id) cached_ussd_session = json.loads(cached_ussd_session)
ussd_session = session.query(UssdSession).filter_by(external_session_id=external_session_id).first()
# process persistence to db if ussd_session:
if in_memory_session: ussd_session.update(
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(
session=session, session=session,
user_input=in_memory_session.get('user_input'), user_input=cached_ussd_session.get('user_input'),
state=in_memory_session.get('state'), state=cached_ussd_session.get('state'),
version=in_memory_session.get('version'), version=cached_ussd_session.get('version'),
) )
else: else:
in_db_ussd_session = UssdSession( ussd_session = UssdSession(
external_session_id=external_session_id, external_session_id=external_session_id,
service_code=in_memory_session.get('service_code'), service_code=cached_ussd_session.get('service_code'),
msisdn=in_memory_session.get('msisdn'), msisdn=cached_ussd_session.get('msisdn'),
user_input=in_memory_session.get('user_input'), user_input=cached_ussd_session.get('user_input'),
state=in_memory_session.get('state'), state=cached_ussd_session.get('state'),
version=in_memory_session.get('version'), version=cached_ussd_session.get('version'),
) )
data = cached_ussd_session.get('data')
# handle the updating of session data for persistence to db if data:
session_data = in_memory_session.get('session_data') for key, value in data.items():
ussd_session.set_data(key=key, value=value, session=session)
if session_data: session.add(ussd_session)
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)
session.commit() session.commit()
session.close() session.close()
InMemoryUssdSession.redis_cache.expire(external_session_id, timedelta(minutes=1)) Cache.store.expire(external_session_id, timedelta(minutes=1))
else: else:
session.close() session.close()
raise SessionNotFoundError('Session does not exist!') raise SessionNotFoundError('Session does not exist!')
session.close() session.close()

View File

@ -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)

View File

@ -1,15 +1,13 @@
# standard imports # standard imports
import ipaddress
import logging import logging
import os import os
import re import re
import ipaddress
# third-party imports # third-party imports
from confini import Config from confini import Config
# local imports # local imports
from cic_ussd.db.models.account import Account
from cic_ussd.db.models.base import SessionBase
logg = logging.getLogger(__file__) logg = logging.getLogger(__file__)
@ -46,23 +44,6 @@ def check_request_content_length(config: Config, env: dict):
config.get('APP_MAX_BODY_LENGTH')) 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): def check_request_method(env: dict):
""" """
Checks whether request method is POST Checks whether request method is POST
@ -74,17 +55,6 @@ def check_request_method(env: dict):
return env.get('REQUEST_METHOD').upper() == 'POST' 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): def validate_phone_number(phone: str):
""" """
Check if phone number is in the correct format. 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. :return: Whether the phone number is of the correct format.
:rtype: bool :rtype: bool
""" """
if phone and re.match('[+]?[0-9]{10,12}$', phone): return bool(phone and re.match('[+]?[0-9]{10,12}$', phone))
return True
return False
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 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 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: if len(processor_response) > 164:
logg.warning(f'Warning, text has length {len(processor_response)}, display may be truncated') logg.warning(f'Warning, text has length {len(processor_response)}, display may be truncated')
if re.match(matcher, processor_response): return bool(re.match(matcher, processor_response))
return True
return False
def validate_presence(path: str): def validate_presence(path: str):

View 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 =

View File

@ -0,0 +1,3 @@
[celery]
broker_url=redis://
result_url=redis://

View 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

View File

@ -0,0 +1,5 @@
[e164]
region=KE
[office]
support_phone=0757628885

View File

@ -0,0 +1,5 @@
[redis]
host=redis
database=0
password=
port=6379

View 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/

View File

@ -0,0 +1,3 @@
[celery]
broker_url = filesystem://
result_url = filesystem://

View File

@ -2,4 +2,4 @@
engine = evm engine = evm
common_name = bloxberg common_name = bloxberg
network_id = 8996 network_id = 8996
meta_url = http://localhost:63380 meta_url = http://test-meta.io

View File

@ -0,0 +1,9 @@
[database]
name=cic_ussd_test
user=postgres
password=
host=localhost
port=5432
engine=sqlite
driver=pysqlite
debug=

View File

@ -0,0 +1,5 @@
[pgp]
export_dir =
keys_path =tests/data/pgp
private_keys = privatekeys_meta.asc
passphrase = merman

View File

@ -0,0 +1,5 @@
[e164]
region=KE
[office]
support_phone=0757628885

View File

@ -0,0 +1,5 @@
[redis]
host=localhost
database=0
password=
port=6379

View File

@ -0,0 +1,3 @@
[locale]
fallback=sw
path=var/lib/locale/

View File

@ -0,0 +1,5 @@
[ussd]
menu_file=cic_ussd/db/ussd_menu.json
service_code=*483*46#,*483*061#,*384*96#
user =
pass =

View File

@ -0,0 +1,3 @@
[locale]
fallback=sw
path=var/lib/locale/

View File

@ -0,0 +1,5 @@
[ussd]
menu_file=data/ussd_menu.json
service_code=*483*46#,*483*061#,*384*96#
user =
pass =

Some files were not shown because too many files have changed in this diff Show More