Minor refactors:
- Renames s_assemble to s_brief - Link s_local to s_brief
This commit is contained in:
49
apps/cic-ussd/cic_ussd/account.py
Normal file
49
apps/cic-ussd/cic_ussd/account.py
Normal file
@@ -0,0 +1,49 @@
|
||||
# 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.user import User
|
||||
from cic_ussd.metadata import blockchain_address_to_metadata_pointer
|
||||
from cic_ussd.redis import get_cached_data
|
||||
|
||||
|
||||
def define_account_tx_metadata(user: User):
|
||||
# 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(metadata=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
|
||||
)
|
||||
result = cic_eth_api.list(address=blockchain_address, limit=9)
|
||||
@@ -1,39 +0,0 @@
|
||||
# standard imports
|
||||
import logging
|
||||
from collections import deque
|
||||
|
||||
# third-party imports
|
||||
from cic_eth.api import Api
|
||||
|
||||
# local imports
|
||||
from cic_ussd.transactions import from_wei
|
||||
|
||||
logg = logging.getLogger()
|
||||
|
||||
|
||||
class BalanceManager:
|
||||
|
||||
def __init__(self, address: str, chain_str: str, token_symbol: str):
|
||||
"""
|
||||
:param address: Ethereum address of account whose balance is being queried
|
||||
:type address: str, 0x-hex
|
||||
:param chain_str: The chain name and network id.
|
||||
:type chain_str: str
|
||||
:param token_symbol: ERC20 token symbol of whose balance is being queried
|
||||
:type token_symbol: str
|
||||
"""
|
||||
self.address = address
|
||||
self.chain_str = chain_str
|
||||
self.token_symbol = token_symbol
|
||||
|
||||
def get_operational_balance(self) -> float:
|
||||
"""This question queries cic-eth for an account's balance
|
||||
:return: The current balance of the account as reflected on the blockchain.
|
||||
:rtype: int
|
||||
"""
|
||||
cic_eth_api = Api(chain_str=self.chain_str, callback_task=None)
|
||||
balance_request_task = cic_eth_api.balance(address=self.address, token_symbol=self.token_symbol)
|
||||
balance_request_task_results = balance_request_task.collect()
|
||||
balance_result = deque(balance_request_task_results, maxlen=1).pop()
|
||||
balance = from_wei(value=balance_result[-1])
|
||||
return balance
|
||||
90
apps/cic-ussd/cic_ussd/balance.py
Normal file
90
apps/cic-ussd/cic_ussd/balance.py
Normal file
@@ -0,0 +1,90 @@
|
||||
# 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()
|
||||
|
||||
|
||||
class BalanceManager:
|
||||
|
||||
def __init__(self, address: str, chain_str: str, token_symbol: str):
|
||||
"""
|
||||
:param address: Ethereum address of account whose balance is being queried
|
||||
:type address: str, 0x-hex
|
||||
:param chain_str: The chain name and network id.
|
||||
:type chain_str: str
|
||||
:param token_symbol: ERC20 token symbol of whose balance is being queried
|
||||
:type token_symbol: str
|
||||
"""
|
||||
self.address = address
|
||||
self.chain_str = chain_str
|
||||
self.token_symbol = token_symbol
|
||||
|
||||
def get_balances(self, asynchronous: bool = False) -> 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 asynchronous: Boolean value checking whether to return balances asynchronously
|
||||
:type asynchronous: bool
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
if asynchronous:
|
||||
cic_eth_api = Api(
|
||||
chain_str=self.chain_str,
|
||||
callback_queue='cic-ussd',
|
||||
callback_task='cic_ussd.tasks.callback_handler.process_balances_callback',
|
||||
callback_param=''
|
||||
)
|
||||
cic_eth_api.balance(address=self.address, token_symbol=self.token_symbol)
|
||||
else:
|
||||
cic_eth_api = Api(chain_str=self.chain_str)
|
||||
balance_request_task = cic_eth_api.balance(
|
||||
address=self.address,
|
||||
token_symbol=self.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.')
|
||||
10
apps/cic-ussd/cic_ussd/chain.py
Normal file
10
apps/cic-ussd/cic_ussd/chain.py
Normal file
@@ -0,0 +1,10 @@
|
||||
# local imports
|
||||
|
||||
# third-party imports
|
||||
from chainlib.chain import ChainSpec
|
||||
|
||||
# local imports
|
||||
|
||||
|
||||
class Chain:
|
||||
spec: ChainSpec = None
|
||||
41
apps/cic-ussd/cic_ussd/conversions.py
Normal file
41
apps/cic-ussd/cic_ussd/conversions.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# 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: 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)
|
||||
@@ -1,213 +1,237 @@
|
||||
{
|
||||
"ussd_menu": {
|
||||
"1": {
|
||||
"description": "The self signup process has been initiated and the account is being created",
|
||||
"display_key": "ussd.kenya.account_creation_prompt",
|
||||
"name": "account_creation_prompt",
|
||||
"parent": null
|
||||
},
|
||||
"2": {
|
||||
"description": "Start menu. This is the entry point for users to select their preferred language",
|
||||
"description": "Entry point for users to select their preferred language.",
|
||||
"display_key": "ussd.kenya.initial_language_selection",
|
||||
"name": "initial_language_selection",
|
||||
"parent": null
|
||||
},
|
||||
"3": {
|
||||
"description": "PIN setup entry menu",
|
||||
"2": {
|
||||
"description": "Entry point for users to enter a pin to secure their account.",
|
||||
"display_key": "ussd.kenya.initial_pin_entry",
|
||||
"name": "initial_pin_entry",
|
||||
"parent": "initial_language_selection"
|
||||
"parent": null
|
||||
},
|
||||
"4": {
|
||||
"description": "Confirm new PIN menu",
|
||||
"3": {
|
||||
"description": "Pin confirmation entry menu.",
|
||||
"display_key": "ussd.kenya.initial_pin_confirmation",
|
||||
"name": "initial_pin_confirmation",
|
||||
"parent": "initial_pin_entry"
|
||||
},
|
||||
"4": {
|
||||
"description": "The signup process has been initiated and the account is being created.",
|
||||
"display_key": "ussd.kenya.account_creation_prompt",
|
||||
"name": "account_creation_prompt",
|
||||
"parent": null
|
||||
},
|
||||
"5": {
|
||||
"description": "Start menu. This is the entry point for activated users",
|
||||
"description": "Entry point for activated users.",
|
||||
"display_key": "ussd.kenya.start",
|
||||
"name": "start",
|
||||
"parent": null
|
||||
},
|
||||
"6": {
|
||||
"description": "Send Token recipient entry",
|
||||
"description": "Given name entry menu.",
|
||||
"display_key": "ussd.kenya.enter_given_name",
|
||||
"name": "enter_given_name",
|
||||
"parent": "metadata_management"
|
||||
},
|
||||
"7": {
|
||||
"description": "Family name entry menu.",
|
||||
"display_key": "ussd.kenya.enter_family_name",
|
||||
"name": "enter_family_name",
|
||||
"parent": "metadata_management"
|
||||
},
|
||||
"8": {
|
||||
"description": "Gender entry menu.",
|
||||
"display_key": "ussd.kenya.enter_gender",
|
||||
"name": "enter_gender",
|
||||
"parent": "metadata_management"
|
||||
},
|
||||
"9": {
|
||||
"description": "Age entry menu.",
|
||||
"display_key": "ussd.kenya.enter_gender",
|
||||
"name": "enter_gender",
|
||||
"parent": "metadata_management"
|
||||
},
|
||||
"10": {
|
||||
"description": "Location entry menu.",
|
||||
"display_key": "ussd.kenya.enter_location",
|
||||
"name": "enter_location",
|
||||
"parent": "metadata_management"
|
||||
},
|
||||
"11": {
|
||||
"description": "Products entry menu.",
|
||||
"display_key": "ussd.kenya.enter_products",
|
||||
"name": "enter_products",
|
||||
"parent": "metadata_management"
|
||||
},
|
||||
"12": {
|
||||
"description": "Entry point for activated users.",
|
||||
"display_key": "ussd.kenya.start",
|
||||
"name": "start",
|
||||
"parent": null
|
||||
},
|
||||
"13": {
|
||||
"description": "Send Token recipient entry.",
|
||||
"display_key": "ussd.kenya.enter_transaction_recipient",
|
||||
"name": "enter_transaction_recipient",
|
||||
"parent": "start"
|
||||
},
|
||||
"7": {
|
||||
"description": "Send Token amount prompt menu",
|
||||
"14": {
|
||||
"description": "Send Token amount prompt menu.",
|
||||
"display_key": "ussd.kenya.enter_transaction_amount",
|
||||
"name": "enter_transaction_amount",
|
||||
"parent": "start"
|
||||
},
|
||||
"8": {
|
||||
"description": "PIN entry for authorization to send token",
|
||||
"15": {
|
||||
"description": "Pin entry for authorization to send token.",
|
||||
"display_key": "ussd.kenya.transaction_pin_authorization",
|
||||
"name": "transaction_pin_authorization",
|
||||
"parent": "start"
|
||||
},
|
||||
"9": {
|
||||
"description": "Terminal of a menu flow where an SMS is expected after.",
|
||||
"display_key": "ussd.kenya.complete",
|
||||
"name": "complete",
|
||||
"parent": null
|
||||
},
|
||||
"10": {
|
||||
"description": "Help menu",
|
||||
"display_key": "ussd.kenya.help",
|
||||
"name": "help",
|
||||
"16": {
|
||||
"description": "Manage account menu.",
|
||||
"display_key": "ussd.kenya.account_management",
|
||||
"name": "account_management",
|
||||
"parent": "start"
|
||||
},
|
||||
"11": {
|
||||
"description": "Manage account menu",
|
||||
"display_key": "ussd.kenya.profile_management",
|
||||
"name": "profile_management",
|
||||
"17": {
|
||||
"description": "Manage metadata menu.",
|
||||
"display_key": "ussd.kenya.metadata_management",
|
||||
"name": "metadata_management",
|
||||
"parent": "start"
|
||||
},
|
||||
"12": {
|
||||
"description": "Manage business directory info",
|
||||
"18": {
|
||||
"description": "Manage user's preferred language menu.",
|
||||
"display_key": "ussd.kenya.select_preferred_language",
|
||||
"name": "select_preferred_language",
|
||||
"parent": "account_management"
|
||||
},
|
||||
"13": {
|
||||
"description": "About business directory info",
|
||||
"19": {
|
||||
"description": "Retrieve mini-statement menu.",
|
||||
"display_key": "ussd.kenya.mini_statement_pin_authorization",
|
||||
"name": "mini_statement_pin_authorization",
|
||||
"parent": "account_management"
|
||||
},
|
||||
"14": {
|
||||
"description": "Change business directory info",
|
||||
"20": {
|
||||
"description": "Manage user's pin menu.",
|
||||
"display_key": "ussd.kenya.enter_current_pin",
|
||||
"name": "enter_current_pin",
|
||||
"parent": "account_management"
|
||||
},
|
||||
"15": {
|
||||
"description": "New PIN entry menu",
|
||||
"21": {
|
||||
"description": "New pin entry menu.",
|
||||
"display_key": "ussd.kenya.enter_new_pin",
|
||||
"name": "enter_new_pin",
|
||||
"parent": "account_management"
|
||||
},
|
||||
"16": {
|
||||
"description": "First name entry menu",
|
||||
"display_key": "ussd.kenya.enter_first_name",
|
||||
"name": "enter_first_name",
|
||||
"parent": "profile_management"
|
||||
},
|
||||
"17": {
|
||||
"description": "Last name entry menu",
|
||||
"display_key": "ussd.kenya.enter_last_name",
|
||||
"name": "enter_last_name",
|
||||
"parent": "profile_management"
|
||||
},
|
||||
"18": {
|
||||
"description": "Gender entry menu",
|
||||
"display_key": "ussd.kenya.enter_gender",
|
||||
"name": "enter_gender",
|
||||
"parent": "profile_management"
|
||||
},
|
||||
"19": {
|
||||
"description": "Location entry menu",
|
||||
"display_key": "ussd.kenya.enter_location",
|
||||
"name": "enter_location",
|
||||
"parent": "profile_management"
|
||||
},
|
||||
"20": {
|
||||
"description": "Business profile entry menu",
|
||||
"display_key": "ussd.kenya.enter_business_profile",
|
||||
"name": "enter_business_profile",
|
||||
"parent": "profile_management"
|
||||
},
|
||||
"21": {
|
||||
"description": "Menu to display a user's entire profile",
|
||||
"display_key": "ussd.kenya.display_user_profile_data",
|
||||
"name": "display_user_profile_data",
|
||||
"parent": "profile_management"
|
||||
},
|
||||
"22": {
|
||||
"description": "Pin authorization to change name",
|
||||
"display_key": "ussd.kenya.name_management_pin_authorization",
|
||||
"name": "name_management_pin_authorization",
|
||||
"parent": "profile_management"
|
||||
"description": "Pin entry menu.",
|
||||
"display_key": "ussd.kenya.standard_pin_authorization",
|
||||
"name": "standard_pin_authorization",
|
||||
"parent": "start"
|
||||
},
|
||||
"23": {
|
||||
"description": "Pin authorization to change gender",
|
||||
"display_key": "ussd.kenya.gender_management_pin_authorization",
|
||||
"name": "gender_management_pin_authorization",
|
||||
"parent": "profile_management"
|
||||
},
|
||||
"24": {
|
||||
"description": "Pin authorization to change location",
|
||||
"display_key": "ussd.kenya.location_management_pin_authorization",
|
||||
"name": "location_management_pin_authorization",
|
||||
"parent": "profile_management"
|
||||
},
|
||||
"26": {
|
||||
"description": "Pin authorization to display user's profile",
|
||||
"display_key": "ussd.kenya.view_profile_pin_authorization",
|
||||
"name": "view_profile_pin_authorization",
|
||||
"parent": "profile_management"
|
||||
},
|
||||
"27": {
|
||||
"description": "Exit menu",
|
||||
"description": "Exit menu.",
|
||||
"display_key": "ussd.kenya.exit",
|
||||
"name": "exit",
|
||||
"parent": null
|
||||
},
|
||||
"28": {
|
||||
"description": "Invalid menu option",
|
||||
"24": {
|
||||
"description": "Invalid menu option.",
|
||||
"display_key": "ussd.kenya.exit_invalid_menu_option",
|
||||
"name": "exit_invalid_menu_option",
|
||||
"parent": null
|
||||
},
|
||||
"29": {
|
||||
"description": "PIN policy violation",
|
||||
"25": {
|
||||
"description": "Pin policy violation.",
|
||||
"display_key": "ussd.kenya.exit_invalid_pin",
|
||||
"name": "exit_invalid_pin",
|
||||
"parent": null
|
||||
},
|
||||
"30": {
|
||||
"description": "PIN mismatch. New PIN and the new PIN confirmation do not match",
|
||||
"26": {
|
||||
"description": "Pin mismatch. New pin and the new pin confirmation do not match",
|
||||
"display_key": "ussd.kenya.exit_pin_mismatch",
|
||||
"name": "exit_pin_mismatch",
|
||||
"parent": null
|
||||
},
|
||||
"31": {
|
||||
"description": "Ussd PIN Blocked Menu",
|
||||
"27": {
|
||||
"description": "Ussd pin blocked Menu",
|
||||
"display_key": "ussd.kenya.exit_pin_blocked",
|
||||
"name": "exit_pin_blocked",
|
||||
"parent": null
|
||||
},
|
||||
"32": {
|
||||
"description": "Key params missing in request",
|
||||
"28": {
|
||||
"description": "Key params missing in request.",
|
||||
"display_key": "ussd.kenya.exit_invalid_request",
|
||||
"name": "exit_invalid_request",
|
||||
"parent": null
|
||||
},
|
||||
"33": {
|
||||
"description": "The user did not select a choice",
|
||||
"29": {
|
||||
"description": "The user did not select a choice.",
|
||||
"display_key": "ussd.kenya.exit_invalid_input",
|
||||
"name": "exit_invalid_input",
|
||||
"parent": null
|
||||
},
|
||||
"34": {
|
||||
"30": {
|
||||
"description": "Exit following unsuccessful transaction due to insufficient account balance.",
|
||||
"display_key": "ussd.kenya.exit_insufficient_balance",
|
||||
"name": "exit_insufficient_balance",
|
||||
"parent": null
|
||||
},
|
||||
"31": {
|
||||
"description": "Exit following a successful transaction.",
|
||||
"display_key": "ussd.kenya.exit_successful_transaction",
|
||||
"name": "exit_successful_transaction",
|
||||
"parent": null
|
||||
},
|
||||
"32": {
|
||||
"description": "End of a menu flow.",
|
||||
"display_key": "ussd.kenya.complete",
|
||||
"name": "complete",
|
||||
"parent": null
|
||||
},
|
||||
"33": {
|
||||
"description": "Pin entry menu to view account balances.",
|
||||
"display_key": "ussd.kenya.account_balances_pin_authorization",
|
||||
"name": "account_balances_pin_authorization",
|
||||
"parent": "account_management"
|
||||
},
|
||||
"34": {
|
||||
"description": "Pin entry menu to view account statement.",
|
||||
"display_key": "ussd.kenya.account_statement_pin_authorization",
|
||||
"name": "account_statement_pin_authorization",
|
||||
"parent": "account_management"
|
||||
},
|
||||
"35": {
|
||||
"description": "Manage account menu",
|
||||
"display_key": "ussd.kenya.account_management",
|
||||
"name": "account_management",
|
||||
"parent": "start"
|
||||
"description": "Menu to display account balances.",
|
||||
"display_key": "ussd.kenya.account_balances",
|
||||
"name": "account_balances",
|
||||
"parent": "account_management"
|
||||
},
|
||||
"36": {
|
||||
"description": "Exit following insufficient balance to perform a transaction.",
|
||||
"display_key": "ussd.kenya.exit_insufficient_balance",
|
||||
"name": "exit_insufficient_balance",
|
||||
"description": "Menu to display first set of transactions in statement.",
|
||||
"display_key": "ussd.kenya.first_transaction_set",
|
||||
"name": "first_transaction_set",
|
||||
"parent": null
|
||||
},
|
||||
"37": {
|
||||
"description": "Menu to display middle set of transactions in statement.",
|
||||
"display_key": "ussd.kenya.middle_transaction_set",
|
||||
"name": "middle_transaction_set",
|
||||
"parent": null
|
||||
},
|
||||
"38": {
|
||||
"description": "Menu to display last set of transactions in statement.",
|
||||
"display_key": "ussd.kenya.last_transaction_set",
|
||||
"name": "last_transaction_set",
|
||||
"parent": null
|
||||
},
|
||||
"39": {
|
||||
"description": "Menu to instruct users to call the office.",
|
||||
"display_key": "ussd.key.help",
|
||||
"name": "help",
|
||||
"parent": null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,3 +17,17 @@ class ActionDataNotFoundError(OSError):
|
||||
"""Raised when action data matching a specific task uuid is not found in the redis cache"""
|
||||
pass
|
||||
|
||||
|
||||
class UserMetadataNotFoundError(OSError):
|
||||
"""Raised when metadata is expected but not available in cache."""
|
||||
pass
|
||||
|
||||
|
||||
class UnsupportedMethodError(OSError):
|
||||
"""Raised when the method passed to the make request function is unsupported."""
|
||||
pass
|
||||
|
||||
|
||||
class CachedDataNotFoundError(OSError):
|
||||
"""Raised when the method passed to the make request function is unsupported."""
|
||||
pass
|
||||
|
||||
43
apps/cic-ussd/cic_ussd/metadata/__init__.py
Normal file
43
apps/cic-ussd/cic_ussd/metadata/__init__.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# standard imports
|
||||
|
||||
# third-party imports
|
||||
import requests
|
||||
from chainlib.eth.address import to_checksum
|
||||
from hexathon import add_0x
|
||||
|
||||
# local imports
|
||||
from cic_ussd.error import UnsupportedMethodError
|
||||
|
||||
|
||||
def make_request(method: str, url: str, data: any = None, headers: dict = None):
|
||||
"""
|
||||
:param method:
|
||||
:type method:
|
||||
:param url:
|
||||
:type url:
|
||||
:param data:
|
||||
:type data:
|
||||
:param headers:
|
||||
:type headers:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
if method == 'GET':
|
||||
result = requests.get(url=url)
|
||||
elif method == 'POST':
|
||||
result = requests.post(url=url, data=data, headers=headers)
|
||||
elif method == 'PUT':
|
||||
result = requests.put(url=url, data=data, headers=headers)
|
||||
else:
|
||||
raise UnsupportedMethodError(f'Unsupported method: {method}')
|
||||
return result
|
||||
|
||||
|
||||
def blockchain_address_to_metadata_pointer(blockchain_address: str):
|
||||
"""
|
||||
:param blockchain_address:
|
||||
:type blockchain_address:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
return bytes.fromhex(blockchain_address[2:])
|
||||
63
apps/cic-ussd/cic_ussd/metadata/signer.py
Normal file
63
apps/cic-ussd/cic_ussd/metadata/signer.py
Normal file
@@ -0,0 +1,63 @@
|
||||
# standard imports
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
# third-party imports
|
||||
import gnupg
|
||||
|
||||
# local imports
|
||||
|
||||
logg = logging.getLogger()
|
||||
|
||||
|
||||
class Signer:
|
||||
"""
|
||||
:cvar gpg_path:
|
||||
:type gpg_path:
|
||||
:cvar gpg_passphrase:
|
||||
:type gpg_passphrase:
|
||||
:cvar key_file_path:
|
||||
:type key_file_path:
|
||||
|
||||
"""
|
||||
gpg_path: str = None
|
||||
gpg_passphrase: str = None
|
||||
key_file_path: str = None
|
||||
|
||||
def __init__(self):
|
||||
self.gpg = gnupg.GPG(gnupghome=self.gpg_path)
|
||||
|
||||
# parse key file data
|
||||
key_file = open(self.key_file_path, 'r')
|
||||
self.key_data = key_file.read()
|
||||
key_file.close()
|
||||
|
||||
def get_operational_key(self):
|
||||
"""
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
# import key data into keyring
|
||||
self.gpg.import_keys(key_data=self.key_data)
|
||||
gpg_keys = self.gpg.list_keys()
|
||||
key_algorithm = gpg_keys[0].get('algo')
|
||||
key_id = gpg_keys[0].get("keyid")
|
||||
logg.info(f'using signing key: {key_id}, algorithm: {key_algorithm}')
|
||||
return gpg_keys[0]
|
||||
|
||||
def sign_digest(self, data: bytes):
|
||||
"""
|
||||
:param data:
|
||||
:type data:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
data = json.loads(data)
|
||||
digest = data['digest']
|
||||
key_id = self.get_operational_key().get('keyid')
|
||||
signature = self.gpg.sign(digest, passphrase=self.gpg_passphrase, keyid=key_id)
|
||||
return str(signature)
|
||||
|
||||
|
||||
102
apps/cic-ussd/cic_ussd/metadata/user.py
Normal file
102
apps/cic-ussd/cic_ussd/metadata/user.py
Normal file
@@ -0,0 +1,102 @@
|
||||
# standard imports
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
# third-party imports
|
||||
import requests
|
||||
from cic_types.models.person import generate_metadata_pointer, Person
|
||||
|
||||
# local imports
|
||||
from cic_ussd.chain import Chain
|
||||
from cic_ussd.metadata import make_request
|
||||
from cic_ussd.metadata.signer import Signer
|
||||
from cic_ussd.redis import cache_data
|
||||
|
||||
logg = logging.getLogger()
|
||||
|
||||
|
||||
class UserMetadata:
|
||||
"""
|
||||
:cvar base_url:
|
||||
:type base_url:
|
||||
"""
|
||||
base_url = None
|
||||
|
||||
def __init__(self, identifier: bytes):
|
||||
"""
|
||||
:param identifier:
|
||||
:type identifier:
|
||||
"""
|
||||
self. headers = {
|
||||
'X-CIC-AUTOMERGE': 'server',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
self.identifier = identifier
|
||||
self.metadata_pointer = generate_metadata_pointer(
|
||||
identifier=self.identifier,
|
||||
cic_type='cic.person'
|
||||
)
|
||||
if self.base_url:
|
||||
self.url = os.path.join(self.base_url, self.metadata_pointer)
|
||||
|
||||
def create(self, data: dict):
|
||||
try:
|
||||
data = json.dumps(data).encode('utf-8')
|
||||
result = make_request(method='POST', url=self.url, data=data, headers=self.headers)
|
||||
metadata = result.content
|
||||
self.edit(data=metadata, engine='pgp')
|
||||
logg.info(f'Get sign material response status: {result.status_code}')
|
||||
result.raise_for_status()
|
||||
except requests.exceptions.HTTPError as error:
|
||||
raise RuntimeError(error)
|
||||
|
||||
def edit(self, data: bytes, engine: str):
|
||||
"""
|
||||
:param data:
|
||||
:type data:
|
||||
:param engine:
|
||||
:type engine:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
cic_meta_signer = Signer()
|
||||
signature = cic_meta_signer.sign_digest(data=data)
|
||||
algorithm = cic_meta_signer.get_operational_key().get('algo')
|
||||
formatted_data = {
|
||||
'm': data.decode('utf-8'),
|
||||
's': {
|
||||
'engine': engine,
|
||||
'algo': algorithm,
|
||||
'data': signature,
|
||||
'digest': json.loads(data).get('digest'),
|
||||
}
|
||||
}
|
||||
formatted_data = json.dumps(formatted_data).encode('utf-8')
|
||||
|
||||
try:
|
||||
result = make_request(method='PUT', url=self.url, data=formatted_data, headers=self.headers)
|
||||
logg.info(f'Signed content submission status: {result.status_code}.')
|
||||
result.raise_for_status()
|
||||
except requests.exceptions.HTTPError as error:
|
||||
raise RuntimeError(error)
|
||||
|
||||
def query(self):
|
||||
result = make_request(method='GET', url=self.url)
|
||||
status = result.status_code
|
||||
logg.info(f'Get latest data status: {status}')
|
||||
try:
|
||||
if status == 200:
|
||||
response_data = result.content
|
||||
data = json.loads(response_data.decode())
|
||||
|
||||
# validate data
|
||||
person = Person()
|
||||
deserialized_person = person.deserialize(metadata=json.loads(data))
|
||||
|
||||
cache_data(key=self.metadata_pointer, data=json.dumps(deserialized_person.serialize()))
|
||||
elif status == 404:
|
||||
logg.info('The data is not available and might need to be added.')
|
||||
result.raise_for_status()
|
||||
except requests.exceptions.HTTPError as error:
|
||||
raise RuntimeError(error)
|
||||
@@ -5,7 +5,6 @@ import logging
|
||||
# third party imports
|
||||
import celery
|
||||
import i18n
|
||||
import phonenumbers
|
||||
from cic_eth.api.api_task import Api
|
||||
from tinydb.table import Document
|
||||
from typing import Optional
|
||||
@@ -239,7 +238,7 @@ def persist_session_to_db_task(external_session_id: str, queue: str):
|
||||
:type queue: str
|
||||
"""
|
||||
s_persist_session_to_db = celery.signature(
|
||||
'cic_ussd.tasks.ussd.persist_session_to_db',
|
||||
'cic_ussd.tasks.ussd_session.persist_session_to_db',
|
||||
[external_session_id]
|
||||
)
|
||||
s_persist_session_to_db.apply_async(queue=queue)
|
||||
@@ -453,37 +452,3 @@ def save_to_in_memory_ussd_session_data(queue: str, session_data: dict, ussd_ses
|
||||
)
|
||||
persist_session_to_db_task(external_session_id=external_session_id, queue=queue)
|
||||
|
||||
|
||||
def process_phone_number(phone_number: str, region: str):
|
||||
"""This function parses any phone number for the provided region
|
||||
:param phone_number: A string with a phone number.
|
||||
:type phone_number: str
|
||||
:param region: Caller defined region
|
||||
:type region: str
|
||||
:return: The parsed phone number value based on the defined region
|
||||
:rtype: str
|
||||
"""
|
||||
if not isinstance(phone_number, str):
|
||||
try:
|
||||
phone_number = str(int(phone_number))
|
||||
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
phone_number_object = phonenumbers.parse(phone_number, region)
|
||||
parsed_phone_number = phonenumbers.format_number(phone_number_object, phonenumbers.PhoneNumberFormat.E164)
|
||||
|
||||
return parsed_phone_number
|
||||
|
||||
|
||||
def get_user_by_phone_number(phone_number: str) -> Optional[User]:
|
||||
"""This function queries the database for a user based on the provided phone number.
|
||||
:param phone_number: A valid phone number.
|
||||
:type phone_number: str
|
||||
:return: A user object matching a given phone number
|
||||
:rtype: User|None
|
||||
"""
|
||||
# consider adding region to user's metadata
|
||||
phone_number = process_phone_number(phone_number=phone_number, region='KE')
|
||||
user = User.session.query(User).filter_by(phone_number=phone_number).first()
|
||||
return user
|
||||
|
||||
43
apps/cic-ussd/cic_ussd/phone_number.py
Normal file
43
apps/cic-ussd/cic_ussd/phone_number.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# standard imports
|
||||
from typing import Optional
|
||||
|
||||
# third-party imports
|
||||
import phonenumbers
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db.models.user import User
|
||||
|
||||
|
||||
def process_phone_number(phone_number: str, region: str):
|
||||
"""This function parses any phone number for the provided region
|
||||
:param phone_number: A string with a phone number.
|
||||
:type phone_number: str
|
||||
:param region: Caller defined region
|
||||
:type region: str
|
||||
:return: The parsed phone number value based on the defined region
|
||||
:rtype: str
|
||||
"""
|
||||
if not isinstance(phone_number, str):
|
||||
try:
|
||||
phone_number = str(int(phone_number))
|
||||
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
phone_number_object = phonenumbers.parse(phone_number, region)
|
||||
parsed_phone_number = phonenumbers.format_number(phone_number_object, phonenumbers.PhoneNumberFormat.E164)
|
||||
|
||||
return parsed_phone_number
|
||||
|
||||
|
||||
def get_user_by_phone_number(phone_number: str) -> Optional[User]:
|
||||
"""This function queries the database for a user based on the provided phone number.
|
||||
:param phone_number: A valid phone number.
|
||||
:type phone_number: str
|
||||
:return: A user object matching a given phone number
|
||||
:rtype: User|None
|
||||
"""
|
||||
# consider adding region to user's metadata
|
||||
phone_number = process_phone_number(phone_number=phone_number, region='KE')
|
||||
user = User.session.query(User).filter_by(phone_number=phone_number).first()
|
||||
return user
|
||||
@@ -1,17 +1,26 @@
|
||||
# standard imports
|
||||
import logging
|
||||
import json
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
# third party imports
|
||||
import celery
|
||||
from cic_types.models.person import Person
|
||||
from tinydb.table import Document
|
||||
|
||||
# local imports
|
||||
from cic_ussd.accounts import BalanceManager
|
||||
from cic_ussd.account import define_account_tx_metadata, retrieve_account_statement
|
||||
from cic_ussd.balance import BalanceManager, compute_operational_balance, get_cached_operational_balance
|
||||
from cic_ussd.chain import Chain
|
||||
from cic_ussd.db.models.user import AccountStatus, User
|
||||
from cic_ussd.db.models.ussd_session import UssdSession
|
||||
from cic_ussd.menu.ussd_menu import UssdMenu
|
||||
from cic_ussd.metadata import blockchain_address_to_metadata_pointer
|
||||
from cic_ussd.phone_number import get_user_by_phone_number
|
||||
from cic_ussd.redis import cache_data, create_cached_data_key, get_cached_data
|
||||
from cic_ussd.state_machine import UssdStateMachine
|
||||
from cic_ussd.transactions import to_wei, from_wei
|
||||
from cic_ussd.conversions import to_wei, from_wei
|
||||
from cic_ussd.translation import translation_for
|
||||
|
||||
logg = logging.getLogger(__name__)
|
||||
@@ -57,17 +66,17 @@ def process_exit_insufficient_balance(display_key: str, user: User, ussd_session
|
||||
:rtype: str
|
||||
"""
|
||||
# get account balance
|
||||
balance_manager = BalanceManager(address=user.blockchain_address,
|
||||
chain_str=UssdStateMachine.chain_str,
|
||||
token_symbol='SRF')
|
||||
balance = balance_manager.get_operational_balance()
|
||||
operational_balance = get_cached_operational_balance(blockchain_address=user.blockchain_address)
|
||||
|
||||
# compile response data
|
||||
user_input = ussd_session.get('user_input').split('*')[-1]
|
||||
transaction_amount = to_wei(value=int(user_input))
|
||||
token_symbol = 'SRF'
|
||||
|
||||
recipient_phone_number = ussd_session.get('session_data').get('recipient_phone_number')
|
||||
tx_recipient_information = recipient_phone_number
|
||||
recipient = get_user_by_phone_number(phone_number=recipient_phone_number)
|
||||
|
||||
tx_recipient_information = define_account_tx_metadata(user=recipient)
|
||||
|
||||
return translation_for(
|
||||
key=display_key,
|
||||
@@ -75,7 +84,7 @@ def process_exit_insufficient_balance(display_key: str, user: User, ussd_session
|
||||
amount=from_wei(transaction_amount),
|
||||
token_symbol=token_symbol,
|
||||
recipient_information=tx_recipient_information,
|
||||
token_balance=balance
|
||||
token_balance=operational_balance
|
||||
)
|
||||
|
||||
|
||||
@@ -122,9 +131,10 @@ def process_transaction_pin_authorization(user: User, display_key: str, ussd_ses
|
||||
"""
|
||||
# compile response data
|
||||
recipient_phone_number = ussd_session.get('session_data').get('recipient_phone_number')
|
||||
tx_recipient_information = recipient_phone_number
|
||||
tx_sender_information = user.phone_number
|
||||
logg.debug('Requires integration with cic-meta to get user name.')
|
||||
recipient = get_user_by_phone_number(phone_number=recipient_phone_number)
|
||||
tx_recipient_information = define_account_tx_metadata(user=recipient)
|
||||
tx_sender_information = define_account_tx_metadata(user=user)
|
||||
|
||||
token_symbol = 'SRF'
|
||||
user_input = ussd_session.get('user_input').split('*')[-1]
|
||||
transaction_amount = to_wei(value=int(user_input))
|
||||
@@ -139,6 +149,123 @@ def process_transaction_pin_authorization(user: User, display_key: str, ussd_ses
|
||||
)
|
||||
|
||||
|
||||
def process_account_balances(user: User, display_key: str, ussd_session: dict):
|
||||
"""
|
||||
:param user:
|
||||
:type user:
|
||||
:param display_key:
|
||||
:type display_key:
|
||||
:param ussd_session:
|
||||
:type ussd_session:
|
||||
: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 = ''
|
||||
|
||||
return translation_for(
|
||||
key=display_key,
|
||||
preferred_language=user.preferred_language,
|
||||
operational_balance=operational_balance,
|
||||
tax=tax,
|
||||
bonus=bonus,
|
||||
token_symbol='SRF'
|
||||
)
|
||||
|
||||
|
||||
def format_transactions(transactions: list, preferred_language: 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('destination_value')
|
||||
timestamp = transaction.get('timestamp')
|
||||
action_tag = transaction.get('action_tag')
|
||||
token_symbol = transaction.get('destination_token_symbol')
|
||||
|
||||
if action_tag == 'SENT' or action_tag == 'ULITUMA':
|
||||
formatted_transactions += f'{action_tag} {value} {token_symbol} {recipient_phone_number} {timestamp}.\n'
|
||||
else:
|
||||
formatted_transactions += f'{action_tag} {value} {token_symbol} {sender_phone_number} {timestamp}. \n'
|
||||
return formatted_transactions
|
||||
else:
|
||||
if preferred_language == 'en':
|
||||
formatted_transactions = 'Empty'
|
||||
else:
|
||||
formatted_transactions = 'Hamna historia'
|
||||
return formatted_transactions
|
||||
|
||||
|
||||
def process_account_statement(user: User, display_key: str, ussd_session: dict):
|
||||
"""
|
||||
:param user:
|
||||
:type user:
|
||||
:param display_key:
|
||||
:type display_key:
|
||||
:param ussd_session:
|
||||
:type ussd_session:
|
||||
: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)
|
||||
|
||||
first_transaction_set = []
|
||||
middle_transaction_set = []
|
||||
last_transaction_set = []
|
||||
|
||||
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 4 < len(transactions) < 7:
|
||||
middle_transaction_set += transactions[3:]
|
||||
first_transaction_set += transactions[:3]
|
||||
else:
|
||||
first_transaction_set += transactions[:3]
|
||||
|
||||
logg.debug(f'TRANSACTIONS: {transactions}')
|
||||
|
||||
if display_key == 'ussd.kenya.first_transaction_set':
|
||||
logg.debug(f'FIRST TRANSACTION SET: {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
|
||||
)
|
||||
)
|
||||
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
|
||||
)
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def process_start_menu(display_key: str, user: User):
|
||||
"""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
|
||||
@@ -150,16 +277,41 @@ def process_start_menu(display_key: str, user: User):
|
||||
:return: Corresponding translation text response
|
||||
:rtype: str
|
||||
"""
|
||||
balance_manager = BalanceManager(address=user.blockchain_address,
|
||||
chain_str=UssdStateMachine.chain_str,
|
||||
chain_str = Chain.spec.__str__()
|
||||
blockchain_address = user.blockchain_address
|
||||
balance_manager = BalanceManager(address=blockchain_address,
|
||||
chain_str=chain_str,
|
||||
token_symbol='SRF')
|
||||
balance = balance_manager.get_operational_balance()
|
||||
|
||||
# get balances synchronously for display on start menu
|
||||
balances_data = balance_manager.get_balances()
|
||||
|
||||
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 metadata
|
||||
s_query_user_metadata = celery.signature(
|
||||
'cic_ussd.tasks.metadata.query_user_metadata',
|
||||
[blockchain_address]
|
||||
)
|
||||
s_query_user_metadata.apply_async(queue='cic-ussd')
|
||||
|
||||
# retrieve and cache account's statement
|
||||
retrieve_account_statement(blockchain_address=blockchain_address)
|
||||
|
||||
# TODO [Philip]: figure out how to get token symbol from a metadata layer of sorts.
|
||||
token_symbol = 'SRF'
|
||||
logg.debug("Requires integration to determine user's balance and token.")
|
||||
|
||||
return translation_for(
|
||||
key=display_key,
|
||||
preferred_language=user.preferred_language,
|
||||
account_balance=balance,
|
||||
account_balance=operational_balance,
|
||||
account_token_name=token_symbol
|
||||
)
|
||||
|
||||
@@ -241,5 +393,11 @@ def custom_display_text(
|
||||
return process_exit_successful_transaction(display_key=display_key, user=user, ussd_session=ussd_session)
|
||||
elif menu_name == 'start':
|
||||
return process_start_menu(display_key=display_key, user=user)
|
||||
elif 'pin_authorization' in menu_name:
|
||||
return process_pin_authorization(display_key=display_key, user=user)
|
||||
elif menu_name == 'account_balances':
|
||||
return process_account_balances(display_key=display_key, user=user, ussd_session=ussd_session)
|
||||
elif 'transaction_set' in menu_name:
|
||||
return process_account_statement(display_key=display_key, user=user, ussd_session=ussd_session)
|
||||
else:
|
||||
return translation_for(key=display_key, preferred_language=user.preferred_language)
|
||||
|
||||
@@ -1,6 +1,52 @@
|
||||
# standard imports
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
# third-party imports
|
||||
from redis import Redis
|
||||
|
||||
logg = logging.getLogger()
|
||||
|
||||
|
||||
class InMemoryStore:
|
||||
cache: Redis = None
|
||||
|
||||
|
||||
def cache_data(key: str, data: str):
|
||||
"""
|
||||
:param key:
|
||||
:type key:
|
||||
:param data:
|
||||
:type data:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
cache = InMemoryStore.cache
|
||||
cache.set(name=key, value=data)
|
||||
cache.persist(name=key)
|
||||
|
||||
|
||||
def get_cached_data(key: str):
|
||||
"""
|
||||
:param key:
|
||||
:type key:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
cache = InMemoryStore.cache
|
||||
return cache.get(name=key)
|
||||
|
||||
|
||||
def create_cached_data_key(identifier: bytes, salt: str):
|
||||
"""
|
||||
:param identifier:
|
||||
:type identifier:
|
||||
:param salt:
|
||||
:type salt:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
hash_object = hashlib.new("sha256")
|
||||
hash_object.update(identifier)
|
||||
hash_object.update(salt.encode(encoding="utf-8"))
|
||||
return hash_object.digest().hex()
|
||||
|
||||
@@ -12,14 +12,18 @@ import redis
|
||||
|
||||
# third-party imports
|
||||
from confini import Config
|
||||
from chainlib.chain import ChainSpec
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
# local imports
|
||||
from cic_ussd.chain import Chain
|
||||
from cic_ussd.db import dsn_from_config
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.encoder import PasswordEncoder
|
||||
from cic_ussd.files.local_files import create_local_file_data_stores, json_file_parser
|
||||
from cic_ussd.menu.ussd_menu import UssdMenu
|
||||
from cic_ussd.metadata.signer import Signer
|
||||
from cic_ussd.metadata.user import UserMetadata
|
||||
from cic_ussd.operations import (define_response_with_content,
|
||||
process_menu_interaction_requests,
|
||||
define_multilingual_responses)
|
||||
@@ -59,6 +63,7 @@ config.censor('PASSWORD', 'DATABASE')
|
||||
# define log levels
|
||||
if args.vv:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
logging.getLogger('sqlalchemy.engine').setLevel(logging.DEBUG)
|
||||
elif args.v:
|
||||
logging.getLogger().setLevel(logging.INFO)
|
||||
|
||||
@@ -92,6 +97,14 @@ InMemoryStore.cache = redis.StrictRedis(host=config.get('REDIS_HOSTNAME'),
|
||||
decode_responses=True)
|
||||
InMemoryUssdSession.redis_cache = InMemoryStore.cache
|
||||
|
||||
# define metadata URL
|
||||
UserMetadata.base_url = config.get('CIC_META_URL')
|
||||
|
||||
# define signer values
|
||||
Signer.gpg_path = '/tmp/.gpg'
|
||||
Signer.gpg_passphrase = config.get('KEYS_PASSPHRASE')
|
||||
Signer.key_file_path = config.get('KEYS_PRIVATE')
|
||||
|
||||
# initialize celery app
|
||||
celery.Celery(backend=config.get('CELERY_RESULT_URL'), broker=config.get('CELERY_BROKER_URL'))
|
||||
|
||||
@@ -99,7 +112,13 @@ celery.Celery(backend=config.get('CELERY_RESULT_URL'), broker=config.get('CELERY
|
||||
states = json_file_parser(filepath=config.get('STATEMACHINE_STATES'))
|
||||
transitions = json_file_parser(filepath=config.get('STATEMACHINE_TRANSITIONS'))
|
||||
|
||||
UssdStateMachine.chain_str = config.get('CIC_CHAIN_SPEC')
|
||||
chain_spec = ChainSpec(
|
||||
common_name=config.get('CIC_COMMON_NAME'),
|
||||
engine=config.get('CIC_ENGINE'),
|
||||
network_id=config.get('CIC_NETWORK_ID')
|
||||
)
|
||||
|
||||
Chain.spec = chain_spec
|
||||
UssdStateMachine.states = states
|
||||
UssdStateMachine.transitions = transitions
|
||||
|
||||
@@ -152,7 +171,8 @@ def application(env, start_response):
|
||||
return []
|
||||
|
||||
# handle menu interaction requests
|
||||
response = process_menu_interaction_requests(chain_str=config.get('CIC_CHAIN_SPEC'),
|
||||
chain_str = chain_spec.__str__()
|
||||
response = process_menu_interaction_requests(chain_str=chain_str,
|
||||
external_session_id=external_session_id,
|
||||
phone_number=phone_number,
|
||||
queue=args.q,
|
||||
|
||||
@@ -12,6 +12,8 @@ from confini import Config
|
||||
# local imports
|
||||
from cic_ussd.db import dsn_from_config
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.metadata.signer import Signer
|
||||
from cic_ussd.metadata.user import UserMetadata
|
||||
from cic_ussd.redis import InMemoryStore
|
||||
from cic_ussd.session.ussd_session import UssdSession as InMemoryUssdSession
|
||||
|
||||
@@ -37,6 +39,7 @@ config.censor('PASSWORD', 'DATABASE')
|
||||
# define log levels
|
||||
if args.vv:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
logging.getLogger('sqlalchemy.engine').setLevel(logging.DEBUG)
|
||||
elif args.v:
|
||||
logging.getLogger().setLevel(logging.INFO)
|
||||
|
||||
@@ -59,6 +62,14 @@ InMemoryStore.cache = redis.StrictRedis(host=config.get('REDIS_HOSTNAME'),
|
||||
decode_responses=True)
|
||||
InMemoryUssdSession.redis_cache = InMemoryStore.cache
|
||||
|
||||
# define metadata URL
|
||||
UserMetadata.base_url = config.get('CIC_META_URL')
|
||||
|
||||
# define signer values
|
||||
Signer.gpg_path = '/tmp/.gpg'
|
||||
Signer.gpg_passphrase = config.get('KEYS_PASSPHRASE')
|
||||
Signer.key_file_path = config.get('KEYS_PRIVATE')
|
||||
|
||||
# set up celery
|
||||
current_app = celery.Celery(__name__)
|
||||
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
# standard imports
|
||||
import json
|
||||
import logging
|
||||
from typing import Tuple
|
||||
|
||||
# third party imports
|
||||
import celery
|
||||
|
||||
# local imports
|
||||
from cic_ussd.accounts import BalanceManager
|
||||
from cic_ussd.balance import BalanceManager, compute_operational_balance
|
||||
from cic_ussd.chain import Chain
|
||||
from cic_ussd.db.models.user import AccountStatus, User
|
||||
from cic_ussd.operations import get_user_by_phone_number, save_to_in_memory_ussd_session_data
|
||||
from cic_ussd.state_machine.state_machine import UssdStateMachine
|
||||
from cic_ussd.operations import save_to_in_memory_ussd_session_data
|
||||
from cic_ussd.phone_number import get_user_by_phone_number
|
||||
from cic_ussd.redis import create_cached_data_key, get_cached_data
|
||||
from cic_ussd.transactions import OutgoingTransactionProcessor
|
||||
|
||||
|
||||
@@ -27,22 +31,7 @@ def is_valid_recipient(state_machine_data: Tuple[str, dict, User]) -> bool:
|
||||
recipient = get_user_by_phone_number(phone_number=user_input)
|
||||
is_not_initiator = user_input != user.phone_number
|
||||
has_active_account_status = user.get_account_status() == AccountStatus.ACTIVE.name
|
||||
logg.debug('This section requires implementation of checks for user roles and authorization status of an account.')
|
||||
return is_not_initiator and has_active_account_status
|
||||
|
||||
|
||||
def is_valid_token_agent(state_machine_data: Tuple[str, dict, User]) -> bool:
|
||||
"""This function checks that a user exists, is not the initiator of the transaction, has an active account status
|
||||
and is authorized to perform exchange transactions.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
:return: A user's validity
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
# is_token_agent = AccountRole.TOKEN_AGENT.value in user.get_user_roles()
|
||||
logg.debug('This section requires implementation of user roles and authorization to facilitate exchanges.')
|
||||
return is_valid_recipient(state_machine_data=state_machine_data)
|
||||
return is_not_initiator and has_active_account_status and recipient is not None
|
||||
|
||||
|
||||
def is_valid_transaction_amount(state_machine_data: Tuple[str, dict, User]) -> bool:
|
||||
@@ -70,10 +59,17 @@ def has_sufficient_balance(state_machine_data: Tuple[str, dict, User]) -> bool:
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
balance_manager = BalanceManager(address=user.blockchain_address,
|
||||
chain_str=UssdStateMachine.chain_str,
|
||||
chain_str=Chain.spec.__str__(),
|
||||
token_symbol='SRF')
|
||||
balance = balance_manager.get_operational_balance()
|
||||
return int(user_input) <= balance
|
||||
# get cached balance
|
||||
key = create_cached_data_key(
|
||||
identifier=bytes.fromhex(user.blockchain_address[2:]),
|
||||
salt='cic.balances_data'
|
||||
)
|
||||
cached_balance = get_cached_data(key=key)
|
||||
operational_balance = compute_operational_balance(balances=json.loads(cached_balance))
|
||||
|
||||
return int(user_input) <= operational_balance
|
||||
|
||||
|
||||
def save_recipient_phone_to_session_data(state_machine_data: Tuple[str, dict, User]):
|
||||
@@ -88,6 +84,25 @@ def save_recipient_phone_to_session_data(state_machine_data: Tuple[str, dict, Us
|
||||
save_to_in_memory_ussd_session_data(queue='cic-ussd', session_data=session_data, ussd_session=ussd_session)
|
||||
|
||||
|
||||
def retrieve_recipient_metadata(state_machine_data: Tuple[str, dict, User]):
|
||||
"""
|
||||
:param state_machine_data:
|
||||
:type state_machine_data:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
|
||||
recipient = get_user_by_phone_number(phone_number=user_input)
|
||||
blockchain_address = recipient.blockchain_address
|
||||
# retrieve and cache account's metadata
|
||||
s_query_user_metadata = celery.signature(
|
||||
'cic_ussd.tasks.metadata.query_user_metadata',
|
||||
[blockchain_address]
|
||||
)
|
||||
s_query_user_metadata.apply_async(queue='cic-ussd')
|
||||
|
||||
|
||||
def save_transaction_amount_to_session_data(state_machine_data: Tuple[str, dict, User]):
|
||||
"""This function saves the phone number corresponding the intended recipients blockchain account.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
@@ -113,7 +128,8 @@ def process_transaction_request(state_machine_data: Tuple[str, dict, User]):
|
||||
to_address = recipient.blockchain_address
|
||||
from_address = user.blockchain_address
|
||||
amount = int(ussd_session.get('session_data').get('transaction_amount'))
|
||||
outgoing_tx_processor = OutgoingTransactionProcessor(chain_str=UssdStateMachine.chain_str,
|
||||
chain_str = Chain.spec.__str__()
|
||||
outgoing_tx_processor = OutgoingTransactionProcessor(chain_str=chain_str,
|
||||
from_address=from_address,
|
||||
to_address=to_address)
|
||||
outgoing_tx_processor.process_outgoing_transfer_transaction(amount=amount)
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
# standard imports
|
||||
import json
|
||||
import logging
|
||||
from typing import Tuple
|
||||
|
||||
# third-party imports
|
||||
import celery
|
||||
from cic_types.models.person import Person, generate_metadata_pointer
|
||||
from cic_types.models.person import generate_vcard_from_contact_data, manage_identity_data
|
||||
|
||||
# local imports
|
||||
from cic_ussd.chain import Chain
|
||||
from cic_ussd.db.models.user import User
|
||||
from cic_ussd.error import UserMetadataNotFoundError
|
||||
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__)
|
||||
|
||||
@@ -42,7 +52,29 @@ def update_account_status_to_active(state_machine_data: Tuple[str, dict, User]):
|
||||
User.session.commit()
|
||||
|
||||
|
||||
def save_profile_attribute_to_session_data(state_machine_data: Tuple[str, dict, User]):
|
||||
def process_gender_user_input(user: User, user_input: str):
|
||||
"""
|
||||
:param user:
|
||||
:type user:
|
||||
:param user_input:
|
||||
:type user_input:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
if user.preferred_language == 'en':
|
||||
if user_input == '1':
|
||||
gender = 'Male'
|
||||
else:
|
||||
gender = 'Female'
|
||||
else:
|
||||
if user_input == '1':
|
||||
gender = 'Mwanaume'
|
||||
else:
|
||||
gender = 'Mwanamke'
|
||||
return gender
|
||||
|
||||
|
||||
def save_metadata_attribute_to_session_data(state_machine_data: Tuple[str, dict, User]):
|
||||
"""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
|
||||
@@ -54,16 +86,17 @@ def save_profile_attribute_to_session_data(state_machine_data: Tuple[str, dict,
|
||||
|
||||
# define session data key from current state
|
||||
key = ''
|
||||
if 'first_name' in current_state:
|
||||
key = 'first_name'
|
||||
elif 'last_name' in current_state:
|
||||
key = 'last_name'
|
||||
if 'given_name' in current_state:
|
||||
key = 'given_name'
|
||||
elif 'family_name' in current_state:
|
||||
key = 'family_name'
|
||||
elif 'gender' in current_state:
|
||||
key = 'gender'
|
||||
user_input = process_gender_user_input(user=user, user_input=user_input)
|
||||
elif 'location' in current_state:
|
||||
key = 'location'
|
||||
elif 'business_profile' in current_state:
|
||||
key = 'business_profile'
|
||||
elif 'products' in current_state:
|
||||
key = 'products'
|
||||
|
||||
# check if there is existing session data
|
||||
if ussd_session.get('session_data'):
|
||||
@@ -76,14 +109,120 @@ def save_profile_attribute_to_session_data(state_machine_data: Tuple[str, dict,
|
||||
save_to_in_memory_ussd_session_data(queue='cic-ussd', session_data=session_data, ussd_session=ussd_session)
|
||||
|
||||
|
||||
def persist_profile_data(state_machine_data: Tuple[str, dict, User]):
|
||||
"""This function persists elements of the user profile stored in session data
|
||||
def format_user_metadata(metadata: dict, user: User):
|
||||
"""
|
||||
: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')
|
||||
location = {
|
||||
"area_name": metadata.get('location')
|
||||
}
|
||||
products = []
|
||||
if metadata.get('products'):
|
||||
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,
|
||||
"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, User]):
|
||||
"""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 = state_machine_data
|
||||
|
||||
# get session data
|
||||
profile_data = ussd_session.get('session_data')
|
||||
logg.debug('This section requires implementation of user metadata.')
|
||||
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_user_metadata = celery.signature(
|
||||
'cic_ussd.tasks.metadata.create_user_metadata',
|
||||
[blockchain_address, user_metadata]
|
||||
)
|
||||
s_create_user_metadata.apply_async(queue='cic-ussd')
|
||||
|
||||
|
||||
def edit_user_metadata_attribute(state_machine_data: Tuple[str, dict, User]):
|
||||
user_input, ussd_session, user = 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 UserMetadataNotFoundError(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')
|
||||
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
|
||||
person = Person()
|
||||
user_metadata = json.loads(user_metadata)
|
||||
deserialized_person = person.deserialize(metadata=user_metadata)
|
||||
|
||||
# edit specific metadata attribute
|
||||
if given_name:
|
||||
deserialized_person.given_name = given_name
|
||||
elif family_name:
|
||||
deserialized_person.family_name = family_name
|
||||
elif gender:
|
||||
deserialized_person.gender = gender
|
||||
elif location:
|
||||
# get existing location metadata:
|
||||
location_data = user_metadata.get('location')
|
||||
location_data['area_name'] = location
|
||||
deserialized_person.location = location_data
|
||||
elif products:
|
||||
deserialized_person.products = products
|
||||
|
||||
edited_metadata = deserialized_person.serialize()
|
||||
|
||||
s_edit_user_metadata = celery.signature(
|
||||
'cic_ussd.tasks.metadata.edit_user_metadata',
|
||||
[blockchain_address, edited_metadata, 'pgp']
|
||||
)
|
||||
s_edit_user_metadata.apply_async(queue='cic-ussd')
|
||||
|
||||
|
||||
def get_user_metadata(state_machine_data: Tuple[str, dict, User]):
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
blockchain_address = user.blockchain_address
|
||||
s_get_user_metadata = celery.signature(
|
||||
'cic_ussd.tasks.metadata.query_user_metadata',
|
||||
[blockchain_address]
|
||||
)
|
||||
s_get_user_metadata.apply_async(queue='cic-ussd')
|
||||
|
||||
@@ -3,55 +3,30 @@ import logging
|
||||
import re
|
||||
from typing import Tuple
|
||||
|
||||
# third-party imports
|
||||
from cic_types.models.person import generate_metadata_pointer
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db.models.user import User
|
||||
from cic_ussd.metadata import blockchain_address_to_metadata_pointer
|
||||
from cic_ussd.redis import get_cached_data
|
||||
|
||||
logg = logging.getLogger()
|
||||
|
||||
|
||||
def has_complete_profile_data(state_machine_data: Tuple[str, dict, User]):
|
||||
def has_cached_user_metadata(state_machine_data: Tuple[str, dict, User]):
|
||||
"""This function checks whether the attributes of the user's metadata constituting a profile are filled out.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: str
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
logg.debug('This section requires implementation of user metadata.')
|
||||
|
||||
|
||||
def has_empty_username_data(state_machine_data: Tuple[str, dict, User]):
|
||||
"""This function checks whether the aspects of the user's name metadata is filled out.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: str
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
logg.debug('This section requires implementation of user metadata.')
|
||||
|
||||
|
||||
def has_empty_gender_data(state_machine_data: Tuple[str, dict, User]):
|
||||
"""This function checks whether the aspects of the user's gender metadata is filled out.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: str
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
logg.debug('This section requires implementation of user metadata.')
|
||||
|
||||
|
||||
def has_empty_location_data(state_machine_data: Tuple[str, dict, User]):
|
||||
"""This function checks whether the aspects of the user's location metadata is filled out.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: str
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
logg.debug('This section requires implementation of user metadata.')
|
||||
|
||||
|
||||
def has_empty_business_profile_data(state_machine_data: Tuple[str, dict, User]):
|
||||
"""This function checks whether the aspects of the user's business profile metadata is filled out.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: str
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
logg.debug('This section requires implementation of user metadata.')
|
||||
# check for user metadata in cache
|
||||
key = generate_metadata_pointer(
|
||||
identifier=blockchain_address_to_metadata_pointer(blockchain_address=user.blockchain_address),
|
||||
cic_type='cic.person'
|
||||
)
|
||||
user_metadata = get_cached_data(key=key)
|
||||
return user_metadata is not None
|
||||
|
||||
|
||||
def is_valid_name(state_machine_data: Tuple[str, dict, User]):
|
||||
@@ -66,3 +41,18 @@ def is_valid_name(state_machine_data: Tuple[str, dict, User]):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def is_valid_gender_selection(state_machine_data: Tuple[str, dict, User]):
|
||||
"""
|
||||
:param state_machine_data:
|
||||
:type state_machine_data:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
selection_matcher = "^[1-2]$"
|
||||
if re.match(selection_matcher, user_input):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
@@ -14,15 +14,11 @@ class UssdStateMachine(Machine):
|
||||
menu as well as providing a means for navigating through these states based on different user inputs.
|
||||
It defines different helper functions that co-ordinate with the stakeholder components of the ussd menu: i.e the
|
||||
User, UssdSession, UssdMenu to facilitate user interaction with ussd menu.
|
||||
|
||||
:cvar chain_str: The chain name and network id.
|
||||
:type chain_str: str
|
||||
:cvar states: A list of pre-defined states.
|
||||
:type states: list
|
||||
:cvar transitions: A list of pre-defined transitions.
|
||||
:type transitions: list
|
||||
"""
|
||||
chain_str = None
|
||||
states = []
|
||||
transitions = []
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import celery
|
||||
|
||||
celery_app = celery.current_app
|
||||
# export external celery task modules
|
||||
from .foo import log_it_plz
|
||||
from .ussd import persist_session_to_db
|
||||
from .callback_handler import process_account_creation_callback
|
||||
from .logger import *
|
||||
from .ussd_session import *
|
||||
from .callback_handler import *
|
||||
from .metadata import *
|
||||
|
||||
20
apps/cic-ussd/cic_ussd/tasks/base.py
Normal file
20
apps/cic-ussd/cic_ussd/tasks/base.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# standard imports
|
||||
|
||||
# third-party imports
|
||||
import celery
|
||||
import sqlalchemy
|
||||
|
||||
# local imports
|
||||
|
||||
|
||||
class CriticalTask(celery.Task):
|
||||
retry_jitter = True
|
||||
retry_backoff = True
|
||||
retry_backoff_max = 8
|
||||
|
||||
|
||||
class CriticalSQLAlchemyTask(CriticalTask):
|
||||
autoretry_for = (
|
||||
sqlalchemy.exc.DatabaseError,
|
||||
sqlalchemy.exc.TimeoutError,
|
||||
)
|
||||
@@ -1,23 +1,26 @@
|
||||
# standard imports
|
||||
import json
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# third-party imports
|
||||
import celery
|
||||
|
||||
# local imports
|
||||
from cic_ussd.conversions import from_wei
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.db.models.user import User
|
||||
from cic_ussd.account import define_account_tx_metadata
|
||||
from cic_ussd.error import ActionDataNotFoundError
|
||||
from cic_ussd.redis import InMemoryStore
|
||||
from cic_ussd.redis import InMemoryStore, cache_data, create_cached_data_key
|
||||
from cic_ussd.tasks.base import CriticalSQLAlchemyTask
|
||||
from cic_ussd.transactions import IncomingTransactionProcessor
|
||||
|
||||
logg = logging.getLogger(__file__)
|
||||
celery_app = celery.current_app
|
||||
|
||||
|
||||
@celery_app.task(bind=True)
|
||||
@celery_app.task(bind=True, base=CriticalSQLAlchemyTask)
|
||||
def process_account_creation_callback(self, result: str, url: str, status_code: int):
|
||||
"""This function defines a task that creates a user and
|
||||
:param result: The blockchain address for the created account
|
||||
@@ -49,14 +52,14 @@ def process_account_creation_callback(self, result: str, url: str, status_code:
|
||||
user = User(blockchain_address=result, phone_number=phone_number)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
# expire cache
|
||||
cache.expire(task_id, timedelta(seconds=30))
|
||||
session.close()
|
||||
cache.expire(task_id, timedelta(seconds=180))
|
||||
|
||||
else:
|
||||
cache.expire(task_id, timedelta(seconds=30))
|
||||
session.close()
|
||||
cache.expire(task_id, timedelta(seconds=180))
|
||||
|
||||
else:
|
||||
session.close()
|
||||
@@ -65,9 +68,8 @@ def process_account_creation_callback(self, result: str, url: str, status_code:
|
||||
|
||||
@celery_app.task
|
||||
def process_incoming_transfer_callback(result: dict, param: str, status_code: int):
|
||||
logg.debug(f'PARAM: {param}, RESULT: {result}, STATUS_CODE: {status_code}')
|
||||
session = SessionBase.create_session()
|
||||
if result and status_code == 0:
|
||||
if status_code == 0:
|
||||
|
||||
# collect result data
|
||||
recipient_blockchain_address = result.get('recipient')
|
||||
@@ -93,22 +95,125 @@ def process_incoming_transfer_callback(result: dict, param: str, status_code: in
|
||||
value=value)
|
||||
|
||||
if param == 'tokengift':
|
||||
logg.debug('Name information would require integration with cic meta.')
|
||||
incoming_tx_processor.process_token_gift_incoming_transactions(first_name="")
|
||||
incoming_tx_processor.process_token_gift_incoming_transactions()
|
||||
elif param == 'transfer':
|
||||
logg.debug('Name information would require integration with cic meta.')
|
||||
if sender_user:
|
||||
sender_information = f'{sender_user.phone_number}, {""}, {""}'
|
||||
incoming_tx_processor.process_transfer_incoming_transaction(sender_information=sender_information)
|
||||
sender_information = define_account_tx_metadata(user=sender_user)
|
||||
incoming_tx_processor.process_transfer_incoming_transaction(
|
||||
sender_information=sender_information,
|
||||
recipient_blockchain_address=recipient_blockchain_address
|
||||
)
|
||||
else:
|
||||
logg.warning(
|
||||
f'Tx with sender: {sender_blockchain_address} was received but has no matching user in the system.'
|
||||
)
|
||||
incoming_tx_processor.process_transfer_incoming_transaction(
|
||||
sender_information=sender_blockchain_address)
|
||||
sender_information='GRASSROOTS ECONOMICS',
|
||||
recipient_blockchain_address=recipient_blockchain_address
|
||||
)
|
||||
else:
|
||||
session.close()
|
||||
raise ValueError(f'Unexpected transaction param: {param}.')
|
||||
else:
|
||||
session.close()
|
||||
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))
|
||||
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'
|
||||
else:
|
||||
action_tag = 'ULITUMA'
|
||||
else:
|
||||
if preferred_language == 'en':
|
||||
action_tag = 'RECEIVED'
|
||||
else:
|
||||
action_tag = 'ULIPOKEA'
|
||||
return action_tag
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def process_statement_callback(result, param: str, status_code: int):
|
||||
if status_code == 0:
|
||||
# create session
|
||||
session = SessionBase.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')
|
||||
destination_token_symbol = transaction.get('destination_token_symbol')
|
||||
|
||||
# filter out any transactions that are "gassy"
|
||||
if '0x0000000000000000000000000000000000000000' in source_token:
|
||||
pass
|
||||
else:
|
||||
# describe a processed transaction
|
||||
processed_transaction = {}
|
||||
|
||||
# check if sender is in the system
|
||||
sender: User = session.query(User).filter_by(blockchain_address=sender_blockchain_address).first()
|
||||
if sender:
|
||||
processed_transaction['sender_phone_number'] = sender.phone_number
|
||||
|
||||
action_tag = define_transaction_action_tag(
|
||||
preferred_language=sender.preferred_language,
|
||||
sender_blockchain_address=sender_blockchain_address,
|
||||
param=param
|
||||
)
|
||||
processed_transaction['action_tag'] = action_tag
|
||||
|
||||
else:
|
||||
processed_transaction['sender_phone_number'] = 'GRASSROOTS ECONOMICS'
|
||||
|
||||
# check if recipient is in the system
|
||||
recipient: User = session.query(User).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')
|
||||
|
||||
# add transaction values
|
||||
processed_transaction['destination_value'] = from_wei(value=transaction.get('destination_value'))
|
||||
processed_transaction['destination_token_symbol'] = destination_token_symbol
|
||||
processed_transaction['source_value'] = from_wei(value=transaction.get('source_value'))
|
||||
|
||||
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}.')
|
||||
|
||||
48
apps/cic-ussd/cic_ussd/tasks/metadata.py
Normal file
48
apps/cic-ussd/cic_ussd/tasks/metadata.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# standard imports
|
||||
import json
|
||||
import logging
|
||||
|
||||
# third-party imports
|
||||
import celery
|
||||
|
||||
# local imports
|
||||
from cic_ussd.metadata import blockchain_address_to_metadata_pointer
|
||||
from cic_ussd.metadata.user import UserMetadata
|
||||
|
||||
celery_app = celery.current_app
|
||||
logg = logging.getLogger()
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def query_user_metadata(blockchain_address: str):
|
||||
"""
|
||||
:param blockchain_address:
|
||||
:type blockchain_address:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
identifier = blockchain_address_to_metadata_pointer(blockchain_address=blockchain_address)
|
||||
user_metadata_client = UserMetadata(identifier=identifier)
|
||||
user_metadata_client.query()
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def create_user_metadata(blockchain_address: str, data: dict):
|
||||
"""
|
||||
:param blockchain_address:
|
||||
:type blockchain_address:
|
||||
:param data:
|
||||
:type data:
|
||||
:return:
|
||||
:rtype:
|
||||
"""
|
||||
identifier = blockchain_address_to_metadata_pointer(blockchain_address=blockchain_address)
|
||||
user_metadata_client = UserMetadata(identifier=identifier)
|
||||
user_metadata_client.create(data=data)
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def edit_user_metadata(blockchain_address: str, data: bytes, engine: str):
|
||||
identifier = blockchain_address_to_metadata_pointer(blockchain_address=blockchain_address)
|
||||
user_metadata_client = UserMetadata(identifier=identifier)
|
||||
user_metadata_client.edit(data=data, engine=engine)
|
||||
@@ -11,12 +11,13 @@ from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.db.models.ussd_session import UssdSession
|
||||
from cic_ussd.error import SessionNotFoundError
|
||||
from cic_ussd.session.ussd_session import UssdSession as InMemoryUssdSession
|
||||
from cic_ussd.tasks.base import CriticalSQLAlchemyTask
|
||||
|
||||
celery_app = celery.current_app
|
||||
logg = get_logger(__file__)
|
||||
|
||||
|
||||
@celery_app.task
|
||||
@celery_app.task(base=CriticalSQLAlchemyTask)
|
||||
def persist_session_to_db(external_session_id: str):
|
||||
"""
|
||||
This task initiates the saving of the session object to the database and it's removal from the in-memory storage.
|
||||
@@ -62,11 +63,10 @@ def persist_session_to_db(external_session_id: str):
|
||||
in_db_ussd_session.set_data(key=key, value=value, session=session)
|
||||
|
||||
session.add(in_db_ussd_session)
|
||||
session.commit()
|
||||
session.close()
|
||||
InMemoryUssdSession.redis_cache.expire(external_session_id, timedelta(minutes=1))
|
||||
else:
|
||||
session.close()
|
||||
raise SessionNotFoundError('Session does not exist!')
|
||||
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
@@ -7,6 +7,7 @@ from datetime import datetime
|
||||
from cic_eth.api import Api
|
||||
|
||||
# local imports
|
||||
from cic_ussd.balance import get_cached_operational_balance
|
||||
from cic_ussd.notifications import Notifier
|
||||
|
||||
|
||||
@@ -35,7 +36,7 @@ def from_wei(value: int) -> float:
|
||||
:return: SRF equivalent of value in Wei
|
||||
:rtype: float
|
||||
"""
|
||||
value = float(value) / 1e+18
|
||||
value = float(value) / 1e+6
|
||||
return truncate(value=value, decimals=2)
|
||||
|
||||
|
||||
@@ -67,11 +68,9 @@ class IncomingTransactionProcessor:
|
||||
self.token_symbol = token_symbol
|
||||
self.value = value
|
||||
|
||||
def process_token_gift_incoming_transactions(self, first_name: str):
|
||||
def process_token_gift_incoming_transactions(self):
|
||||
"""This function processes incoming transactions with a "tokengift" param, it collects all appropriate data to
|
||||
send out notifications to users when their accounts are successfully created.
|
||||
:param first_name: The first name of the recipient of the token gift transaction.
|
||||
:type first_name: str
|
||||
|
||||
"""
|
||||
balance = from_wei(value=self.value)
|
||||
@@ -80,20 +79,22 @@ class IncomingTransactionProcessor:
|
||||
phone_number=self.phone_number,
|
||||
preferred_language=self.preferred_language,
|
||||
balance=balance,
|
||||
first_name=first_name,
|
||||
token_symbol=self.token_symbol)
|
||||
|
||||
def process_transfer_incoming_transaction(self, sender_information: str):
|
||||
def process_transfer_incoming_transaction(self, sender_information: str, recipient_blockchain_address: str):
|
||||
"""This function processes incoming transactions with the "transfer" param and issues notifications to users
|
||||
about reception of funds into their accounts.
|
||||
:param sender_information: A string with a user's full name and phone number.
|
||||
:type sender_information: str
|
||||
:param recipient_blockchain_address:
|
||||
type recipient_blockchain_address: str
|
||||
"""
|
||||
key = 'sms.received_tokens'
|
||||
amount = from_wei(value=self.value)
|
||||
timestamp = datetime.now().strftime('%d-%m-%y, %H:%M %p')
|
||||
|
||||
logg.debug('Balance requires implementation of cic-eth integration with balance.')
|
||||
operational_balance = get_cached_operational_balance(blockchain_address=recipient_blockchain_address)
|
||||
|
||||
notifier.send_sms_notification(key=key,
|
||||
phone_number=self.phone_number,
|
||||
preferred_language=self.preferred_language,
|
||||
@@ -101,7 +102,7 @@ class IncomingTransactionProcessor:
|
||||
token_symbol=self.token_symbol,
|
||||
tx_sender_information=sender_information,
|
||||
timestamp=timestamp,
|
||||
balance='')
|
||||
balance=operational_balance)
|
||||
|
||||
|
||||
class OutgoingTransactionProcessor:
|
||||
|
||||
@@ -110,7 +110,7 @@ def validate_phone_number(phone: str):
|
||||
|
||||
|
||||
def validate_response_type(processor_response: str) -> bool:
|
||||
"""
|
||||
"""1*3443*3443*Philip*Wanga*1*Juja*Software Developer*2*3
|
||||
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
|
||||
:param processor_response: A ussd menu's text value.
|
||||
|
||||
Reference in New Issue
Block a user