Merge remote-tracking branch 'origin/master' into lash/advanced-cache-queries
This commit is contained in:
commit
d00af34528
@ -46,7 +46,7 @@ def get_adjusted_balance(self, token_symbol, amount, timestamp):
|
|||||||
|
|
||||||
|
|
||||||
def aux_setup(rpc, config, sender_address=ZERO_ADDRESS):
|
def aux_setup(rpc, config, sender_address=ZERO_ADDRESS):
|
||||||
chain_spec_str = config.get('CIC_CHAIN_SPEC')
|
chain_spec_str = config.get('CHAIN_SPEC')
|
||||||
chain_spec = ChainSpec.from_chain_str(chain_spec_str)
|
chain_spec = ChainSpec.from_chain_str(chain_spec_str)
|
||||||
token_symbol = config.get('CIC_DEFAULT_TOKEN_SYMBOL')
|
token_symbol = config.get('CIC_DEFAULT_TOKEN_SYMBOL')
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
celery==4.4.7
|
celery==4.4.7
|
||||||
erc20-demurrage-token~=0.0.2a3
|
erc20-demurrage-token~=0.0.3a1
|
||||||
cic-eth-registry~=0.5.6a1
|
cic-eth-registry~=0.5.8a1
|
||||||
chainlib~=0.0.5a1
|
chainlib~=0.0.7a1
|
||||||
cic_eth~=0.12.0a2
|
cic_eth~=0.12.2a4
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
[metadata]
|
[metadata]
|
||||||
name = cic-eth-aux-erc20-demurrage-token
|
name = cic-eth-aux-erc20-demurrage-token
|
||||||
version = 0.0.2a4
|
version = 0.0.2a6
|
||||||
description = cic-eth tasks supporting erc20 demurrage token
|
description = cic-eth tasks supporting erc20 demurrage token
|
||||||
author = Louis Holbrook
|
author = Louis Holbrook
|
||||||
author_email = dev@holbrook.no
|
author_email = dev@holbrook.no
|
||||||
|
@ -5,8 +5,7 @@ pytest-cov==2.10.1
|
|||||||
eth-tester==0.5.0b3
|
eth-tester==0.5.0b3
|
||||||
py-evm==0.3.0a20
|
py-evm==0.3.0a20
|
||||||
SQLAlchemy==1.3.20
|
SQLAlchemy==1.3.20
|
||||||
cic-eth~=0.12.0a1
|
|
||||||
liveness~=0.0.1a7
|
liveness~=0.0.1a7
|
||||||
eth-accounts-index==0.0.12a1
|
eth-accounts-index==0.1.1a1
|
||||||
eth-contract-registry==0.5.6a1
|
eth-contract-registry==0.5.8a1
|
||||||
eth-address-index==0.1.2a1
|
eth-address-index==0.2.1a1
|
||||||
|
@ -1,5 +1,15 @@
|
|||||||
[redis]
|
[redis]
|
||||||
host = localhost
|
host = localhost
|
||||||
|
<<<<<<< HEAD
|
||||||
port = 6379
|
port = 6379
|
||||||
db = 0
|
db = 0
|
||||||
timeout = 20.0
|
timeout = 20.0
|
||||||
|
=======
|
||||||
|
<<<<<<<< HEAD:apps/cic-eth/config/docker/redis.ini
|
||||||
|
port = 63379
|
||||||
|
========
|
||||||
|
port = 6379
|
||||||
|
db = 0
|
||||||
|
timeout = 20.0
|
||||||
|
>>>>>>>> origin/master:apps/cic-eth/cic_eth/data/config/redis.ini
|
||||||
|
>>>>>>> origin/master
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
[celery]
|
|
||||||
broker_url = redis://localhost:63379
|
|
||||||
result_url = redis://localhost:63379
|
|
@ -1,10 +0,0 @@
|
|||||||
[database]
|
|
||||||
NAME=cic_eth
|
|
||||||
USER=postgres
|
|
||||||
PASSWORD=tralala
|
|
||||||
HOST=localhost
|
|
||||||
PORT=63432
|
|
||||||
ENGINE=postgresql
|
|
||||||
DRIVER=psycopg2
|
|
||||||
POOL_SIZE=50
|
|
||||||
DEBUG=0
|
|
@ -1,3 +0,0 @@
|
|||||||
[redis]
|
|
||||||
host = localhost
|
|
||||||
port = 63379
|
|
@ -27,6 +27,12 @@ RUN python setup.py install
|
|||||||
|
|
||||||
ENV PYTHONPATH .
|
ENV PYTHONPATH .
|
||||||
|
|
||||||
|
RUN --mount=type=cache,mode=0755,target=/root/.cache/pip \
|
||||||
|
pip install --index-url https://pypi.org/simple \
|
||||||
|
--extra-index-url $GITLAB_PYTHON_REGISTRY \
|
||||||
|
--extra-index-url $EXTRA_INDEX_URL \
|
||||||
|
cic-eth-aux-erc20-demurrage-token~=0.0.2a6
|
||||||
|
|
||||||
COPY docker/entrypoints/* ./
|
COPY docker/entrypoints/* ./
|
||||||
RUN chmod 755 *.sh
|
RUN chmod 755 *.sh
|
||||||
|
|
||||||
|
@ -9,5 +9,5 @@ def test_check_signer(
|
|||||||
eth_rpc,
|
eth_rpc,
|
||||||
):
|
):
|
||||||
|
|
||||||
config.add(str(default_chain_spec), 'CIC_CHAIN_SPEC', exists_ok=True)
|
config.add(str(default_chain_spec), 'CHAIN_SPEC', exists_ok=True)
|
||||||
assert health(config=config)
|
assert health(config=config)
|
||||||
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
0
apps/cic-notify/cic_notify/ext/__init__.py
Normal file
0
apps/cic-notify/cic_notify/ext/__init__.py
Normal file
0
apps/cic-notify/cic_notify/runnable/__init__.py
Normal file
0
apps/cic-notify/cic_notify/runnable/__init__.py
Normal file
@ -13,13 +13,10 @@ RUN --mount=type=cache,mode=0755,target=/root/.cache/pip \
|
|||||||
-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 []
|
||||||
|
@ -15,10 +15,8 @@ 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
|
||||||
|
@ -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
|
||||||
|
@ -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 $@
|
||||||
|
@ -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 =
|
||||||
|
@ -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 =
|
|
@ -1,10 +0,0 @@
|
|||||||
[database]
|
|
||||||
NAME=cic_ussd
|
|
||||||
USER=postgres
|
|
||||||
PASSWORD=
|
|
||||||
HOST=localhost
|
|
||||||
PORT=5432
|
|
||||||
ENGINE=postgresql
|
|
||||||
DRIVER=psycopg2
|
|
||||||
DEBUG=0
|
|
||||||
POOL_SIZE=1
|
|
@ -1,9 +0,0 @@
|
|||||||
[celery]
|
|
||||||
BROKER_URL=redis://
|
|
||||||
RESULT_URL=redis://
|
|
||||||
|
|
||||||
[redis]
|
|
||||||
HOSTNAME=redis
|
|
||||||
PASSWORD=
|
|
||||||
PORT=6379
|
|
||||||
DATABASE=0
|
|
@ -1,15 +0,0 @@
|
|||||||
[app]
|
|
||||||
ALLOWED_IP=127.0.0.1
|
|
||||||
LOCALE_FALLBACK=en
|
|
||||||
LOCALE_PATH=var/lib/locale/
|
|
||||||
MAX_BODY_LENGTH=1024
|
|
||||||
PASSWORD_PEPPER=QYbzKff6NhiQzY3ygl2BkiKOpER8RE/Upqs/5aZWW+I=
|
|
||||||
SERVICE_CODE=*483*46#
|
|
||||||
SUPPORT_PHONE_NUMBER=0757628885
|
|
||||||
|
|
||||||
[ussd]
|
|
||||||
MENU_FILE=/usr/local/lib/python3.8/site-packages/cic_ussd/db/ussd_menu.json
|
|
||||||
|
|
||||||
[statemachine]
|
|
||||||
STATES=/usr/src/cic-ussd/states/
|
|
||||||
TRANSITIONS=/usr/src/cic-ussd/transitions/
|
|
@ -1,8 +0,0 @@
|
|||||||
[database]
|
|
||||||
NAME=cic_ussd_test
|
|
||||||
USER=postgres
|
|
||||||
PASSWORD=
|
|
||||||
HOST=localhost
|
|
||||||
PORT=5432
|
|
||||||
ENGINE=sqlite
|
|
||||||
DRIVER=pysqlite
|
|
@ -1,5 +0,0 @@
|
|||||||
[pgp]
|
|
||||||
export_dir = /usr/src/pgp/keys/
|
|
||||||
keys_path = /usr/src/secrets/
|
|
||||||
private_keys = privatekeys_meta.asc
|
|
||||||
passphrase =
|
|
@ -1,9 +0,0 @@
|
|||||||
[celery]
|
|
||||||
BROKER_URL = filesystem://
|
|
||||||
RESULT_URL = filesystem://
|
|
||||||
|
|
||||||
[redis]
|
|
||||||
HOSTNAME=localhost
|
|
||||||
PASSWORD=
|
|
||||||
PORT=6379
|
|
||||||
DATABASE=0
|
|
@ -31,7 +31,7 @@ test-mr-cic-ussd:
|
|||||||
pip install --extra-index-url https://pip.grassrootseconomics.net:8433
|
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"
|
||||||
|
@ -1,49 +0,0 @@
|
|||||||
# standard imports
|
|
||||||
import json
|
|
||||||
|
|
||||||
# third-party imports
|
|
||||||
from cic_eth.api import Api
|
|
||||||
from cic_types.models.person import Person
|
|
||||||
from cic_types.processor import generate_metadata_pointer
|
|
||||||
|
|
||||||
# local imports
|
|
||||||
from cic_ussd.chain import Chain
|
|
||||||
from cic_ussd.db.models.account import Account
|
|
||||||
from cic_ussd.metadata import blockchain_address_to_metadata_pointer
|
|
||||||
from cic_ussd.redis import get_cached_data
|
|
||||||
|
|
||||||
|
|
||||||
def define_account_tx_metadata(user: Account):
|
|
||||||
# get sender metadata
|
|
||||||
identifier = blockchain_address_to_metadata_pointer(
|
|
||||||
blockchain_address=user.blockchain_address
|
|
||||||
)
|
|
||||||
key = generate_metadata_pointer(
|
|
||||||
identifier=identifier,
|
|
||||||
cic_type=':cic.person'
|
|
||||||
)
|
|
||||||
account_metadata = get_cached_data(key=key)
|
|
||||||
|
|
||||||
if account_metadata:
|
|
||||||
account_metadata = json.loads(account_metadata)
|
|
||||||
person = Person()
|
|
||||||
deserialized_person = person.deserialize(person_data=account_metadata)
|
|
||||||
given_name = deserialized_person.given_name
|
|
||||||
family_name = deserialized_person.family_name
|
|
||||||
phone_number = deserialized_person.tel
|
|
||||||
|
|
||||||
return f'{given_name} {family_name} {phone_number}'
|
|
||||||
else:
|
|
||||||
phone_number = user.phone_number
|
|
||||||
return phone_number
|
|
||||||
|
|
||||||
|
|
||||||
def retrieve_account_statement(blockchain_address: str):
|
|
||||||
chain_str = Chain.spec.__str__()
|
|
||||||
cic_eth_api = Api(
|
|
||||||
chain_str=chain_str,
|
|
||||||
callback_queue='cic-ussd',
|
|
||||||
callback_task='cic_ussd.tasks.callback_handler.process_statement_callback',
|
|
||||||
callback_param=blockchain_address
|
|
||||||
)
|
|
||||||
cic_eth_api.list(address=blockchain_address, limit=9)
|
|
0
apps/cic-ussd/cic_ussd/account/__init__.py
Normal file
0
apps/cic-ussd/cic_ussd/account/__init__.py
Normal file
90
apps/cic-ussd/cic_ussd/account/balance.py
Normal file
90
apps/cic-ussd/cic_ussd/account/balance.py
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
# standard imports
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# third-party imports
|
||||||
|
from cic_eth.api import Api
|
||||||
|
|
||||||
|
# local imports
|
||||||
|
from cic_ussd.account.transaction import from_wei
|
||||||
|
from cic_ussd.cache import cache_data_key, get_cached_data
|
||||||
|
from cic_ussd.error import CachedDataNotFoundError
|
||||||
|
|
||||||
|
logg = logging.getLogger()
|
||||||
|
|
||||||
|
|
||||||
|
def get_balances(address: str,
|
||||||
|
chain_str: str,
|
||||||
|
token_symbol: str,
|
||||||
|
asynchronous: bool = False,
|
||||||
|
callback_param: any = None,
|
||||||
|
callback_queue='cic-ussd',
|
||||||
|
callback_task='cic_ussd.tasks.callback_handler.process_balances_callback') -> Optional[list]:
|
||||||
|
"""This function queries cic-eth for an account's balances, It provides a means to receive the balance either
|
||||||
|
asynchronously or synchronously.. It returns a dictionary containing the network, outgoing and incoming balances.
|
||||||
|
:param address: Ethereum address of an account.
|
||||||
|
:type address: str, 0x-hex
|
||||||
|
:param chain_str: The chain name and network id.
|
||||||
|
:type chain_str: str
|
||||||
|
:param asynchronous: Boolean value checking whether to return balances asynchronously.
|
||||||
|
:type asynchronous: bool
|
||||||
|
:param callback_param: Data to be sent along with the callback containing balance data.
|
||||||
|
:type callback_param: any
|
||||||
|
:param callback_queue:
|
||||||
|
:type callback_queue:
|
||||||
|
:param callback_task: A celery task path to which callback data should be sent.
|
||||||
|
:type callback_task: str
|
||||||
|
:param token_symbol: ERC20 token symbol of the account whose balance is being queried.
|
||||||
|
:type token_symbol: str
|
||||||
|
:return: A list containing balance data if called synchronously. | None
|
||||||
|
:rtype: list | None
|
||||||
|
"""
|
||||||
|
logg.debug(f'retrieving balance for address: {address}')
|
||||||
|
if asynchronous:
|
||||||
|
cic_eth_api = Api(
|
||||||
|
chain_str=chain_str,
|
||||||
|
callback_queue=callback_queue,
|
||||||
|
callback_task=callback_task,
|
||||||
|
callback_param=callback_param
|
||||||
|
)
|
||||||
|
cic_eth_api.balance(address=address, token_symbol=token_symbol)
|
||||||
|
else:
|
||||||
|
cic_eth_api = Api(chain_str=chain_str)
|
||||||
|
balance_request_task = cic_eth_api.balance(
|
||||||
|
address=address,
|
||||||
|
token_symbol=token_symbol)
|
||||||
|
return balance_request_task.get()
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_available_balance(balances: dict) -> float:
|
||||||
|
"""This function calculates an account's balance at a specific point in time by computing the difference from the
|
||||||
|
outgoing balance and the sum of the incoming and network balances.
|
||||||
|
:param balances: incoming, network and outgoing balances.
|
||||||
|
:type balances: dict
|
||||||
|
:return: Token value of the available balance.
|
||||||
|
:rtype: float
|
||||||
|
"""
|
||||||
|
incoming_balance = balances.get('balance_incoming')
|
||||||
|
outgoing_balance = balances.get('balance_outgoing')
|
||||||
|
network_balance = balances.get('balance_network')
|
||||||
|
|
||||||
|
available_balance = (network_balance + incoming_balance) - outgoing_balance
|
||||||
|
return from_wei(value=available_balance)
|
||||||
|
|
||||||
|
|
||||||
|
def get_cached_available_balance(blockchain_address: str) -> float:
|
||||||
|
"""This function attempts to retrieve balance data from the redis cache.
|
||||||
|
:param blockchain_address: Ethereum address of an account.
|
||||||
|
:type blockchain_address: str
|
||||||
|
:raises CachedDataNotFoundError: No cached balance data could be found.
|
||||||
|
:return: Operational balance of an account.
|
||||||
|
:rtype: float
|
||||||
|
"""
|
||||||
|
identifier = bytes.fromhex(blockchain_address[2:])
|
||||||
|
key = cache_data_key(identifier, salt=':cic.balances')
|
||||||
|
cached_balances = get_cached_data(key=key)
|
||||||
|
if cached_balances:
|
||||||
|
return calculate_available_balance(json.loads(cached_balances))
|
||||||
|
else:
|
||||||
|
raise CachedDataNotFoundError(f'No cached available balance for address: {blockchain_address}')
|
20
apps/cic-ussd/cic_ussd/account/maps.py
Normal file
20
apps/cic-ussd/cic_ussd/account/maps.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# standard imports
|
||||||
|
|
||||||
|
# external imports
|
||||||
|
|
||||||
|
# local imports
|
||||||
|
|
||||||
|
|
||||||
|
def gender():
|
||||||
|
return {
|
||||||
|
'1': 'male',
|
||||||
|
'2': 'female',
|
||||||
|
'3': 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def language():
|
||||||
|
return {
|
||||||
|
'1': 'en',
|
||||||
|
'2': 'sw'
|
||||||
|
}
|
44
apps/cic-ussd/cic_ussd/account/metadata.py
Normal file
44
apps/cic-ussd/cic_ussd/account/metadata.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# standard imports
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# external imports
|
||||||
|
from chainlib.hash import strip_0x
|
||||||
|
from cic_types.models.person import Person
|
||||||
|
|
||||||
|
# local imports
|
||||||
|
from cic_ussd.metadata import PreferencesMetadata
|
||||||
|
|
||||||
|
logg = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_cached_preferred_language(blockchain_address: str) -> Optional[str]:
|
||||||
|
"""This function retrieves an account's set preferred language from preferences metadata in redis cache.
|
||||||
|
:param blockchain_address:
|
||||||
|
:type blockchain_address:
|
||||||
|
:return: Account's set preferred language | Fallback preferred language.
|
||||||
|
:rtype: str
|
||||||
|
"""
|
||||||
|
identifier = bytes.fromhex(strip_0x(blockchain_address))
|
||||||
|
preferences_metadata_handler = PreferencesMetadata(identifier)
|
||||||
|
cached_preferences_metadata = preferences_metadata_handler.get_cached_metadata()
|
||||||
|
if cached_preferences_metadata:
|
||||||
|
preferences_metadata = json.loads(cached_preferences_metadata)
|
||||||
|
return preferences_metadata.get('preferred_language')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_account_metadata(account_metadata: dict) -> str:
|
||||||
|
"""
|
||||||
|
:param account_metadata:
|
||||||
|
:type account_metadata:
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
person = Person()
|
||||||
|
deserialized_person = person.deserialize(person_data=account_metadata)
|
||||||
|
given_name = deserialized_person.given_name
|
||||||
|
family_name = deserialized_person.family_name
|
||||||
|
phone_number = deserialized_person.tel
|
||||||
|
return f'{given_name} {family_name} {phone_number}'
|
111
apps/cic-ussd/cic_ussd/account/statement.py
Normal file
111
apps/cic-ussd/cic_ussd/account/statement.py
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
# standard imports
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# external imports
|
||||||
|
import celery
|
||||||
|
from chainlib.hash import strip_0x
|
||||||
|
from cic_eth.api import Api
|
||||||
|
|
||||||
|
# local import
|
||||||
|
from cic_ussd.account.chain import Chain
|
||||||
|
from cic_ussd.account.transaction import from_wei
|
||||||
|
from cic_ussd.cache import cache_data_key, get_cached_data
|
||||||
|
from cic_ussd.translation import translation_for
|
||||||
|
|
||||||
|
logg = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def filter_statement_transactions(transaction_list: list) -> list:
|
||||||
|
"""This function parses a transaction list and removes all transactions that entail interactions with the
|
||||||
|
zero address as the source transaction.
|
||||||
|
:param transaction_list: Array containing transaction objects.
|
||||||
|
:type transaction_list: list
|
||||||
|
:return: Transactions exclusive of the zero address transactions.
|
||||||
|
:rtype: list
|
||||||
|
"""
|
||||||
|
return [tx for tx in transaction_list if tx.get('source_token') != '0x0000000000000000000000000000000000000000']
|
||||||
|
|
||||||
|
|
||||||
|
def generate(querying_party: str, queue: Optional[str], transaction: dict):
|
||||||
|
"""
|
||||||
|
:param querying_party:
|
||||||
|
:type querying_party:
|
||||||
|
:param queue:
|
||||||
|
:type queue:
|
||||||
|
:param transaction:
|
||||||
|
:type transaction:
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
s_generate_statement = celery.signature(
|
||||||
|
'cic_ussd.tasks.processor.generate_statement', [querying_party, transaction], queue=queue
|
||||||
|
)
|
||||||
|
s_generate_statement.apply_async()
|
||||||
|
|
||||||
|
|
||||||
|
def get_cached_statement(blockchain_address: str) -> bytes:
|
||||||
|
"""This function retrieves an account's cached record of a specific number of transactions in chronological order.
|
||||||
|
:param blockchain_address: Bytes representation of the hex value of an account's blockchain address.
|
||||||
|
:type blockchain_address: bytes
|
||||||
|
:return: Account's transactions statements.
|
||||||
|
:rtype: str
|
||||||
|
"""
|
||||||
|
identifier = bytes.fromhex(strip_0x(blockchain_address))
|
||||||
|
key = cache_data_key(identifier=identifier, salt=':cic.statement')
|
||||||
|
return get_cached_data(key=key)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_statement_transactions(statement: list):
|
||||||
|
"""This function extracts information for transaction objects loaded from the redis cache and structures the data in
|
||||||
|
a format that is appropriate for the ussd interface.
|
||||||
|
:param statement: A list of transaction objects.
|
||||||
|
:type statement: list
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
parsed_transactions = []
|
||||||
|
for transaction in statement:
|
||||||
|
action_tag = transaction.get('action_tag')
|
||||||
|
amount = from_wei(transaction.get('token_value'))
|
||||||
|
direction_tag = transaction.get('direction_tag')
|
||||||
|
token_symbol = transaction.get('token_symbol')
|
||||||
|
metadata_id = transaction.get('metadata_id')
|
||||||
|
timestamp = datetime.datetime.now().strftime('%d/%m/%y, %H:%M')
|
||||||
|
transaction_repr = f'{action_tag} {amount} {token_symbol} {direction_tag} {metadata_id} {timestamp}'
|
||||||
|
parsed_transactions.append(transaction_repr)
|
||||||
|
return parsed_transactions
|
||||||
|
|
||||||
|
|
||||||
|
def query_statement(blockchain_address: str, limit: int = 9):
|
||||||
|
"""This function queries cic-eth for a set of chronologically ordered number of transactions associated with
|
||||||
|
an account.
|
||||||
|
:param blockchain_address: Ethereum address associated with an account.
|
||||||
|
:type blockchain_address: str, 0x-hex
|
||||||
|
:param limit: Number of transactions to be returned.
|
||||||
|
:type limit: int
|
||||||
|
"""
|
||||||
|
logg.debug(f'retrieving balance for address: {blockchain_address}')
|
||||||
|
chain_str = Chain.spec.__str__()
|
||||||
|
cic_eth_api = Api(
|
||||||
|
chain_str=chain_str,
|
||||||
|
callback_queue='cic-ussd',
|
||||||
|
callback_task='cic_ussd.tasks.callback_handler.statement_callback',
|
||||||
|
callback_param=blockchain_address
|
||||||
|
)
|
||||||
|
cic_eth_api.list(address=blockchain_address, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
def statement_transaction_set(preferred_language: str, transaction_reprs: list):
|
||||||
|
"""
|
||||||
|
:param preferred_language:
|
||||||
|
:type preferred_language:
|
||||||
|
:param transaction_reprs:
|
||||||
|
:type transaction_reprs:
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
if not transaction_reprs:
|
||||||
|
return translation_for('helpers.no_transaction_history', preferred_language)
|
||||||
|
return ''.join(f'{transaction_repr}\n' for transaction_repr in transaction_reprs)
|
61
apps/cic-ussd/cic_ussd/account/tokens.py
Normal file
61
apps/cic-ussd/cic_ussd/account/tokens.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# standard imports
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
# external imports
|
||||||
|
from cic_eth.api import Api
|
||||||
|
|
||||||
|
# local imports
|
||||||
|
from cic_ussd.account.chain import Chain
|
||||||
|
from cic_ussd.cache import cache_data_key, get_cached_data
|
||||||
|
from cic_ussd.error import SeppukuError
|
||||||
|
|
||||||
|
|
||||||
|
logg = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_cached_default_token(chain_str: str) -> Optional[str]:
|
||||||
|
"""This function attempts to retrieve the default token's data from the redis cache.
|
||||||
|
:param chain_str: chain name and network id.
|
||||||
|
:type chain_str: str
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
logg.debug(f'Retrieving default token from cache for chain: {chain_str}')
|
||||||
|
key = cache_data_key(identifier=chain_str.encode('utf-8'), salt=':cic.default_token_data')
|
||||||
|
return get_cached_data(key=key)
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_token_symbol():
|
||||||
|
"""This function attempts to retrieve the default token's symbol from cached default token's data.
|
||||||
|
:raises SeppukuError: The system should terminate itself because the default token is required for an appropriate
|
||||||
|
system state.
|
||||||
|
:return: Default token's symbol.
|
||||||
|
:rtype: str
|
||||||
|
"""
|
||||||
|
chain_str = Chain.spec.__str__()
|
||||||
|
cached_default_token = get_cached_default_token(chain_str)
|
||||||
|
if cached_default_token:
|
||||||
|
default_token_data = json.loads(cached_default_token)
|
||||||
|
return default_token_data.get('symbol')
|
||||||
|
else:
|
||||||
|
logg.warning('Cached default token data not found. Attempting retrieval from default token API')
|
||||||
|
default_token_data = query_default_token(chain_str)
|
||||||
|
if default_token_data:
|
||||||
|
return default_token_data.get('symbol')
|
||||||
|
else:
|
||||||
|
raise SeppukuError(f'Could not retrieve default token for: {chain_str}')
|
||||||
|
|
||||||
|
|
||||||
|
def query_default_token(chain_str: str):
|
||||||
|
"""This function synchronously queries cic-eth for the deployed system's default token.
|
||||||
|
:param chain_str: Chain name and network id.
|
||||||
|
:type chain_str: str
|
||||||
|
:return: Token's data.
|
||||||
|
:rtype: dict
|
||||||
|
"""
|
||||||
|
logg.debug(f'Querying API for default token on chain: {chain_str}')
|
||||||
|
cic_eth_api = Api(chain_str=chain_str)
|
||||||
|
default_token_request_task = cic_eth_api.default_token()
|
||||||
|
return default_token_request_task.get()
|
172
apps/cic-ussd/cic_ussd/account/transaction.py
Normal file
172
apps/cic-ussd/cic_ussd/account/transaction.py
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
# standard import
|
||||||
|
import decimal
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Tuple
|
||||||
|
|
||||||
|
# external import
|
||||||
|
from cic_eth.api import Api
|
||||||
|
from sqlalchemy.orm.session import Session
|
||||||
|
|
||||||
|
# local import
|
||||||
|
from cic_ussd.db.models.account import Account
|
||||||
|
from cic_ussd.db.models.base import SessionBase
|
||||||
|
from cic_ussd.error import UnknownUssdRecipient
|
||||||
|
from cic_ussd.translation import translation_for
|
||||||
|
|
||||||
|
logg = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_tags(action_tag_key: str, preferred_language: str, direction_tag_key: str, transaction: dict):
|
||||||
|
""" This function adds action and direction tags to a transaction data object.
|
||||||
|
:param action_tag_key: Key mapping to a helper entry in the translation files describing an action.
|
||||||
|
:type action_tag_key: str
|
||||||
|
:param preferred_language: An account's set preferred language.
|
||||||
|
:type preferred_language: str
|
||||||
|
:param direction_tag_key: Key mapping to a helper entry in the translation files describing a transaction's
|
||||||
|
direction relative to the transaction's subject account.
|
||||||
|
:type direction_tag_key: str
|
||||||
|
:param transaction: Parsed transaction data object.
|
||||||
|
:type transaction: dict
|
||||||
|
"""
|
||||||
|
action_tag = translation_for(action_tag_key, preferred_language)
|
||||||
|
direction_tag = translation_for(direction_tag_key, preferred_language)
|
||||||
|
transaction['action_tag'] = action_tag
|
||||||
|
transaction['direction_tag'] = direction_tag
|
||||||
|
|
||||||
|
|
||||||
|
def aux_transaction_data(preferred_language: str, transaction: dict) -> dict:
|
||||||
|
"""This function adds auxiliary data to a transaction object offering contextual information relative to the
|
||||||
|
subject account's role in the transaction.
|
||||||
|
:param preferred_language: An account's set preferred language.
|
||||||
|
:type preferred_language: str
|
||||||
|
:param transaction: Parsed transaction data object.
|
||||||
|
:type transaction: dict
|
||||||
|
:return: Transaction object with contextual data.
|
||||||
|
:rtype: dict
|
||||||
|
"""
|
||||||
|
role = transaction.get('role')
|
||||||
|
if role == 'recipient':
|
||||||
|
_add_tags('helpers.received', preferred_language, 'helpers.from', transaction)
|
||||||
|
if role == 'sender':
|
||||||
|
_add_tags('helpers.sent', preferred_language, 'helpers.to', transaction)
|
||||||
|
return transaction
|
||||||
|
|
||||||
|
|
||||||
|
def from_wei(value: int) -> float:
|
||||||
|
"""This function converts values in Wei to a token in the cic network.
|
||||||
|
:param value: Value in Wei
|
||||||
|
:type value: int
|
||||||
|
:return: SRF equivalent of value in Wei
|
||||||
|
:rtype: float
|
||||||
|
"""
|
||||||
|
value = float(value) / 1e+6
|
||||||
|
return truncate(value=value, decimals=2)
|
||||||
|
|
||||||
|
|
||||||
|
def to_wei(value: int) -> int:
|
||||||
|
"""This functions converts values from a token in the cic network to Wei.
|
||||||
|
:param value: Value in SRF
|
||||||
|
:type value: int
|
||||||
|
:return: Wei equivalent of value in SRF
|
||||||
|
:rtype: int
|
||||||
|
"""
|
||||||
|
return int(value * 1e+6)
|
||||||
|
|
||||||
|
|
||||||
|
def truncate(value: float, decimals: int):
|
||||||
|
"""This function truncates a value to a specified number of decimals places.
|
||||||
|
:param value: The value to be truncated.
|
||||||
|
:type value: float
|
||||||
|
:param decimals: The number of decimals for the value to be truncated to
|
||||||
|
:type decimals: int
|
||||||
|
:return: The truncated value.
|
||||||
|
:rtype: int
|
||||||
|
"""
|
||||||
|
decimal.getcontext().rounding = decimal.ROUND_DOWN
|
||||||
|
contextualized_value = decimal.Decimal(value)
|
||||||
|
return round(contextualized_value, decimals)
|
||||||
|
|
||||||
|
|
||||||
|
def transaction_actors(transaction: dict) -> Tuple[Dict, Dict]:
|
||||||
|
""" This function parses transaction data into a tuple of transaction data objects representative of
|
||||||
|
of the source and destination account's involved in a transaction.
|
||||||
|
:param transaction: Transaction data object.
|
||||||
|
:type transaction: dict
|
||||||
|
:return: Recipient and sender transaction data object
|
||||||
|
:rtype: Tuple[Dict, Dict]
|
||||||
|
"""
|
||||||
|
destination_token_symbol = transaction.get('destination_token_symbol')
|
||||||
|
destination_token_value = transaction.get('destination_token_value') or transaction.get('to_value')
|
||||||
|
recipient_blockchain_address = transaction.get('recipient')
|
||||||
|
sender_blockchain_address = transaction.get('sender')
|
||||||
|
source_token_symbol = transaction.get('source_token_symbol')
|
||||||
|
source_token_value = transaction.get('source_token_value') or transaction.get('from_value')
|
||||||
|
|
||||||
|
recipient_transaction_data = {
|
||||||
|
"token_symbol": destination_token_symbol,
|
||||||
|
"token_value": destination_token_value,
|
||||||
|
"blockchain_address": recipient_blockchain_address,
|
||||||
|
"role": "recipient",
|
||||||
|
}
|
||||||
|
sender_transaction_data = {
|
||||||
|
"blockchain_address": sender_blockchain_address,
|
||||||
|
"token_symbol": source_token_symbol,
|
||||||
|
"token_value": source_token_value,
|
||||||
|
"role": "sender",
|
||||||
|
}
|
||||||
|
return recipient_transaction_data, sender_transaction_data
|
||||||
|
|
||||||
|
|
||||||
|
def validate_transaction_account(session: Session, transaction: dict) -> Account:
|
||||||
|
"""This function checks whether the blockchain address specified in a parsed transaction object resolves to an
|
||||||
|
account object in the ussd system.
|
||||||
|
:param session: Database session object.
|
||||||
|
:type session: Session
|
||||||
|
:param transaction: Parsed transaction data object.
|
||||||
|
:type transaction: dict
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
blockchain_address = transaction.get('blockchain_address')
|
||||||
|
role = transaction.get('role')
|
||||||
|
session = SessionBase.bind_session(session)
|
||||||
|
account = session.query(Account).filter_by(blockchain_address=blockchain_address).first()
|
||||||
|
if not account:
|
||||||
|
if role == 'recipient':
|
||||||
|
raise UnknownUssdRecipient(
|
||||||
|
f'Tx for recipient: {blockchain_address} has no matching account in the system.'
|
||||||
|
)
|
||||||
|
if role == 'sender':
|
||||||
|
logg.warning(f'Tx from sender: {blockchain_address} has no matching account in system.')
|
||||||
|
|
||||||
|
SessionBase.release_session(session)
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
class OutgoingTransaction:
|
||||||
|
|
||||||
|
def __init__(self, chain_str: str, from_address: str, to_address: str):
|
||||||
|
"""
|
||||||
|
:param chain_str: The chain name and network id.
|
||||||
|
:type chain_str: str
|
||||||
|
:param from_address: Ethereum address of the sender
|
||||||
|
:type from_address: str, 0x-hex
|
||||||
|
:param to_address: Ethereum address of the recipient
|
||||||
|
:type to_address: str, 0x-hex
|
||||||
|
"""
|
||||||
|
self.chain_str = chain_str
|
||||||
|
self.cic_eth_api = Api(chain_str=chain_str)
|
||||||
|
self.from_address = from_address
|
||||||
|
self.to_address = to_address
|
||||||
|
|
||||||
|
def transfer(self, amount: int, token_symbol: str):
|
||||||
|
"""This function initiates standard transfers between one account to another
|
||||||
|
:param amount: The amount of tokens to be sent
|
||||||
|
:type amount: int
|
||||||
|
:param token_symbol: ERC20 token symbol of token to send
|
||||||
|
:type token_symbol: str
|
||||||
|
"""
|
||||||
|
self.cic_eth_api.transfer(from_address=self.from_address,
|
||||||
|
to_address=self.to_address,
|
||||||
|
value=to_wei(value=amount),
|
||||||
|
token_symbol=token_symbol)
|
@ -1,92 +0,0 @@
|
|||||||
# standard imports
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
# third-party imports
|
|
||||||
import celery
|
|
||||||
from cic_eth.api import Api
|
|
||||||
|
|
||||||
# local imports
|
|
||||||
from cic_ussd.error import CachedDataNotFoundError
|
|
||||||
from cic_ussd.redis import create_cached_data_key, get_cached_data
|
|
||||||
from cic_ussd.conversions import from_wei
|
|
||||||
|
|
||||||
logg = logging.getLogger()
|
|
||||||
|
|
||||||
|
|
||||||
def get_balances(
|
|
||||||
address: str,
|
|
||||||
chain_str: str,
|
|
||||||
token_symbol: str,
|
|
||||||
asynchronous: bool = False,
|
|
||||||
callback_param: any = None,
|
|
||||||
callback_task='cic_ussd.tasks.callback_handler.process_balances_callback') -> Union[celery.Task, dict]:
|
|
||||||
"""
|
|
||||||
This function queries cic-eth for an account's balances, It provides a means to receive the balance either
|
|
||||||
asynchronously or synchronously depending on the provided value for teh asynchronous parameter. It returns a
|
|
||||||
dictionary containing network, outgoing and incoming balances.
|
|
||||||
:param address: Ethereum address of the recipient
|
|
||||||
:type address: str, 0x-hex
|
|
||||||
:param chain_str: The chain name and network id.
|
|
||||||
:type chain_str: str
|
|
||||||
:param callback_param:
|
|
||||||
:type callback_param:
|
|
||||||
:param callback_task:
|
|
||||||
:type callback_task:
|
|
||||||
:param token_symbol: ERC20 token symbol of the account whose balance is being queried.
|
|
||||||
:type token_symbol: str
|
|
||||||
:param asynchronous: Boolean value checking whether to return balances asynchronously.
|
|
||||||
:type asynchronous: bool
|
|
||||||
:return:
|
|
||||||
:rtype:
|
|
||||||
"""
|
|
||||||
logg.debug(f'Retrieving balance for address: {address}')
|
|
||||||
if asynchronous:
|
|
||||||
cic_eth_api = Api(
|
|
||||||
chain_str=chain_str,
|
|
||||||
callback_queue='cic-ussd',
|
|
||||||
callback_task=callback_task,
|
|
||||||
callback_param=callback_param
|
|
||||||
)
|
|
||||||
cic_eth_api.balance(address=address, token_symbol=token_symbol)
|
|
||||||
else:
|
|
||||||
cic_eth_api = Api(chain_str=chain_str)
|
|
||||||
balance_request_task = cic_eth_api.balance(
|
|
||||||
address=address,
|
|
||||||
token_symbol=token_symbol)
|
|
||||||
return balance_request_task.get()[0]
|
|
||||||
|
|
||||||
|
|
||||||
def compute_operational_balance(balances: dict) -> float:
|
|
||||||
"""This function calculates the right balance given incoming and outgoing
|
|
||||||
:param balances:
|
|
||||||
:type balances:
|
|
||||||
:return:
|
|
||||||
:rtype:
|
|
||||||
"""
|
|
||||||
incoming_balance = balances.get('balance_incoming')
|
|
||||||
outgoing_balance = balances.get('balance_outgoing')
|
|
||||||
network_balance = balances.get('balance_network')
|
|
||||||
|
|
||||||
operational_balance = (network_balance + incoming_balance) - outgoing_balance
|
|
||||||
return from_wei(value=operational_balance)
|
|
||||||
|
|
||||||
|
|
||||||
def get_cached_operational_balance(blockchain_address: str):
|
|
||||||
"""
|
|
||||||
:param blockchain_address:
|
|
||||||
:type blockchain_address:
|
|
||||||
:return:
|
|
||||||
:rtype:
|
|
||||||
"""
|
|
||||||
key = create_cached_data_key(
|
|
||||||
identifier=bytes.fromhex(blockchain_address[2:]),
|
|
||||||
salt=':cic.balances_data'
|
|
||||||
)
|
|
||||||
cached_balance = get_cached_data(key=key)
|
|
||||||
if cached_balance:
|
|
||||||
operational_balance = compute_operational_balance(balances=json.loads(cached_balance))
|
|
||||||
return operational_balance
|
|
||||||
else:
|
|
||||||
raise CachedDataNotFoundError('Cached operational balance not found.')
|
|
@ -8,8 +8,8 @@ from redis import Redis
|
|||||||
logg = logging.getLogger()
|
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:
|
@ -1,41 +0,0 @@
|
|||||||
# standard imports
|
|
||||||
import decimal
|
|
||||||
|
|
||||||
# third-party imports
|
|
||||||
|
|
||||||
# local imports
|
|
||||||
|
|
||||||
|
|
||||||
def truncate(value: float, decimals: int):
|
|
||||||
"""This function truncates a value to a specified number of decimals places.
|
|
||||||
:param value: The value to be truncated.
|
|
||||||
:type value: float
|
|
||||||
:param decimals: The number of decimals for the value to be truncated to
|
|
||||||
:type decimals: int
|
|
||||||
:return: The truncated value.
|
|
||||||
:rtype: int
|
|
||||||
"""
|
|
||||||
decimal.getcontext().rounding = decimal.ROUND_DOWN
|
|
||||||
contextualized_value = decimal.Decimal(value)
|
|
||||||
return round(contextualized_value, decimals)
|
|
||||||
|
|
||||||
|
|
||||||
def from_wei(value: int) -> float:
|
|
||||||
"""This function converts values in Wei to a token in the cic network.
|
|
||||||
:param value: Value in Wei
|
|
||||||
:type value: int
|
|
||||||
:return: platform's default token equivalent of value in Wei
|
|
||||||
:rtype: float
|
|
||||||
"""
|
|
||||||
value = float(value) / 1e+6
|
|
||||||
return truncate(value=value, decimals=2)
|
|
||||||
|
|
||||||
|
|
||||||
def to_wei(value: int) -> int:
|
|
||||||
"""This functions converts values from a token in the cic network to Wei.
|
|
||||||
:param value: Value in platform's default token
|
|
||||||
:type value: int
|
|
||||||
:return: Wei equivalent of value in platform's default token
|
|
||||||
:rtype: int
|
|
||||||
"""
|
|
||||||
return int(value * 1e+6)
|
|
@ -26,7 +26,7 @@ def upgrade():
|
|||||||
sa.Column('msisdn', sa.String(), nullable=False),
|
sa.Column('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')
|
||||||
)
|
)
|
||||||
|
@ -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')
|
||||||
|
@ -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.
|
|
||||||
:return: The account status for a user object
|
|
||||||
:rtype: str
|
|
||||||
"""
|
"""
|
||||||
if self.failed_pin_attempts > 2:
|
:param chain_str:
|
||||||
self.account_status = AccountStatus.LOCKED.value
|
:type chain_str:
|
||||||
return AccountStatus(self.account_status).name
|
:param phone_number:
|
||||||
|
:type phone_number:
|
||||||
def activate_account(self):
|
:param session:
|
||||||
"""This method is used to reset failed pin attempts and change account status to Active."""
|
:type session:
|
||||||
self.failed_pin_attempts = 0
|
:return:
|
||||||
self.account_status = AccountStatus.ACTIVE.value
|
:rtype:
|
||||||
|
|
||||||
def has_valid_pin(self):
|
|
||||||
"""This method checks whether the user's account status and if a pin hash is present which implies
|
|
||||||
pin validity.
|
|
||||||
:return: The presence of a valid pin and status of the account being active.
|
|
||||||
:rtype: bool
|
|
||||||
"""
|
"""
|
||||||
valid_pin = None
|
api = Api(callback_task='cic_ussd.tasks.callback_handler.account_creation_callback',
|
||||||
if self.get_account_status() == 'ACTIVE' and self.password_hash is not None:
|
callback_queue='cic-ussd',
|
||||||
valid_pin = True
|
callback_param='',
|
||||||
return valid_pin
|
chain_str=chain_str)
|
||||||
|
task_uuid = api.create_account().id
|
||||||
|
TaskTracker.add(session=session, task_uuid=task_uuid)
|
||||||
|
cache_creation_task_uuid(phone_number=phone_number, task_uuid=task_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
def cache_creation_task_uuid(phone_number: str, task_uuid: str):
|
||||||
|
"""This function stores the task id that is returned from a task spawned to create a blockchain account in the redis
|
||||||
|
cache.
|
||||||
|
:param phone_number: The phone number for the user whose account is being created.
|
||||||
|
:type phone_number: str
|
||||||
|
:param task_uuid: A celery task id
|
||||||
|
:type task_uuid: str
|
||||||
|
"""
|
||||||
|
cache = Cache.store
|
||||||
|
account_creation_request_data = {
|
||||||
|
'phone_number': phone_number,
|
||||||
|
'sms_notification_sent': False,
|
||||||
|
'status': 'PENDING',
|
||||||
|
'task_uuid': task_uuid
|
||||||
|
}
|
||||||
|
cache.set(task_uuid, json.dumps(account_creation_request_data))
|
||||||
|
cache.persist(name=task_uuid)
|
||||||
|
@ -12,7 +12,7 @@ from sqlalchemy.pool import (
|
|||||||
QueuePool,
|
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.
|
||||||
@ -72,7 +69,7 @@ class SessionBase(Model):
|
|||||||
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,
|
||||||
@ -100,7 +97,6 @@ class SessionBase(Model):
|
|||||||
|
|
||||||
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))
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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."""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
0
apps/cic-ussd/cic_ussd/http/__init__.py
Normal file
0
apps/cic-ussd/cic_ussd/http/__init__.py
Normal file
65
apps/cic-ussd/cic_ussd/http/requests.py
Normal file
65
apps/cic-ussd/cic_ussd/http/requests.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
# standard imports
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Union
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
|
# external imports
|
||||||
|
import requests
|
||||||
|
from requests.exceptions import HTTPError
|
||||||
|
|
||||||
|
# local imports
|
||||||
|
from cic_ussd.error import UnsupportedMethodError
|
||||||
|
|
||||||
|
logg = logging.getLogger(__file__)
|
||||||
|
|
||||||
|
|
||||||
|
def error_handler(result: requests.Response):
|
||||||
|
""""""
|
||||||
|
status_code = result.status_code
|
||||||
|
|
||||||
|
if 100 <= status_code < 200:
|
||||||
|
raise HTTPError(f'Informational errors: {status_code}, reason: {result.reason}')
|
||||||
|
|
||||||
|
elif 300 <= status_code < 400:
|
||||||
|
raise HTTPError(f'Redirect Issues: {status_code}, reason: {result.reason}')
|
||||||
|
|
||||||
|
elif 400 <= status_code < 500:
|
||||||
|
raise HTTPError(f'Client Error: {status_code}, reason: {result.reason}')
|
||||||
|
|
||||||
|
elif 500 <= status_code < 600:
|
||||||
|
raise HTTPError(f'Server Error: {status_code}, reason: {result.reason}')
|
||||||
|
|
||||||
|
|
||||||
|
def get_query_parameters(env: dict, query_name: Optional[str] = None) -> Union[dict, str]:
|
||||||
|
""""""
|
||||||
|
parsed_url = urlparse(env.get('REQUEST_URI'))
|
||||||
|
params = parse_qs(parsed_url.query)
|
||||||
|
if query_name:
|
||||||
|
return params.get(query_name)[0]
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
def get_request_endpoint(env: dict) -> str:
|
||||||
|
""""""
|
||||||
|
return env.get('PATH_INFO')
|
||||||
|
|
||||||
|
|
||||||
|
def get_request_method(env: dict) -> str:
|
||||||
|
""""""
|
||||||
|
return env.get('REQUEST_METHOD').upper()
|
||||||
|
|
||||||
|
|
||||||
|
def make_request(method: str, url: str, data: any = None, headers: dict = None):
|
||||||
|
""""""
|
||||||
|
if method == 'GET':
|
||||||
|
logg.debug(f'Retrieving data from: {url}')
|
||||||
|
result = requests.get(url=url)
|
||||||
|
elif method == 'POST':
|
||||||
|
logg.debug(f'Posting to: {url} with: {data}')
|
||||||
|
result = requests.post(url=url, data=data, headers=headers)
|
||||||
|
elif method == 'PUT':
|
||||||
|
logg.debug(f'Putting to: {url} with: {data}')
|
||||||
|
result = requests.put(url=url, data=data, headers=headers)
|
||||||
|
else:
|
||||||
|
raise UnsupportedMethodError(f'Unsupported method: {method}')
|
||||||
|
return result
|
26
apps/cic-ussd/cic_ussd/http/responses.py
Normal file
26
apps/cic-ussd/cic_ussd/http/responses.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# standard imports
|
||||||
|
import logging
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
# external imports
|
||||||
|
|
||||||
|
# local imports
|
||||||
|
|
||||||
|
|
||||||
|
def with_content_headers(headers: list, response: str) -> Tuple[bytes, list]:
|
||||||
|
"""This function calculates the length of a http response body and appends the content length to the headers.
|
||||||
|
:param headers: A list of tuples defining headers for responses.
|
||||||
|
:type headers: list
|
||||||
|
:param response: The response to send for an incoming http request
|
||||||
|
:type response: str
|
||||||
|
:return: A tuple containing the response bytes and a list of tuples defining headers
|
||||||
|
:rtype: tuple
|
||||||
|
"""
|
||||||
|
response_bytes = response.encode('utf-8')
|
||||||
|
content_length = len(response_bytes)
|
||||||
|
content_length_header = ('Content-Length', str(content_length))
|
||||||
|
for position, header in enumerate(headers):
|
||||||
|
if 'Content-Length' in header:
|
||||||
|
headers.pop(position)
|
||||||
|
headers.append(content_length_header)
|
||||||
|
return response_bytes, headers
|
87
apps/cic-ussd/cic_ussd/http/routes.py
Normal file
87
apps/cic-ussd/cic_ussd/http/routes.py
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
# standard imports
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
|
# external imports
|
||||||
|
from sqlalchemy import desc
|
||||||
|
from sqlalchemy.orm.session import Session
|
||||||
|
|
||||||
|
# local imports
|
||||||
|
from cic_ussd.db.enum import AccountStatus
|
||||||
|
from cic_ussd.db.models.account import Account
|
||||||
|
from cic_ussd.db.models.base import SessionBase
|
||||||
|
from cic_ussd.http.requests import get_query_parameters, get_request_method
|
||||||
|
from cic_ussd.http.responses import with_content_headers
|
||||||
|
|
||||||
|
logg = logging.getLogger(__file__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_locked_accounts(env: dict, session: Session):
|
||||||
|
offset = 0
|
||||||
|
limit = 100
|
||||||
|
|
||||||
|
locked_accounts_path = r'/accounts/locked/(\d+)?/?(\d+)?'
|
||||||
|
r = re.match(locked_accounts_path, env.get('PATH_INFO'))
|
||||||
|
|
||||||
|
if r:
|
||||||
|
if r.lastindex > 1:
|
||||||
|
offset = r[1]
|
||||||
|
limit = r[2]
|
||||||
|
else:
|
||||||
|
limit = r[1]
|
||||||
|
session = SessionBase.bind_session(session)
|
||||||
|
accounts = session.query(Account.blockchain_address)\
|
||||||
|
.filter(Account.status == AccountStatus.LOCKED.value, Account.failed_pin_attempts >= 3)\
|
||||||
|
.order_by(desc(Account.updated))\
|
||||||
|
.offset(offset)\
|
||||||
|
.limit(limit)\
|
||||||
|
.all()
|
||||||
|
accounts = [blockchain_address for (blockchain_address,) in accounts]
|
||||||
|
SessionBase.release_session(session=session)
|
||||||
|
response = json.dumps(accounts)
|
||||||
|
return response, '200 OK'
|
||||||
|
|
||||||
|
|
||||||
|
def locked_accounts(env: dict, session: Session) -> tuple:
|
||||||
|
"""
|
||||||
|
:param env:
|
||||||
|
:type env:
|
||||||
|
:param session:
|
||||||
|
:type session:
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
if get_request_method(env) == 'GET':
|
||||||
|
return _get_locked_accounts(env, session)
|
||||||
|
return '', '405 Play by the rules'
|
||||||
|
|
||||||
|
|
||||||
|
def pin_reset(env: dict, phone_number: str, session: Session):
|
||||||
|
""""""
|
||||||
|
account = session.query(Account).filter_by(phone_number=phone_number).first()
|
||||||
|
if not account:
|
||||||
|
return '', '404 Not found'
|
||||||
|
|
||||||
|
if get_request_method(env) == 'PUT':
|
||||||
|
return account.reset_pin(session), '200 OK'
|
||||||
|
|
||||||
|
if get_request_method(env) == 'GET':
|
||||||
|
status = account.get_status(session)
|
||||||
|
response = {
|
||||||
|
'status': f'{status}'
|
||||||
|
}
|
||||||
|
response = json.dumps(response)
|
||||||
|
return response, '200 OK'
|
||||||
|
|
||||||
|
|
||||||
|
def handle_pin_requests(env, session, errors_headers, start_response):
|
||||||
|
phone_number = get_query_parameters(env=env, query_name='phoneNumber')
|
||||||
|
phone_number = quote_plus(phone_number)
|
||||||
|
response, message = pin_reset(env=env, phone_number=phone_number, session=session)
|
||||||
|
response_bytes, headers = with_content_headers(errors_headers, response)
|
||||||
|
session.commit()
|
||||||
|
session.close()
|
||||||
|
start_response(message, headers)
|
||||||
|
return [response_bytes]
|
@ -65,11 +65,10 @@ class UssdMenu:
|
|||||||
:rtype: Document.
|
: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:
|
||||||
|
return menu
|
||||||
logg.error("No USSD Menu with name {}".format(name))
|
logg.error("No USSD Menu with name {}".format(name))
|
||||||
return UssdMenu.ussd_menu_db.get(UssdMenu.Menu.name == 'exit_invalid_request')
|
return UssdMenu.ussd_menu_db.get(UssdMenu.Menu.name == 'exit_invalid_request')
|
||||||
else:
|
|
||||||
return menu
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def set_description(name: str, description: str):
|
def set_description(name: str, description: str):
|
||||||
|
@ -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))
|
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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)
|
||||||
|
@ -1,521 +0,0 @@
|
|||||||
# standard imports
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
|
|
||||||
# third party imports
|
|
||||||
import celery
|
|
||||||
import i18n
|
|
||||||
from cic_eth.api.api_task import Api
|
|
||||||
from sqlalchemy.orm.session import Session
|
|
||||||
from tinydb.table import Document
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
# local imports
|
|
||||||
from cic_ussd.db.models.account import Account
|
|
||||||
from cic_ussd.db.models.base import SessionBase
|
|
||||||
from cic_ussd.db.models.ussd_session import UssdSession
|
|
||||||
from cic_ussd.db.models.task_tracker import TaskTracker
|
|
||||||
from cic_ussd.menu.ussd_menu import UssdMenu
|
|
||||||
from cic_ussd.processor import custom_display_text, process_request, retrieve_most_recent_ussd_session
|
|
||||||
from cic_ussd.redis import InMemoryStore
|
|
||||||
from cic_ussd.session.ussd_session import UssdSession as InMemoryUssdSession
|
|
||||||
from cic_ussd.validator import check_known_user, validate_response_type
|
|
||||||
|
|
||||||
logg = logging.getLogger()
|
|
||||||
|
|
||||||
|
|
||||||
def add_tasks_to_tracker(session, task_uuid: str):
|
|
||||||
"""This function takes tasks spawned over api interfaces and records their creation time for tracking.
|
|
||||||
:param session:
|
|
||||||
:type session:
|
|
||||||
:param task_uuid: The uuid for an initiated task.
|
|
||||||
:type task_uuid: str
|
|
||||||
"""
|
|
||||||
session = SessionBase.bind_session(session=session)
|
|
||||||
task_record = TaskTracker(task_uuid=task_uuid)
|
|
||||||
session.add(task_record)
|
|
||||||
session.flush()
|
|
||||||
SessionBase.release_session(session=session)
|
|
||||||
|
|
||||||
|
|
||||||
def define_response_with_content(headers: list, response: str) -> tuple:
|
|
||||||
"""This function encodes responses to byte form in order to make feasible for uwsgi response formats. It then
|
|
||||||
computes the length of the response and appends the content length to the headers.
|
|
||||||
:param headers: A list of tuples defining headers for responses.
|
|
||||||
:type headers: list
|
|
||||||
:param response: The response to send for an incoming http request
|
|
||||||
:type response: str
|
|
||||||
:return: A tuple containing the response bytes and a list of tuples defining headers
|
|
||||||
:rtype: tuple
|
|
||||||
"""
|
|
||||||
response_bytes = response.encode('utf-8')
|
|
||||||
content_length = len(response_bytes)
|
|
||||||
content_length_header = ('Content-Length', str(content_length))
|
|
||||||
# check for content length defaulted to zero in error headers
|
|
||||||
for position, header in enumerate(headers):
|
|
||||||
if 'Content-Length' in header:
|
|
||||||
headers.pop(position)
|
|
||||||
headers.append(content_length_header)
|
|
||||||
return response_bytes, headers
|
|
||||||
|
|
||||||
|
|
||||||
def create_ussd_session(
|
|
||||||
external_session_id: str,
|
|
||||||
phone: str,
|
|
||||||
service_code: str,
|
|
||||||
user_input: str,
|
|
||||||
current_menu: str,
|
|
||||||
session_data: Optional[dict] = None) -> InMemoryUssdSession:
|
|
||||||
"""
|
|
||||||
Creates a new ussd session
|
|
||||||
:param external_session_id: Session id value provided by AT
|
|
||||||
:type external_session_id: str
|
|
||||||
:param phone: A valid phone number
|
|
||||||
:type phone: str
|
|
||||||
:param service_code: service code passed over request
|
|
||||||
:type service_code AT service code
|
|
||||||
:param user_input: Input from the request
|
|
||||||
:type user_input: str
|
|
||||||
:param current_menu: Menu name that is currently being displayed on the ussd session
|
|
||||||
:type current_menu: str
|
|
||||||
:param session_data: Any additional data that was persisted during the user's interaction with the system.
|
|
||||||
:type session_data: dict.
|
|
||||||
:return: ussd session object
|
|
||||||
:rtype: Session
|
|
||||||
"""
|
|
||||||
session = InMemoryUssdSession(
|
|
||||||
external_session_id=external_session_id,
|
|
||||||
msisdn=phone,
|
|
||||||
user_input=user_input,
|
|
||||||
state=current_menu,
|
|
||||||
service_code=service_code,
|
|
||||||
session_data=session_data
|
|
||||||
)
|
|
||||||
return session
|
|
||||||
|
|
||||||
|
|
||||||
def create_or_update_session(
|
|
||||||
external_session_id: str,
|
|
||||||
phone: str,
|
|
||||||
service_code: str,
|
|
||||||
user_input: str,
|
|
||||||
current_menu: str,
|
|
||||||
session,
|
|
||||||
session_data: Optional[dict] = None) -> InMemoryUssdSession:
|
|
||||||
"""
|
|
||||||
Handles the creation or updating of session as necessary.
|
|
||||||
:param external_session_id: Session id value provided by AT
|
|
||||||
:type external_session_id: str
|
|
||||||
:param phone: A valid phone number
|
|
||||||
:type phone: str
|
|
||||||
:param service_code: service code passed over request
|
|
||||||
:type service_code: AT service code
|
|
||||||
:param user_input: input from the request
|
|
||||||
:type user_input: str
|
|
||||||
:param current_menu: Menu name that is currently being displayed on the ussd session
|
|
||||||
:type current_menu: str
|
|
||||||
:param session:
|
|
||||||
:type session:
|
|
||||||
:param session_data: Any additional data that was persisted during the user's interaction with the system.
|
|
||||||
:type session_data: dict.
|
|
||||||
:return: ussd session object
|
|
||||||
:rtype: InMemoryUssdSession
|
|
||||||
"""
|
|
||||||
session = SessionBase.bind_session(session=session)
|
|
||||||
existing_ussd_session = session.query(UssdSession).filter_by(
|
|
||||||
external_session_id=external_session_id).first()
|
|
||||||
|
|
||||||
if existing_ussd_session:
|
|
||||||
ussd_session = update_ussd_session(
|
|
||||||
ussd_session=existing_ussd_session,
|
|
||||||
current_menu=current_menu,
|
|
||||||
user_input=user_input,
|
|
||||||
session_data=session_data
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
ussd_session = create_ussd_session(
|
|
||||||
external_session_id=external_session_id,
|
|
||||||
phone=phone,
|
|
||||||
service_code=service_code,
|
|
||||||
user_input=user_input,
|
|
||||||
current_menu=current_menu,
|
|
||||||
session_data=session_data
|
|
||||||
)
|
|
||||||
SessionBase.release_session(session=session)
|
|
||||||
return ussd_session
|
|
||||||
|
|
||||||
|
|
||||||
def get_account_status(phone_number, session: Session) -> str:
|
|
||||||
"""Get the status of a user's account.
|
|
||||||
:param phone_number: The phone number to be checked.
|
|
||||||
:type phone_number: str
|
|
||||||
:param session:
|
|
||||||
:type session:
|
|
||||||
:return: The user account status.
|
|
||||||
:rtype: str
|
|
||||||
"""
|
|
||||||
session = SessionBase.bind_session(session=session)
|
|
||||||
account = Account.get_by_phone_number(phone_number=phone_number, session=session)
|
|
||||||
status = account.get_account_status()
|
|
||||||
session.add(account)
|
|
||||||
session.flush()
|
|
||||||
SessionBase.release_session(session=session)
|
|
||||||
|
|
||||||
return status
|
|
||||||
|
|
||||||
|
|
||||||
def get_latest_input(user_input: str) -> str:
|
|
||||||
"""This function gets the last value entered by the user from the collective user input which follows the pattern of
|
|
||||||
asterix (*) separated entries.
|
|
||||||
:param user_input: The data entered by a user.
|
|
||||||
:type user_input: str
|
|
||||||
:return: The last element in the user input value.
|
|
||||||
:rtype: str
|
|
||||||
"""
|
|
||||||
return user_input.split('*')[-1]
|
|
||||||
|
|
||||||
|
|
||||||
def initiate_account_creation_request(chain_str: str,
|
|
||||||
external_session_id: str,
|
|
||||||
phone_number: str,
|
|
||||||
service_code: str,
|
|
||||||
session,
|
|
||||||
user_input: str) -> str:
|
|
||||||
"""This function issues a task to create a blockchain account on cic-eth. It then creates a record of the ussd
|
|
||||||
session corresponding to the creation of the account and returns a response denoting that the user's account is
|
|
||||||
being created.
|
|
||||||
:param chain_str: The chain name and network id.
|
|
||||||
:type chain_str: str
|
|
||||||
:param external_session_id: A unique ID from africastalking.
|
|
||||||
:type external_session_id: str
|
|
||||||
:param phone_number: The phone number for the account to be created.
|
|
||||||
:type phone_number: str
|
|
||||||
:param service_code: The service code dialed.
|
|
||||||
:type service_code: str
|
|
||||||
:param session:
|
|
||||||
:type session:
|
|
||||||
:param user_input: The input entered by the user.
|
|
||||||
:type user_input: str
|
|
||||||
:return: A response denoting that the account is being created.
|
|
||||||
:rtype: str
|
|
||||||
"""
|
|
||||||
# attempt to create a user
|
|
||||||
cic_eth_api = Api(callback_task='cic_ussd.tasks.callback_handler.process_account_creation_callback',
|
|
||||||
callback_queue='cic-ussd',
|
|
||||||
callback_param='',
|
|
||||||
chain_str=chain_str)
|
|
||||||
creation_task_id = cic_eth_api.create_account().id
|
|
||||||
|
|
||||||
# record task initiation time
|
|
||||||
add_tasks_to_tracker(task_uuid=creation_task_id, session=session)
|
|
||||||
|
|
||||||
# cache account creation data
|
|
||||||
cache_account_creation_task_id(phone_number=phone_number, task_id=creation_task_id)
|
|
||||||
|
|
||||||
# find menu to notify user account is being created
|
|
||||||
current_menu = UssdMenu.find_by_name(name='account_creation_prompt')
|
|
||||||
|
|
||||||
# create a ussd session session
|
|
||||||
create_or_update_session(
|
|
||||||
external_session_id=external_session_id,
|
|
||||||
phone=phone_number,
|
|
||||||
service_code=service_code,
|
|
||||||
current_menu=current_menu.get('name'),
|
|
||||||
session=session,
|
|
||||||
user_input=user_input)
|
|
||||||
|
|
||||||
# define response to relay to user
|
|
||||||
response = define_multilingual_responses(
|
|
||||||
key='ussd.kenya.account_creation_prompt', locales=['en', 'sw'], prefix='END')
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def define_multilingual_responses(key: str, locales: list, prefix: str, **kwargs):
|
|
||||||
"""This function returns responses in multiple languages in the interest of enabling responses in more than one
|
|
||||||
language.
|
|
||||||
:param key: The key to access some text value from the translation files.
|
|
||||||
:type key: str
|
|
||||||
:param locales: A list of the locales to translate the text value to.
|
|
||||||
:type locales: list
|
|
||||||
:param prefix: The prefix for the text value either: (CON|END)
|
|
||||||
:type prefix: str
|
|
||||||
:param kwargs: Other arguments to be passed to the translator
|
|
||||||
:type kwargs: kwargs
|
|
||||||
:return: A string of the text value in multiple languages.
|
|
||||||
:rtype: str
|
|
||||||
"""
|
|
||||||
prefix = prefix.upper()
|
|
||||||
response = f'{prefix} '
|
|
||||||
for locale in locales:
|
|
||||||
response += i18n.t(key=key, locale=locale, **kwargs)
|
|
||||||
response += '\n'
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def persist_session_to_db_task(external_session_id: str, queue: str):
|
|
||||||
"""
|
|
||||||
This function creates a signature matching the persist session to db task and runs the task asynchronously.
|
|
||||||
:param external_session_id: Session id value provided by AT
|
|
||||||
:type external_session_id: str
|
|
||||||
:param queue: Celery queue on which task should run
|
|
||||||
:type queue: str
|
|
||||||
"""
|
|
||||||
s_persist_session_to_db = celery.signature(
|
|
||||||
'cic_ussd.tasks.ussd_session.persist_session_to_db',
|
|
||||||
[external_session_id]
|
|
||||||
)
|
|
||||||
s_persist_session_to_db.apply_async(queue=queue)
|
|
||||||
|
|
||||||
|
|
||||||
def cache_account_creation_task_id(phone_number: str, task_id: str):
|
|
||||||
"""This function stores the task id that is returned from a task spawned to create a blockchain account in the redis
|
|
||||||
cache.
|
|
||||||
:param phone_number: The phone number for the user whose account is being created.
|
|
||||||
:type phone_number: str
|
|
||||||
:param task_id: A celery task id
|
|
||||||
:type task_id: str
|
|
||||||
"""
|
|
||||||
redis_cache = InMemoryStore.cache
|
|
||||||
account_creation_request_data = {
|
|
||||||
'phone_number': phone_number,
|
|
||||||
'sms_notification_sent': False,
|
|
||||||
'status': 'PENDING',
|
|
||||||
'task_id': task_id,
|
|
||||||
}
|
|
||||||
redis_cache.set(task_id, json.dumps(account_creation_request_data))
|
|
||||||
redis_cache.persist(name=task_id)
|
|
||||||
|
|
||||||
|
|
||||||
def process_current_menu(account: Account, session: Session, ussd_session: Optional[dict], user_input: str) -> Document:
|
|
||||||
"""This function checks user input and returns a corresponding ussd menu
|
|
||||||
:param ussd_session: An in db ussd session object.
|
|
||||||
:type ussd_session: UssdSession
|
|
||||||
:param account: A account object.
|
|
||||||
:type account: Account
|
|
||||||
:param session:
|
|
||||||
:type session:
|
|
||||||
:param user_input: The user's input.
|
|
||||||
:type user_input: str
|
|
||||||
:return: An in memory ussd menu object.
|
|
||||||
:rtype: Document
|
|
||||||
"""
|
|
||||||
# handle invalid inputs
|
|
||||||
if ussd_session and user_input == "":
|
|
||||||
current_menu = UssdMenu.find_by_name(name='exit_invalid_input')
|
|
||||||
else:
|
|
||||||
# get current state
|
|
||||||
latest_input = get_latest_input(user_input=user_input)
|
|
||||||
session = SessionBase.bind_session(session=session)
|
|
||||||
current_menu = process_request(
|
|
||||||
account=account,
|
|
||||||
session=session,
|
|
||||||
ussd_session=ussd_session,
|
|
||||||
user_input=latest_input)
|
|
||||||
SessionBase.release_session(session=session)
|
|
||||||
return current_menu
|
|
||||||
|
|
||||||
|
|
||||||
def process_menu_interaction_requests(chain_str: str,
|
|
||||||
external_session_id: str,
|
|
||||||
phone_number: str,
|
|
||||||
queue: str,
|
|
||||||
service_code: str,
|
|
||||||
session,
|
|
||||||
user_input: str) -> str:
|
|
||||||
"""This function handles requests intended for interaction with ussd menu, it checks whether a user matching the
|
|
||||||
provided phone number exists and in the absence of which it creates an account for the user.
|
|
||||||
In the event that a user exists it processes the request and returns an appropriate response.
|
|
||||||
:param chain_str: The chain name and network id.
|
|
||||||
:type chain_str: str
|
|
||||||
:param external_session_id: Unique session id from AfricasTalking
|
|
||||||
:type external_session_id: str
|
|
||||||
:param phone_number: Phone number of the user making the request.
|
|
||||||
:type phone_number: str
|
|
||||||
:param queue: The celery queue on which to run tasks
|
|
||||||
:type queue: str
|
|
||||||
:param service_code: The service dialed by the user making the request.
|
|
||||||
:type service_code: str
|
|
||||||
:param session:
|
|
||||||
:type session:
|
|
||||||
:param user_input: The inputs entered by the user.
|
|
||||||
:type user_input: str
|
|
||||||
:return: A response based on the request received.
|
|
||||||
:rtype: str
|
|
||||||
"""
|
|
||||||
# check whether the user exists
|
|
||||||
if not check_known_user(phone_number=phone_number, session=session):
|
|
||||||
response = initiate_account_creation_request(chain_str=chain_str,
|
|
||||||
external_session_id=external_session_id,
|
|
||||||
phone_number=phone_number,
|
|
||||||
service_code=service_code,
|
|
||||||
session=session,
|
|
||||||
user_input=user_input)
|
|
||||||
|
|
||||||
else:
|
|
||||||
# get account
|
|
||||||
session = SessionBase.bind_session(session=session)
|
|
||||||
account = Account.get_by_phone_number(phone_number=phone_number, session=session)
|
|
||||||
|
|
||||||
# retrieve and cache user's metadata
|
|
||||||
blockchain_address = account.blockchain_address
|
|
||||||
s_query_person_metadata = celery.signature(
|
|
||||||
'cic_ussd.tasks.metadata.query_person_metadata',
|
|
||||||
[blockchain_address]
|
|
||||||
)
|
|
||||||
s_query_person_metadata.apply_async(queue='cic-ussd')
|
|
||||||
|
|
||||||
# find any existing ussd session
|
|
||||||
existing_ussd_session = session.query(UssdSession).filter_by(external_session_id=external_session_id).first()
|
|
||||||
|
|
||||||
# validate user inputs
|
|
||||||
if existing_ussd_session:
|
|
||||||
current_menu = process_current_menu(
|
|
||||||
account=account,
|
|
||||||
session=session,
|
|
||||||
ussd_session=existing_ussd_session.to_json(),
|
|
||||||
user_input=user_input
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
current_menu = process_current_menu(
|
|
||||||
account=account,
|
|
||||||
session=session,
|
|
||||||
ussd_session=None,
|
|
||||||
user_input=user_input
|
|
||||||
)
|
|
||||||
|
|
||||||
last_ussd_session = retrieve_most_recent_ussd_session(phone_number=account.phone_number, session=session)
|
|
||||||
|
|
||||||
if last_ussd_session:
|
|
||||||
# create or update the ussd session as appropriate
|
|
||||||
ussd_session = create_or_update_session(
|
|
||||||
external_session_id=external_session_id,
|
|
||||||
phone=phone_number,
|
|
||||||
service_code=service_code,
|
|
||||||
user_input=user_input,
|
|
||||||
current_menu=current_menu.get('name'),
|
|
||||||
session=session,
|
|
||||||
session_data=last_ussd_session.session_data
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
ussd_session = create_or_update_session(
|
|
||||||
external_session_id=external_session_id,
|
|
||||||
phone=phone_number,
|
|
||||||
service_code=service_code,
|
|
||||||
user_input=user_input,
|
|
||||||
current_menu=current_menu.get('name'),
|
|
||||||
session=session
|
|
||||||
)
|
|
||||||
|
|
||||||
# define appropriate response
|
|
||||||
response = custom_display_text(
|
|
||||||
account=account,
|
|
||||||
display_key=current_menu.get('display_key'),
|
|
||||||
menu_name=current_menu.get('name'),
|
|
||||||
session=session,
|
|
||||||
ussd_session=ussd_session.to_json(),
|
|
||||||
)
|
|
||||||
|
|
||||||
# check that the response from the processor is valid
|
|
||||||
if not validate_response_type(processor_response=response):
|
|
||||||
raise Exception(f'Invalid response: {response}')
|
|
||||||
|
|
||||||
# persist session to db
|
|
||||||
persist_session_to_db_task(external_session_id=external_session_id, queue=queue)
|
|
||||||
SessionBase.release_session(session=session)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def reset_pin(phone_number: str, session: Session) -> str:
|
|
||||||
"""Reset account status from Locked to Pending.
|
|
||||||
:param phone_number: The phone number belonging to the account to be unlocked.
|
|
||||||
:type phone_number: str
|
|
||||||
:param session:
|
|
||||||
:type session:
|
|
||||||
:return: The status of the pin reset.
|
|
||||||
:rtype: str
|
|
||||||
"""
|
|
||||||
session = SessionBase.bind_session(session=session)
|
|
||||||
account = Account.get_by_phone_number(phone_number=phone_number, session=session)
|
|
||||||
account.reset_account_pin()
|
|
||||||
session.add(account)
|
|
||||||
session.flush()
|
|
||||||
SessionBase.release_session(session=session)
|
|
||||||
|
|
||||||
response = f'Pin reset for user {phone_number} is successful!'
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def update_ussd_session(
|
|
||||||
ussd_session: InMemoryUssdSession,
|
|
||||||
user_input: str,
|
|
||||||
current_menu: str,
|
|
||||||
session_data: Optional[dict] = None) -> InMemoryUssdSession:
|
|
||||||
"""
|
|
||||||
Updates a ussd session
|
|
||||||
:param ussd_session: Session id value provided by AT
|
|
||||||
:type ussd_session: InMemoryUssdSession
|
|
||||||
:param user_input: Input from the request
|
|
||||||
:type user_input: str
|
|
||||||
:param current_menu: Menu name that is currently being displayed on the ussd session
|
|
||||||
:type current_menu: str
|
|
||||||
:param session_data: Any additional data that was persisted during the user's interaction with the system.
|
|
||||||
:type session_data: dict.
|
|
||||||
:return: ussd session object
|
|
||||||
:rtype: InMemoryUssdSession
|
|
||||||
"""
|
|
||||||
if session_data is None:
|
|
||||||
session_data = ussd_session.session_data
|
|
||||||
|
|
||||||
session = InMemoryUssdSession(
|
|
||||||
external_session_id=ussd_session.external_session_id,
|
|
||||||
msisdn=ussd_session.msisdn,
|
|
||||||
user_input=user_input,
|
|
||||||
state=current_menu,
|
|
||||||
service_code=ussd_session.service_code,
|
|
||||||
session_data=session_data
|
|
||||||
)
|
|
||||||
return session
|
|
||||||
|
|
||||||
|
|
||||||
def save_to_in_memory_ussd_session_data(queue: str, session: Session, session_data: dict, ussd_session: dict):
|
|
||||||
"""This function is used to save information to the session data attribute of a ussd session object in the redis
|
|
||||||
cache.
|
|
||||||
:param queue: The queue on which the celery task should run.
|
|
||||||
:type queue: str
|
|
||||||
:param session:
|
|
||||||
:type session:
|
|
||||||
:param session_data: A dictionary containing data for a specific ussd session in redis that needs to be saved
|
|
||||||
temporarily.
|
|
||||||
:type session_data: dict
|
|
||||||
:param ussd_session: A ussd session passed to the state machine.
|
|
||||||
:type ussd_session: UssdSession
|
|
||||||
"""
|
|
||||||
# define redis cache entry point
|
|
||||||
cache = InMemoryStore.cache
|
|
||||||
|
|
||||||
# get external session id
|
|
||||||
external_session_id = ussd_session.get('external_session_id')
|
|
||||||
|
|
||||||
# check for existing session data
|
|
||||||
existing_session_data = ussd_session.get('session_data')
|
|
||||||
|
|
||||||
# merge old session data with new inputs to session data
|
|
||||||
if existing_session_data:
|
|
||||||
session_data = {**existing_session_data, **session_data}
|
|
||||||
|
|
||||||
# get corresponding session record
|
|
||||||
in_redis_ussd_session = cache.get(external_session_id)
|
|
||||||
in_redis_ussd_session = json.loads(in_redis_ussd_session)
|
|
||||||
|
|
||||||
# create new in memory ussd session with current ussd session data
|
|
||||||
create_or_update_session(
|
|
||||||
external_session_id=external_session_id,
|
|
||||||
phone=in_redis_ussd_session.get('msisdn'),
|
|
||||||
service_code=in_redis_ussd_session.get('service_code'),
|
|
||||||
user_input=in_redis_ussd_session.get('user_input'),
|
|
||||||
current_menu=in_redis_ussd_session.get('state'),
|
|
||||||
session=session,
|
|
||||||
session_data=session_data
|
|
||||||
)
|
|
||||||
persist_session_to_db_task(external_session_id=external_session_id, queue=queue)
|
|
@ -29,9 +29,10 @@ def process_phone_number(phone_number: str, region: str):
|
|||||||
pass
|
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
|
||||||
|
@ -1,562 +0,0 @@
|
|||||||
# standard imports
|
|
||||||
import datetime
|
|
||||||
import logging
|
|
||||||
import json
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
# third party imports
|
|
||||||
from sqlalchemy import desc
|
|
||||||
from cic_eth.api import Api
|
|
||||||
from sqlalchemy.orm.session import Session
|
|
||||||
from tinydb.table import Document
|
|
||||||
|
|
||||||
# local imports
|
|
||||||
from cic_ussd.account import define_account_tx_metadata, retrieve_account_statement
|
|
||||||
from cic_ussd.balance import compute_operational_balance, get_balances, get_cached_operational_balance
|
|
||||||
from cic_ussd.chain import Chain
|
|
||||||
from cic_ussd.db.models.account import Account
|
|
||||||
from cic_ussd.db.models.base import SessionBase
|
|
||||||
from cic_ussd.db.models.ussd_session import UssdSession
|
|
||||||
from cic_ussd.db.enum import AccountStatus
|
|
||||||
from cic_ussd.error import SeppukuError
|
|
||||||
from cic_ussd.menu.ussd_menu import UssdMenu
|
|
||||||
from cic_ussd.metadata import blockchain_address_to_metadata_pointer
|
|
||||||
from cic_ussd.phone_number import Support
|
|
||||||
from cic_ussd.redis import cache_data, create_cached_data_key, get_cached_data
|
|
||||||
from cic_ussd.state_machine import UssdStateMachine
|
|
||||||
from cic_ussd.conversions import to_wei, from_wei
|
|
||||||
from cic_ussd.translation import translation_for
|
|
||||||
from cic_types.models.person import generate_metadata_pointer, get_contact_data_from_vcard
|
|
||||||
|
|
||||||
logg = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def get_default_token_data():
|
|
||||||
chain_str = Chain.spec.__str__()
|
|
||||||
cic_eth_api = Api(chain_str=chain_str)
|
|
||||||
default_token_request_task = cic_eth_api.default_token()
|
|
||||||
default_token_data = default_token_request_task.get()
|
|
||||||
return default_token_data
|
|
||||||
|
|
||||||
|
|
||||||
def retrieve_token_symbol(chain_str: str = Chain.spec.__str__()):
|
|
||||||
"""
|
|
||||||
:param chain_str:
|
|
||||||
:type chain_str:
|
|
||||||
:return:
|
|
||||||
:rtype:
|
|
||||||
"""
|
|
||||||
cache_key = create_cached_data_key(
|
|
||||||
identifier=chain_str.encode('utf-8'),
|
|
||||||
salt=':cic.default_token_data'
|
|
||||||
)
|
|
||||||
cached_data = get_cached_data(key=cache_key)
|
|
||||||
if cached_data:
|
|
||||||
default_token_data = json.loads(cached_data)
|
|
||||||
return default_token_data.get('symbol')
|
|
||||||
else:
|
|
||||||
logg.warning('Cached default token data not found. Attempting retrieval from default token API')
|
|
||||||
default_token_data = get_default_token_data()
|
|
||||||
if default_token_data:
|
|
||||||
return default_token_data.get('symbol')
|
|
||||||
else:
|
|
||||||
raise SeppukuError(f'Could not retrieve default token for: {chain_str}')
|
|
||||||
|
|
||||||
|
|
||||||
def process_pin_authorization(account: Account, display_key: str, **kwargs) -> str:
|
|
||||||
"""This method provides translation for all ussd menu entries that follow the pin authorization pattern.
|
|
||||||
:param account: The account in a running USSD session.
|
|
||||||
:type account: Account
|
|
||||||
:param display_key: The path in the translation files defining an appropriate ussd response
|
|
||||||
:type display_key: str
|
|
||||||
:param kwargs: Any additional information required by the text values in the internationalization files.
|
|
||||||
:type kwargs
|
|
||||||
:return: A string value corresponding the ussd menu's text value.
|
|
||||||
:rtype: str
|
|
||||||
"""
|
|
||||||
remaining_attempts = 3
|
|
||||||
if account.failed_pin_attempts > 0:
|
|
||||||
return translation_for(
|
|
||||||
key=f'{display_key}.retry',
|
|
||||||
preferred_language=account.preferred_language,
|
|
||||||
remaining_attempts=(remaining_attempts - account.failed_pin_attempts)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return translation_for(
|
|
||||||
key=f'{display_key}.first',
|
|
||||||
preferred_language=account.preferred_language,
|
|
||||||
**kwargs
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def process_exit_insufficient_balance(account: Account, display_key: str, session: Session, ussd_session: dict):
|
|
||||||
"""This function processes the exit menu letting users their account balance is insufficient to perform a specific
|
|
||||||
transaction.
|
|
||||||
:param account: The account requesting access to the ussd menu.
|
|
||||||
:type account: Account
|
|
||||||
:param display_key: The path in the translation files defining an appropriate ussd response
|
|
||||||
:type display_key: str
|
|
||||||
:param session:
|
|
||||||
:type session:
|
|
||||||
:param ussd_session: A JSON serialized in-memory ussd session object
|
|
||||||
:type ussd_session: dict
|
|
||||||
:return: Corresponding translation text response
|
|
||||||
:rtype: str
|
|
||||||
"""
|
|
||||||
# get account balance
|
|
||||||
operational_balance = get_cached_operational_balance(blockchain_address=account.blockchain_address)
|
|
||||||
|
|
||||||
# compile response data
|
|
||||||
user_input = ussd_session.get('user_input').split('*')[-1]
|
|
||||||
transaction_amount = to_wei(value=int(user_input))
|
|
||||||
|
|
||||||
# get default data
|
|
||||||
token_symbol = retrieve_token_symbol()
|
|
||||||
|
|
||||||
recipient_phone_number = ussd_session.get('session_data').get('recipient_phone_number')
|
|
||||||
recipient = Account.get_by_phone_number(phone_number=recipient_phone_number, session=session)
|
|
||||||
|
|
||||||
tx_recipient_information = define_account_tx_metadata(user=recipient)
|
|
||||||
|
|
||||||
return translation_for(
|
|
||||||
key=display_key,
|
|
||||||
preferred_language=account.preferred_language,
|
|
||||||
amount=from_wei(transaction_amount),
|
|
||||||
token_symbol=token_symbol,
|
|
||||||
recipient_information=tx_recipient_information,
|
|
||||||
token_balance=operational_balance
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def process_exit_successful_transaction(account: Account, display_key: str, session: Session, ussd_session: dict):
|
|
||||||
"""This function processes the exit menu after a successful initiation for a transfer of tokens.
|
|
||||||
:param account: The account requesting access to the ussd menu.
|
|
||||||
:type account: Account
|
|
||||||
:param display_key: The path in the translation files defining an appropriate ussd response
|
|
||||||
:type display_key: str
|
|
||||||
:param session:
|
|
||||||
:type session:
|
|
||||||
:param ussd_session: A JSON serialized in-memory ussd session object
|
|
||||||
:type ussd_session: dict
|
|
||||||
:return: Corresponding translation text response
|
|
||||||
:rtype: str
|
|
||||||
"""
|
|
||||||
transaction_amount = to_wei(int(ussd_session.get('session_data').get('transaction_amount')))
|
|
||||||
token_symbol = retrieve_token_symbol()
|
|
||||||
recipient_phone_number = ussd_session.get('session_data').get('recipient_phone_number')
|
|
||||||
recipient = Account.get_by_phone_number(phone_number=recipient_phone_number, session=session)
|
|
||||||
tx_recipient_information = define_account_tx_metadata(user=recipient)
|
|
||||||
tx_sender_information = define_account_tx_metadata(user=account)
|
|
||||||
|
|
||||||
return translation_for(
|
|
||||||
key=display_key,
|
|
||||||
preferred_language=account.preferred_language,
|
|
||||||
transaction_amount=from_wei(transaction_amount),
|
|
||||||
token_symbol=token_symbol,
|
|
||||||
recipient_information=tx_recipient_information,
|
|
||||||
sender_information=tx_sender_information
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def process_transaction_pin_authorization(account: Account, display_key: str, session: Session, ussd_session: dict):
|
|
||||||
"""This function processes pin authorization where making a transaction is concerned. It constructs a
|
|
||||||
pre-transaction response menu that shows the details of the transaction.
|
|
||||||
:param account: The account requesting access to the ussd menu.
|
|
||||||
:type account: Account
|
|
||||||
:param display_key: The path in the translation files defining an appropriate ussd response
|
|
||||||
:type display_key: str
|
|
||||||
:param session:
|
|
||||||
:type session:
|
|
||||||
:param ussd_session: The USSD session determining what user data needs to be extracted and added to the menu's
|
|
||||||
text values.
|
|
||||||
:type ussd_session: UssdSession
|
|
||||||
:return: Corresponding translation text response
|
|
||||||
:rtype: str
|
|
||||||
"""
|
|
||||||
# compile response data
|
|
||||||
recipient_phone_number = ussd_session.get('session_data').get('recipient_phone_number')
|
|
||||||
recipient = Account.get_by_phone_number(phone_number=recipient_phone_number, session=session)
|
|
||||||
tx_recipient_information = define_account_tx_metadata(user=recipient)
|
|
||||||
tx_sender_information = define_account_tx_metadata(user=account)
|
|
||||||
|
|
||||||
token_symbol = retrieve_token_symbol()
|
|
||||||
user_input = ussd_session.get('session_data').get('transaction_amount')
|
|
||||||
transaction_amount = to_wei(value=int(user_input))
|
|
||||||
return process_pin_authorization(
|
|
||||||
account=account,
|
|
||||||
display_key=display_key,
|
|
||||||
recipient_information=tx_recipient_information,
|
|
||||||
transaction_amount=from_wei(transaction_amount),
|
|
||||||
token_symbol=token_symbol,
|
|
||||||
sender_information=tx_sender_information
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def process_account_balances(user: Account, display_key: str):
|
|
||||||
"""
|
|
||||||
:param user:
|
|
||||||
:type user:
|
|
||||||
:param display_key:
|
|
||||||
:type display_key:
|
|
||||||
:return:
|
|
||||||
:rtype:
|
|
||||||
"""
|
|
||||||
# retrieve cached balance
|
|
||||||
operational_balance = get_cached_operational_balance(blockchain_address=user.blockchain_address)
|
|
||||||
|
|
||||||
logg.debug('Requires call to retrieve tax and bonus amounts')
|
|
||||||
tax = ''
|
|
||||||
bonus = ''
|
|
||||||
token_symbol = retrieve_token_symbol()
|
|
||||||
return translation_for(
|
|
||||||
key=display_key,
|
|
||||||
preferred_language=user.preferred_language,
|
|
||||||
operational_balance=operational_balance,
|
|
||||||
tax=tax,
|
|
||||||
bonus=bonus,
|
|
||||||
token_symbol=token_symbol
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def format_transactions(transactions: list, preferred_language: str, token_symbol: str):
|
|
||||||
|
|
||||||
formatted_transactions = ''
|
|
||||||
if len(transactions) > 0:
|
|
||||||
for transaction in transactions:
|
|
||||||
recipient_phone_number = transaction.get('recipient_phone_number')
|
|
||||||
sender_phone_number = transaction.get('sender_phone_number')
|
|
||||||
value = transaction.get('to_value')
|
|
||||||
timestamp = transaction.get('timestamp')
|
|
||||||
action_tag = transaction.get('action_tag')
|
|
||||||
direction = transaction.get('direction')
|
|
||||||
token_symbol = token_symbol
|
|
||||||
|
|
||||||
if action_tag == 'SENT' or action_tag == 'ULITUMA':
|
|
||||||
formatted_transactions += f'{action_tag} {value} {token_symbol} {direction} {recipient_phone_number} {timestamp}.\n'
|
|
||||||
else:
|
|
||||||
formatted_transactions += f'{action_tag} {value} {token_symbol} {direction} {sender_phone_number} {timestamp}. \n'
|
|
||||||
return formatted_transactions
|
|
||||||
else:
|
|
||||||
if preferred_language == 'en':
|
|
||||||
formatted_transactions = 'NO TRANSACTION HISTORY'
|
|
||||||
else:
|
|
||||||
formatted_transactions = 'HAMNA RIPOTI YA MATUMIZI'
|
|
||||||
return formatted_transactions
|
|
||||||
|
|
||||||
|
|
||||||
def process_display_user_metadata(user: Account, display_key: str):
|
|
||||||
"""
|
|
||||||
:param user:
|
|
||||||
:type user:
|
|
||||||
:param display_key:
|
|
||||||
:type display_key:
|
|
||||||
"""
|
|
||||||
key = generate_metadata_pointer(
|
|
||||||
identifier=blockchain_address_to_metadata_pointer(blockchain_address=user.blockchain_address),
|
|
||||||
cic_type=':cic.person'
|
|
||||||
)
|
|
||||||
cached_metadata = get_cached_data(key)
|
|
||||||
if cached_metadata:
|
|
||||||
user_metadata = json.loads(cached_metadata)
|
|
||||||
contact_data = get_contact_data_from_vcard(vcard=user_metadata.get('vcard'))
|
|
||||||
logg.debug(f'{contact_data}')
|
|
||||||
full_name = f'{contact_data.get("given")} {contact_data.get("family")}'
|
|
||||||
date_of_birth = user_metadata.get('date_of_birth')
|
|
||||||
year_of_birth = date_of_birth.get('year')
|
|
||||||
present_year = datetime.datetime.now().year
|
|
||||||
age = present_year - year_of_birth
|
|
||||||
gender = user_metadata.get('gender')
|
|
||||||
products = ', '.join(user_metadata.get('products'))
|
|
||||||
location = user_metadata.get('location').get('area_name')
|
|
||||||
|
|
||||||
return translation_for(
|
|
||||||
key=display_key,
|
|
||||||
preferred_language=user.preferred_language,
|
|
||||||
full_name=full_name,
|
|
||||||
age=age,
|
|
||||||
gender=gender,
|
|
||||||
location=location,
|
|
||||||
products=products
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# TODO [Philip]: All these translations could be moved to translation files.
|
|
||||||
logg.warning(f'Expected person metadata but found none in cache for key: {key}')
|
|
||||||
|
|
||||||
absent = ''
|
|
||||||
if user.preferred_language == 'en':
|
|
||||||
absent = 'Not provided'
|
|
||||||
elif user.preferred_language == 'sw':
|
|
||||||
absent = 'Haijawekwa'
|
|
||||||
|
|
||||||
return translation_for(
|
|
||||||
key=display_key,
|
|
||||||
preferred_language=user.preferred_language,
|
|
||||||
full_name=absent,
|
|
||||||
gender=absent,
|
|
||||||
age=absent,
|
|
||||||
location=absent,
|
|
||||||
products=absent
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def process_account_statement(user: Account, display_key: str):
|
|
||||||
"""
|
|
||||||
:param user:
|
|
||||||
:type user:
|
|
||||||
:param display_key:
|
|
||||||
:type display_key:
|
|
||||||
:return:
|
|
||||||
:rtype:
|
|
||||||
"""
|
|
||||||
# retrieve cached statement
|
|
||||||
identifier = blockchain_address_to_metadata_pointer(blockchain_address=user.blockchain_address)
|
|
||||||
key = create_cached_data_key(identifier=identifier, salt=':cic.statement')
|
|
||||||
transactions = get_cached_data(key=key)
|
|
||||||
|
|
||||||
token_symbol = retrieve_token_symbol()
|
|
||||||
|
|
||||||
first_transaction_set = []
|
|
||||||
middle_transaction_set = []
|
|
||||||
last_transaction_set = []
|
|
||||||
|
|
||||||
transactions = json.loads(transactions)
|
|
||||||
|
|
||||||
if len(transactions) > 6:
|
|
||||||
last_transaction_set += transactions[6:]
|
|
||||||
middle_transaction_set += transactions[3:][:3]
|
|
||||||
first_transaction_set += transactions[:3]
|
|
||||||
# there are probably much cleaner and operational inexpensive ways to do this so find them
|
|
||||||
elif 3 < len(transactions) < 7:
|
|
||||||
middle_transaction_set += transactions[3:]
|
|
||||||
first_transaction_set += transactions[:3]
|
|
||||||
else:
|
|
||||||
first_transaction_set += transactions[:3]
|
|
||||||
|
|
||||||
if display_key == 'ussd.kenya.first_transaction_set':
|
|
||||||
return translation_for(
|
|
||||||
key=display_key,
|
|
||||||
preferred_language=user.preferred_language,
|
|
||||||
first_transaction_set=format_transactions(
|
|
||||||
transactions=first_transaction_set,
|
|
||||||
preferred_language=user.preferred_language,
|
|
||||||
token_symbol=token_symbol
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif display_key == 'ussd.kenya.middle_transaction_set':
|
|
||||||
return translation_for(
|
|
||||||
key=display_key,
|
|
||||||
preferred_language=user.preferred_language,
|
|
||||||
middle_transaction_set=format_transactions(
|
|
||||||
transactions=middle_transaction_set,
|
|
||||||
preferred_language=user.preferred_language,
|
|
||||||
token_symbol=token_symbol
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
elif display_key == 'ussd.kenya.last_transaction_set':
|
|
||||||
return translation_for(
|
|
||||||
key=display_key,
|
|
||||||
preferred_language=user.preferred_language,
|
|
||||||
last_transaction_set=format_transactions(
|
|
||||||
transactions=last_transaction_set,
|
|
||||||
preferred_language=user.preferred_language,
|
|
||||||
token_symbol=token_symbol
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def process_start_menu(display_key: str, user: Account):
|
|
||||||
"""This function gets data on an account's balance and token in order to append it to the start of the start menu's
|
|
||||||
title. It passes said arguments to the translation function and returns the appropriate corresponding text from the
|
|
||||||
translation files.
|
|
||||||
:param user: The user requesting access to the ussd menu.
|
|
||||||
:type user: Account
|
|
||||||
:param display_key: The path in the translation files defining an appropriate ussd response
|
|
||||||
:type display_key: str
|
|
||||||
:return: Corresponding translation text response
|
|
||||||
:rtype: str
|
|
||||||
"""
|
|
||||||
token_symbol = retrieve_token_symbol()
|
|
||||||
chain_str = Chain.spec.__str__()
|
|
||||||
blockchain_address = user.blockchain_address
|
|
||||||
|
|
||||||
# get balances synchronously for display on start menu
|
|
||||||
balances_data = get_balances(address=blockchain_address, chain_str=chain_str, token_symbol=token_symbol)
|
|
||||||
|
|
||||||
key = create_cached_data_key(
|
|
||||||
identifier=bytes.fromhex(blockchain_address[2:]),
|
|
||||||
salt=':cic.balances_data'
|
|
||||||
)
|
|
||||||
cache_data(key=key, data=json.dumps(balances_data))
|
|
||||||
|
|
||||||
# get operational balance
|
|
||||||
operational_balance = compute_operational_balance(balances=balances_data)
|
|
||||||
|
|
||||||
# retrieve and cache account's statement
|
|
||||||
retrieve_account_statement(blockchain_address=blockchain_address)
|
|
||||||
|
|
||||||
return translation_for(
|
|
||||||
key=display_key,
|
|
||||||
preferred_language=user.preferred_language,
|
|
||||||
account_balance=operational_balance,
|
|
||||||
account_token_name=token_symbol
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def retrieve_most_recent_ussd_session(phone_number: str, session: Session) -> UssdSession:
|
|
||||||
# get last ussd session based on user phone number
|
|
||||||
session = SessionBase.bind_session(session=session)
|
|
||||||
last_ussd_session = session.query(UssdSession)\
|
|
||||||
.filter_by(msisdn=phone_number)\
|
|
||||||
.order_by(desc(UssdSession.created))\
|
|
||||||
.first()
|
|
||||||
SessionBase.release_session(session=session)
|
|
||||||
return last_ussd_session
|
|
||||||
|
|
||||||
|
|
||||||
def process_request(account: Account, session, user_input: str, ussd_session: Optional[dict] = None) -> Document:
|
|
||||||
"""This function assesses a request based on the user from the request comes, the session_id and the user's
|
|
||||||
input. It determines whether the request translates to a return to an existing session by checking whether the
|
|
||||||
provided session id exists in the database or whether the creation of a new ussd session object is warranted.
|
|
||||||
It then returns the appropriate ussd menu text values.
|
|
||||||
:param account: The account requesting access to the ussd menu.
|
|
||||||
:type account: Account
|
|
||||||
:param session:
|
|
||||||
:type session:
|
|
||||||
:param user_input: The value a user enters in the ussd menu.
|
|
||||||
:type user_input: str
|
|
||||||
:param ussd_session: A JSON serialized in-memory ussd session object
|
|
||||||
:type ussd_session: dict
|
|
||||||
:return: A ussd menu's corresponding text value.
|
|
||||||
:rtype: Document
|
|
||||||
"""
|
|
||||||
|
|
||||||
if ussd_session:
|
|
||||||
if user_input == "0":
|
|
||||||
return UssdMenu.parent_menu(menu_name=ussd_session.get('state'))
|
|
||||||
else:
|
|
||||||
successive_state = next_state(
|
|
||||||
account=account,
|
|
||||||
session=session,
|
|
||||||
ussd_session=ussd_session,
|
|
||||||
user_input=user_input)
|
|
||||||
return UssdMenu.find_by_name(name=successive_state)
|
|
||||||
else:
|
|
||||||
if account.has_valid_pin():
|
|
||||||
last_ussd_session = retrieve_most_recent_ussd_session(phone_number=account.phone_number, session=session)
|
|
||||||
|
|
||||||
if last_ussd_session:
|
|
||||||
# get last state
|
|
||||||
last_state = last_ussd_session.state
|
|
||||||
# if last state is account_creation_prompt and metadata exists, show start menu
|
|
||||||
if last_state in [
|
|
||||||
'account_creation_prompt',
|
|
||||||
'exit',
|
|
||||||
'exit_invalid_pin',
|
|
||||||
'exit_invalid_new_pin',
|
|
||||||
'exit_pin_mismatch',
|
|
||||||
'exit_invalid_request',
|
|
||||||
'exit_successful_transaction'
|
|
||||||
]:
|
|
||||||
return UssdMenu.find_by_name(name='start')
|
|
||||||
else:
|
|
||||||
return UssdMenu.find_by_name(name=last_state)
|
|
||||||
else:
|
|
||||||
if account.failed_pin_attempts >= 3 and account.get_account_status() == AccountStatus.LOCKED.name:
|
|
||||||
return UssdMenu.find_by_name(name='exit_pin_blocked')
|
|
||||||
elif account.preferred_language is None:
|
|
||||||
return UssdMenu.find_by_name(name='initial_language_selection')
|
|
||||||
else:
|
|
||||||
return UssdMenu.find_by_name(name='initial_pin_entry')
|
|
||||||
|
|
||||||
|
|
||||||
def next_state(account: Account, session, ussd_session: dict, user_input: str) -> str:
|
|
||||||
"""This function navigates the state machine based on the ussd session object and user inputs it receives.
|
|
||||||
It checks the user input and provides the successive state in the state machine. It then updates the session's
|
|
||||||
state attribute with the new state.
|
|
||||||
:param account: The account requesting access to the ussd menu.
|
|
||||||
:type account: Account
|
|
||||||
:param session:
|
|
||||||
:type session:
|
|
||||||
:param ussd_session: A JSON serialized in-memory ussd session object
|
|
||||||
:type ussd_session: dict
|
|
||||||
:param user_input: The value a user enters in the ussd menu.
|
|
||||||
:type user_input: str
|
|
||||||
:return: A string value corresponding the successive give a specific state in the state machine.
|
|
||||||
"""
|
|
||||||
state_machine = UssdStateMachine(ussd_session=ussd_session)
|
|
||||||
state_machine.scan_data((user_input, ussd_session, account, session))
|
|
||||||
new_state = state_machine.state
|
|
||||||
|
|
||||||
return new_state
|
|
||||||
|
|
||||||
|
|
||||||
def process_exit_invalid_menu_option(display_key: str, preferred_language: str):
|
|
||||||
return translation_for(
|
|
||||||
key=display_key,
|
|
||||||
preferred_language=preferred_language,
|
|
||||||
support_phone=Support.phone_number
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def custom_display_text(
|
|
||||||
account: Account,
|
|
||||||
display_key: str,
|
|
||||||
menu_name: str,
|
|
||||||
session: Session,
|
|
||||||
ussd_session: dict) -> str:
|
|
||||||
"""This function extracts the appropriate session data based on the current menu name. It then inserts them as
|
|
||||||
keywords in the i18n function.
|
|
||||||
:param account: The account in a running USSD session.
|
|
||||||
:type account: Account
|
|
||||||
:param display_key: The path in the translation files defining an appropriate ussd response
|
|
||||||
:type display_key: str
|
|
||||||
:param menu_name: The name by which a specific menu can be identified.
|
|
||||||
:type menu_name: str
|
|
||||||
:param session:
|
|
||||||
:type session:
|
|
||||||
:param ussd_session: A JSON serialized in-memory ussd session object
|
|
||||||
:type ussd_session: dict
|
|
||||||
:return: A string value corresponding the ussd menu's text value.
|
|
||||||
:rtype: str
|
|
||||||
"""
|
|
||||||
if menu_name == 'transaction_pin_authorization':
|
|
||||||
return process_transaction_pin_authorization(
|
|
||||||
account=account,
|
|
||||||
display_key=display_key,
|
|
||||||
session=session,
|
|
||||||
ussd_session=ussd_session)
|
|
||||||
elif menu_name == 'exit_insufficient_balance':
|
|
||||||
return process_exit_insufficient_balance(
|
|
||||||
account=account,
|
|
||||||
display_key=display_key,
|
|
||||||
session=session,
|
|
||||||
ussd_session=ussd_session)
|
|
||||||
elif menu_name == 'exit_successful_transaction':
|
|
||||||
return process_exit_successful_transaction(
|
|
||||||
account=account,
|
|
||||||
display_key=display_key,
|
|
||||||
session=session,
|
|
||||||
ussd_session=ussd_session)
|
|
||||||
elif menu_name == 'start':
|
|
||||||
return process_start_menu(display_key=display_key, user=account)
|
|
||||||
elif 'pin_authorization' in menu_name:
|
|
||||||
return process_pin_authorization(
|
|
||||||
account=account,
|
|
||||||
display_key=display_key,
|
|
||||||
session=session)
|
|
||||||
elif 'enter_current_pin' in menu_name:
|
|
||||||
return process_pin_authorization(
|
|
||||||
account=account,
|
|
||||||
display_key=display_key,
|
|
||||||
session=session)
|
|
||||||
elif menu_name == 'account_balances':
|
|
||||||
return process_account_balances(display_key=display_key, user=account)
|
|
||||||
elif 'transaction_set' in menu_name:
|
|
||||||
return process_account_statement(display_key=display_key, user=account)
|
|
||||||
elif menu_name == 'display_user_metadata':
|
|
||||||
return process_display_user_metadata(display_key=display_key, user=account)
|
|
||||||
elif menu_name == 'exit_invalid_menu_option':
|
|
||||||
return process_exit_invalid_menu_option(display_key=display_key, preferred_language=account.preferred_language)
|
|
||||||
else:
|
|
||||||
return translation_for(key=display_key, preferred_language=account.preferred_language)
|
|
0
apps/cic-ussd/cic_ussd/processor/__init__.py
Normal file
0
apps/cic-ussd/cic_ussd/processor/__init__.py
Normal file
305
apps/cic-ussd/cic_ussd/processor/menu.py
Normal file
305
apps/cic-ussd/cic_ussd/processor/menu.py
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
# standard imports
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# external imports
|
||||||
|
import i18n.config
|
||||||
|
from sqlalchemy.orm.session import Session
|
||||||
|
|
||||||
|
# local imports
|
||||||
|
from cic_ussd.account.balance import calculate_available_balance, get_balances, get_cached_available_balance
|
||||||
|
from cic_ussd.account.chain import Chain
|
||||||
|
from cic_ussd.account.metadata import get_cached_preferred_language
|
||||||
|
from cic_ussd.account.statement import (
|
||||||
|
get_cached_statement,
|
||||||
|
parse_statement_transactions,
|
||||||
|
query_statement,
|
||||||
|
statement_transaction_set
|
||||||
|
)
|
||||||
|
from cic_ussd.account.transaction import from_wei, to_wei
|
||||||
|
from cic_ussd.account.tokens import get_default_token_symbol
|
||||||
|
from cic_ussd.cache import cache_data_key, cache_data
|
||||||
|
from cic_ussd.db.models.account import Account
|
||||||
|
from cic_ussd.metadata import PersonMetadata
|
||||||
|
from cic_ussd.phone_number import Support
|
||||||
|
from cic_ussd.processor.util import latest_input, parse_person_metadata
|
||||||
|
from cic_ussd.translation import translation_for
|
||||||
|
|
||||||
|
logg = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MenuProcessor:
|
||||||
|
def __init__(self, account: Account, display_key: str, menu_name: str, session: Session, ussd_session: dict):
|
||||||
|
self.account = account
|
||||||
|
self.display_key = display_key
|
||||||
|
self.identifier = bytes.fromhex(self.account.blockchain_address[2:])
|
||||||
|
self.menu_name = menu_name
|
||||||
|
self.session = session
|
||||||
|
self.ussd_session = ussd_session
|
||||||
|
|
||||||
|
def account_balances(self) -> str:
|
||||||
|
"""
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
available_balance = get_cached_available_balance(self.account.blockchain_address)
|
||||||
|
logg.debug('Requires call to retrieve tax and bonus amounts')
|
||||||
|
tax = ''
|
||||||
|
bonus = ''
|
||||||
|
token_symbol = get_default_token_symbol()
|
||||||
|
preferred_language = get_cached_preferred_language(self.account.blockchain_address)
|
||||||
|
if not preferred_language:
|
||||||
|
preferred_language = i18n.config.get('fallback')
|
||||||
|
return translation_for(
|
||||||
|
key=self.display_key,
|
||||||
|
preferred_language=preferred_language,
|
||||||
|
available_balance=available_balance,
|
||||||
|
tax=tax,
|
||||||
|
bonus=bonus,
|
||||||
|
token_symbol=token_symbol
|
||||||
|
)
|
||||||
|
|
||||||
|
def account_statement(self) -> str:
|
||||||
|
"""
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
cached_statement = get_cached_statement(self.account.blockchain_address)
|
||||||
|
statement = json.loads(cached_statement)
|
||||||
|
statement_transactions = parse_statement_transactions(statement)
|
||||||
|
transaction_sets = [statement_transactions[tx:tx+3] for tx in range(0, len(statement_transactions), 3)]
|
||||||
|
preferred_language = get_cached_preferred_language(self.account.blockchain_address)
|
||||||
|
if not preferred_language:
|
||||||
|
preferred_language = i18n.config.get('fallback')
|
||||||
|
first_transaction_set = []
|
||||||
|
middle_transaction_set = []
|
||||||
|
last_transaction_set = []
|
||||||
|
if transaction_sets:
|
||||||
|
first_transaction_set = statement_transaction_set(preferred_language, transaction_sets[0])
|
||||||
|
if len(transaction_sets) >= 2:
|
||||||
|
middle_transaction_set = statement_transaction_set(preferred_language, transaction_sets[1])
|
||||||
|
if len(transaction_sets) >= 3:
|
||||||
|
last_transaction_set = statement_transaction_set(preferred_language, transaction_sets[2])
|
||||||
|
if self.display_key == 'ussd.kenya.first_transaction_set':
|
||||||
|
return translation_for(
|
||||||
|
self.display_key, preferred_language, first_transaction_set=first_transaction_set
|
||||||
|
)
|
||||||
|
if self.display_key == 'ussd.kenya.middle_transaction_set':
|
||||||
|
return translation_for(
|
||||||
|
self.display_key, preferred_language, middle_transaction_set=middle_transaction_set
|
||||||
|
)
|
||||||
|
if self.display_key == 'ussd.kenya.last_transaction_set':
|
||||||
|
return translation_for(
|
||||||
|
self.display_key, preferred_language, last_transaction_set=last_transaction_set
|
||||||
|
)
|
||||||
|
|
||||||
|
def help(self):
|
||||||
|
preferred_language = get_cached_preferred_language(self.account.blockchain_address)
|
||||||
|
if not preferred_language:
|
||||||
|
preferred_language = i18n.config.get('fallback')
|
||||||
|
return translation_for(self.display_key, preferred_language, support_phone=Support.phone_number)
|
||||||
|
|
||||||
|
def person_metadata(self) -> str:
|
||||||
|
"""
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
person_metadata = PersonMetadata(self.identifier)
|
||||||
|
cached_person_metadata = person_metadata.get_cached_metadata()
|
||||||
|
preferred_language = get_cached_preferred_language(self.account.blockchain_address)
|
||||||
|
if not preferred_language:
|
||||||
|
preferred_language = i18n.config.get('fallback')
|
||||||
|
if cached_person_metadata:
|
||||||
|
return parse_person_metadata(cached_person_metadata, self.display_key, preferred_language)
|
||||||
|
absent = translation_for('helpers.not_provided', preferred_language)
|
||||||
|
return translation_for(
|
||||||
|
self.display_key,
|
||||||
|
preferred_language,
|
||||||
|
full_name=absent,
|
||||||
|
gender=absent,
|
||||||
|
age=absent,
|
||||||
|
location=absent,
|
||||||
|
products=absent
|
||||||
|
)
|
||||||
|
|
||||||
|
def pin_authorization(self, **kwargs) -> str:
|
||||||
|
"""
|
||||||
|
:param kwargs:
|
||||||
|
:type kwargs:
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
preferred_language = get_cached_preferred_language(self.account.blockchain_address)
|
||||||
|
if not preferred_language:
|
||||||
|
preferred_language = i18n.config.get('fallback')
|
||||||
|
if self.account.failed_pin_attempts == 0:
|
||||||
|
return translation_for(f'{self.display_key}.first', preferred_language, **kwargs)
|
||||||
|
|
||||||
|
remaining_attempts = 3
|
||||||
|
remaining_attempts -= self.account.failed_pin_attempts
|
||||||
|
retry_pin_entry = translation_for(
|
||||||
|
'ussd.kenya.retry_pin_entry', preferred_language, remaining_attempts=remaining_attempts
|
||||||
|
)
|
||||||
|
return translation_for(
|
||||||
|
f'{self.display_key}.retry', preferred_language, retry_pin_entry=retry_pin_entry
|
||||||
|
)
|
||||||
|
|
||||||
|
def start_menu(self):
|
||||||
|
"""
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
token_symbol = get_default_token_symbol()
|
||||||
|
blockchain_address = self.account.blockchain_address
|
||||||
|
balances = get_balances(blockchain_address, Chain.spec.__str__(), token_symbol, False)[0]
|
||||||
|
key = cache_data_key(self.identifier, ':cic.balances')
|
||||||
|
cache_data(key, json.dumps(balances))
|
||||||
|
available_balance = calculate_available_balance(balances)
|
||||||
|
|
||||||
|
query_statement(blockchain_address)
|
||||||
|
|
||||||
|
preferred_language = get_cached_preferred_language(self.account.blockchain_address)
|
||||||
|
if not preferred_language:
|
||||||
|
preferred_language = i18n.config.get('fallback')
|
||||||
|
return translation_for(
|
||||||
|
self.display_key, preferred_language, account_balance=available_balance, account_token_name=token_symbol
|
||||||
|
)
|
||||||
|
|
||||||
|
def transaction_pin_authorization(self) -> str:
|
||||||
|
"""
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
recipient_phone_number = self.ussd_session.get('data').get('recipient_phone_number')
|
||||||
|
recipient = Account.get_by_phone_number(recipient_phone_number, self.session)
|
||||||
|
tx_recipient_information = recipient.standard_metadata_id()
|
||||||
|
tx_sender_information = self.account.standard_metadata_id()
|
||||||
|
token_symbol = get_default_token_symbol()
|
||||||
|
user_input = self.ussd_session.get('data').get('transaction_amount')
|
||||||
|
transaction_amount = to_wei(value=int(user_input))
|
||||||
|
return self.pin_authorization(
|
||||||
|
recipient_information=tx_recipient_information,
|
||||||
|
transaction_amount=from_wei(transaction_amount),
|
||||||
|
token_symbol=token_symbol,
|
||||||
|
sender_information=tx_sender_information
|
||||||
|
)
|
||||||
|
|
||||||
|
def exit_insufficient_balance(self):
|
||||||
|
"""
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
available_balance = get_cached_available_balance(self.account.blockchain_address)
|
||||||
|
preferred_language = get_cached_preferred_language(self.account.blockchain_address)
|
||||||
|
if not preferred_language:
|
||||||
|
preferred_language = i18n.config.get('fallback')
|
||||||
|
session_data = self.ussd_session.get('data')
|
||||||
|
transaction_amount = session_data.get('transaction_amount')
|
||||||
|
transaction_amount = to_wei(value=int(transaction_amount))
|
||||||
|
token_symbol = get_default_token_symbol()
|
||||||
|
recipient_phone_number = self.ussd_session.get('data').get('recipient_phone_number')
|
||||||
|
recipient = Account.get_by_phone_number(recipient_phone_number, self.session)
|
||||||
|
tx_recipient_information = recipient.standard_metadata_id()
|
||||||
|
return translation_for(
|
||||||
|
self.display_key,
|
||||||
|
preferred_language,
|
||||||
|
amount=from_wei(transaction_amount),
|
||||||
|
token_symbol=token_symbol,
|
||||||
|
recipient_information=tx_recipient_information,
|
||||||
|
token_balance=available_balance
|
||||||
|
)
|
||||||
|
|
||||||
|
def exit_invalid_menu_option(self):
|
||||||
|
preferred_language = get_cached_preferred_language(self.account.blockchain_address)
|
||||||
|
if not preferred_language:
|
||||||
|
preferred_language = i18n.config.get('fallback')
|
||||||
|
return translation_for(self.display_key, preferred_language, support_phone=Support.phone_number)
|
||||||
|
|
||||||
|
def exit_pin_blocked(self):
|
||||||
|
preferred_language = get_cached_preferred_language(self.account.blockchain_address)
|
||||||
|
if not preferred_language:
|
||||||
|
preferred_language = i18n.config.get('fallback')
|
||||||
|
return translation_for('ussd.kenya.exit_pin_blocked', preferred_language, support_phone=Support.phone_number)
|
||||||
|
|
||||||
|
def exit_successful_transaction(self):
|
||||||
|
"""
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
amount = int(self.ussd_session.get('data').get('transaction_amount'))
|
||||||
|
preferred_language = get_cached_preferred_language(self.account.blockchain_address)
|
||||||
|
if not preferred_language:
|
||||||
|
preferred_language = i18n.config.get('fallback')
|
||||||
|
transaction_amount = to_wei(amount)
|
||||||
|
token_symbol = get_default_token_symbol()
|
||||||
|
recipient_phone_number = self.ussd_session.get('data').get('recipient_phone_number')
|
||||||
|
recipient = Account.get_by_phone_number(phone_number=recipient_phone_number, session=self.session)
|
||||||
|
tx_recipient_information = recipient.standard_metadata_id()
|
||||||
|
tx_sender_information = self.account.standard_metadata_id()
|
||||||
|
return translation_for(
|
||||||
|
self.display_key,
|
||||||
|
preferred_language,
|
||||||
|
transaction_amount=from_wei(transaction_amount),
|
||||||
|
token_symbol=token_symbol,
|
||||||
|
recipient_information=tx_recipient_information,
|
||||||
|
sender_information=tx_sender_information
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def response(account: Account, display_key: str, menu_name: str, session: Session, ussd_session: dict) -> str:
|
||||||
|
"""This function extracts the appropriate session data based on the current menu name. It then inserts them as
|
||||||
|
keywords in the i18n function.
|
||||||
|
:param account: The account in a running USSD session.
|
||||||
|
:type account: Account
|
||||||
|
:param display_key: The path in the translation files defining an appropriate ussd response
|
||||||
|
:type display_key: str
|
||||||
|
:param menu_name: The name by which a specific menu can be identified.
|
||||||
|
:type menu_name: str
|
||||||
|
:param session:
|
||||||
|
:type session:
|
||||||
|
:param ussd_session: A JSON serialized in-memory ussd session object
|
||||||
|
:type ussd_session: dict
|
||||||
|
:return: A string value corresponding the ussd menu's text value.
|
||||||
|
:rtype: str
|
||||||
|
"""
|
||||||
|
menu_processor = MenuProcessor(account, display_key, menu_name, session, ussd_session)
|
||||||
|
|
||||||
|
if menu_name == 'start':
|
||||||
|
return menu_processor.start_menu()
|
||||||
|
|
||||||
|
if menu_name == 'help':
|
||||||
|
return menu_processor.help()
|
||||||
|
|
||||||
|
if menu_name == 'transaction_pin_authorization':
|
||||||
|
return menu_processor.transaction_pin_authorization()
|
||||||
|
|
||||||
|
if menu_name == 'exit_insufficient_balance':
|
||||||
|
return menu_processor.exit_insufficient_balance()
|
||||||
|
|
||||||
|
if menu_name == 'exit_successful_transaction':
|
||||||
|
return menu_processor.exit_successful_transaction()
|
||||||
|
|
||||||
|
if menu_name == 'account_balances':
|
||||||
|
return menu_processor.account_balances()
|
||||||
|
|
||||||
|
if 'pin_authorization' in menu_name:
|
||||||
|
return menu_processor.pin_authorization()
|
||||||
|
|
||||||
|
if 'enter_current_pin' in menu_name:
|
||||||
|
return menu_processor.pin_authorization()
|
||||||
|
|
||||||
|
if 'transaction_set' in menu_name:
|
||||||
|
return menu_processor.account_statement()
|
||||||
|
|
||||||
|
if menu_name == 'display_user_metadata':
|
||||||
|
return menu_processor.person_metadata()
|
||||||
|
|
||||||
|
if menu_name == 'exit_invalid_menu_option':
|
||||||
|
return menu_processor.exit_invalid_menu_option()
|
||||||
|
|
||||||
|
if menu_name == 'exit_pin_blocked':
|
||||||
|
return menu_processor.exit_pin_blocked()
|
||||||
|
|
||||||
|
preferred_language = get_cached_preferred_language(account.blockchain_address)
|
||||||
|
|
||||||
|
return translation_for(display_key, preferred_language)
|
185
apps/cic-ussd/cic_ussd/processor/ussd.py
Normal file
185
apps/cic-ussd/cic_ussd/processor/ussd.py
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
# standard imports
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# external imports
|
||||||
|
import celery
|
||||||
|
import i18n
|
||||||
|
from sqlalchemy.orm.session import Session
|
||||||
|
from tinydb.table import Document
|
||||||
|
|
||||||
|
# local imports
|
||||||
|
from cic_ussd.db.models.account import Account, create
|
||||||
|
from cic_ussd.db.models.base import SessionBase
|
||||||
|
from cic_ussd.db.models.ussd_session import UssdSession
|
||||||
|
from cic_ussd.menu.ussd_menu import UssdMenu
|
||||||
|
from cic_ussd.processor.menu import response
|
||||||
|
from cic_ussd.processor.util import latest_input, resume_last_ussd_session
|
||||||
|
from cic_ussd.session.ussd_session import create_or_update_session, persist_ussd_session
|
||||||
|
from cic_ussd.state_machine import UssdStateMachine
|
||||||
|
from cic_ussd.translation import translation_for
|
||||||
|
from cic_ussd.validator import is_valid_response
|
||||||
|
|
||||||
|
|
||||||
|
def handle_menu(account: Account, session: Session) -> Document:
|
||||||
|
"""
|
||||||
|
:param account:
|
||||||
|
:type account:
|
||||||
|
:param session:
|
||||||
|
:type session:
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
if account.pin_is_blocked(session):
|
||||||
|
return UssdMenu.find_by_name('exit_pin_blocked')
|
||||||
|
|
||||||
|
if account.has_valid_pin(session):
|
||||||
|
last_ussd_session = UssdSession.last_ussd_session(account.phone_number, session)
|
||||||
|
if last_ussd_session:
|
||||||
|
return resume_last_ussd_session(last_ussd_session.state)
|
||||||
|
|
||||||
|
elif not account.has_preferred_language():
|
||||||
|
return UssdMenu.find_by_name('initial_language_selection')
|
||||||
|
else:
|
||||||
|
return UssdMenu.find_by_name('initial_pin_entry')
|
||||||
|
|
||||||
|
|
||||||
|
def get_menu(account: Account,
|
||||||
|
session: Session,
|
||||||
|
user_input: str,
|
||||||
|
ussd_session: Optional[dict]) -> Document:
|
||||||
|
"""
|
||||||
|
:param account:
|
||||||
|
:type account:
|
||||||
|
:param session:
|
||||||
|
:type session:
|
||||||
|
:param user_input:
|
||||||
|
:type user_input:
|
||||||
|
:param ussd_session:
|
||||||
|
:type ussd_session:
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
user_input = latest_input(user_input)
|
||||||
|
if not ussd_session:
|
||||||
|
return handle_menu(account, session)
|
||||||
|
if user_input == '':
|
||||||
|
return UssdMenu.find_by_name(name='exit_invalid_input')
|
||||||
|
if user_input == '0':
|
||||||
|
return UssdMenu.parent_menu(ussd_session.get('state'))
|
||||||
|
session = SessionBase.bind_session(session)
|
||||||
|
state = next_state(account, session, user_input, ussd_session)
|
||||||
|
return UssdMenu.find_by_name(state)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_menu_operations(chain_str: str,
|
||||||
|
external_session_id: str,
|
||||||
|
phone_number: str,
|
||||||
|
queue: str,
|
||||||
|
service_code: str,
|
||||||
|
session,
|
||||||
|
user_input: str):
|
||||||
|
"""
|
||||||
|
:param chain_str:
|
||||||
|
:type chain_str:
|
||||||
|
:param external_session_id:
|
||||||
|
:type external_session_id:
|
||||||
|
:param phone_number:
|
||||||
|
:type phone_number:
|
||||||
|
:param queue:
|
||||||
|
:type queue:
|
||||||
|
:param service_code:
|
||||||
|
:type service_code:
|
||||||
|
:param session:
|
||||||
|
:type session:
|
||||||
|
:param user_input:
|
||||||
|
:type user_input:
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
session = SessionBase.bind_session(session=session)
|
||||||
|
account: Account = Account.get_by_phone_number(phone_number, session)
|
||||||
|
if account:
|
||||||
|
return handle_account_menu_operations(account, external_session_id, queue, session, service_code, user_input)
|
||||||
|
create(chain_str, phone_number, session)
|
||||||
|
menu = UssdMenu.find_by_name('account_creation_prompt')
|
||||||
|
preferred_language = i18n.config.get('fallback')
|
||||||
|
create_or_update_session(
|
||||||
|
external_session_id=external_session_id,
|
||||||
|
msisdn=phone_number,
|
||||||
|
service_code=service_code,
|
||||||
|
state=menu.get('name'),
|
||||||
|
session=session,
|
||||||
|
user_input=user_input)
|
||||||
|
persist_ussd_session(external_session_id, queue)
|
||||||
|
return translation_for('ussd.kenya.account_creation_prompt', preferred_language)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_account_menu_operations(account: Account,
|
||||||
|
external_session_id: str,
|
||||||
|
queue: str,
|
||||||
|
session: Session,
|
||||||
|
service_code: str,
|
||||||
|
user_input: str):
|
||||||
|
"""
|
||||||
|
:param account:
|
||||||
|
:type account:
|
||||||
|
:param external_session_id:
|
||||||
|
:type external_session_id:
|
||||||
|
:param queue:
|
||||||
|
:type queue:
|
||||||
|
:param session:
|
||||||
|
:type session:
|
||||||
|
:param service_code:
|
||||||
|
:type service_code:
|
||||||
|
:param user_input:
|
||||||
|
:type user_input:
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
phone_number = account.phone_number
|
||||||
|
s_query_person_metadata = celery.signature(
|
||||||
|
'cic_ussd.tasks.metadata.query_person_metadata', [account.blockchain_address], queue='cic-ussd')
|
||||||
|
s_query_person_metadata.apply_async()
|
||||||
|
s_query_preferences_metadata = celery.signature(
|
||||||
|
'cic_ussd.tasks.metadata.query_preferences_metadata', [account.blockchain_address], queue='cic-ussd')
|
||||||
|
s_query_preferences_metadata.apply_async()
|
||||||
|
existing_ussd_session = session.query(UssdSession).filter_by(external_session_id=external_session_id).first()
|
||||||
|
last_ussd_session = UssdSession.last_ussd_session(phone_number, session)
|
||||||
|
if existing_ussd_session:
|
||||||
|
menu = get_menu(account, session, user_input, existing_ussd_session.to_json())
|
||||||
|
else:
|
||||||
|
menu = get_menu(account, session, user_input, None)
|
||||||
|
if last_ussd_session:
|
||||||
|
ussd_session = create_or_update_session(
|
||||||
|
external_session_id, phone_number, service_code, user_input, menu.get('name'), session,
|
||||||
|
last_ussd_session.data
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ussd_session = create_or_update_session(
|
||||||
|
external_session_id, phone_number, service_code, user_input, menu.get('name'), session, None
|
||||||
|
)
|
||||||
|
menu_response = response(
|
||||||
|
account, menu.get('display_key'), menu.get('name'), session, ussd_session.to_json()
|
||||||
|
)
|
||||||
|
if not is_valid_response(menu_response):
|
||||||
|
raise ValueError(f'Invalid response: {response}')
|
||||||
|
persist_ussd_session(external_session_id, queue)
|
||||||
|
return menu_response
|
||||||
|
|
||||||
|
|
||||||
|
def next_state(account: Account, session, user_input: str, ussd_session: dict) -> str:
|
||||||
|
"""
|
||||||
|
:param account:
|
||||||
|
:type account:
|
||||||
|
:param session:
|
||||||
|
:type session:
|
||||||
|
:param user_input:
|
||||||
|
:type user_input:
|
||||||
|
:param ussd_session:
|
||||||
|
:type ussd_session:
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
state_machine = UssdStateMachine(ussd_session=ussd_session)
|
||||||
|
state_machine.scan_data((user_input, ussd_session, account, session))
|
||||||
|
return state_machine.state
|
77
apps/cic-ussd/cic_ussd/processor/util.py
Normal file
77
apps/cic-ussd/cic_ussd/processor/util.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
# standard imports
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
|
||||||
|
# external imports
|
||||||
|
from cic_types.models.person import get_contact_data_from_vcard
|
||||||
|
from tinydb.table import Document
|
||||||
|
|
||||||
|
# local imports
|
||||||
|
from cic_ussd.menu.ussd_menu import UssdMenu
|
||||||
|
from cic_ussd.translation import translation_for
|
||||||
|
|
||||||
|
|
||||||
|
def latest_input(user_input: str) -> str:
|
||||||
|
"""
|
||||||
|
:param user_input:
|
||||||
|
:type user_input:
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
return user_input.split('*')[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_person_metadata(cached_metadata: str, display_key: str, preferred_language: str) -> str:
|
||||||
|
"""This function extracts person metadata formatted to suite display on the ussd interface.
|
||||||
|
:param cached_metadata: Person metadata JSON str.
|
||||||
|
:type cached_metadata: str
|
||||||
|
:param display_key: Path to an entry in menu data in translation files.
|
||||||
|
:type display_key: str
|
||||||
|
:param preferred_language: An account's set preferred language.
|
||||||
|
:type preferred_language: str
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
user_metadata = json.loads(cached_metadata)
|
||||||
|
contact_data = get_contact_data_from_vcard(user_metadata.get('vcard'))
|
||||||
|
full_name = f'{contact_data.get("given")} {contact_data.get("family")}'
|
||||||
|
date_of_birth = user_metadata.get('date_of_birth')
|
||||||
|
year_of_birth = date_of_birth.get('year')
|
||||||
|
present_year = datetime.datetime.now().year
|
||||||
|
age = present_year - year_of_birth
|
||||||
|
gender = user_metadata.get('gender')
|
||||||
|
products = ', '.join(user_metadata.get('products'))
|
||||||
|
location = user_metadata.get('location').get('area_name')
|
||||||
|
|
||||||
|
return translation_for(
|
||||||
|
key=display_key,
|
||||||
|
preferred_language=preferred_language,
|
||||||
|
full_name=full_name,
|
||||||
|
age=age,
|
||||||
|
gender=gender,
|
||||||
|
location=location,
|
||||||
|
products=products
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resume_last_ussd_session(last_state: str) -> Document:
|
||||||
|
"""
|
||||||
|
:param last_state:
|
||||||
|
:type last_state:
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
# TODO [Philip]: This can be cleaned further
|
||||||
|
non_reusable_states = [
|
||||||
|
'account_creation_prompt',
|
||||||
|
'exit',
|
||||||
|
'exit_invalid_pin',
|
||||||
|
'exit_invalid_new_pin',
|
||||||
|
'exit_invalid_request',
|
||||||
|
'exit_pin_blocked',
|
||||||
|
'exit_pin_mismatch',
|
||||||
|
'exit_successful_transaction'
|
||||||
|
]
|
||||||
|
if last_state in non_reusable_states:
|
||||||
|
return UssdMenu.find_by_name('start')
|
||||||
|
return UssdMenu.find_by_name(last_state)
|
@ -1,143 +0,0 @@
|
|||||||
# standard imports
|
|
||||||
from typing import Optional, Tuple, Union
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
from typing import Optional, Union
|
|
||||||
from urllib.parse import urlparse, parse_qs
|
|
||||||
|
|
||||||
# third-party imports
|
|
||||||
from sqlalchemy import desc
|
|
||||||
from sqlalchemy.orm.session import Session
|
|
||||||
|
|
||||||
# local imports
|
|
||||||
from cic_ussd.db.models.account import Account
|
|
||||||
from cic_ussd.db.models.base import SessionBase
|
|
||||||
from cic_ussd.db.enum import AccountStatus
|
|
||||||
from cic_ussd.operations import get_account_status, reset_pin
|
|
||||||
from cic_ussd.validator import check_known_user
|
|
||||||
|
|
||||||
|
|
||||||
logg = logging.getLogger(__file__)
|
|
||||||
|
|
||||||
|
|
||||||
def get_query_parameters(env: dict, query_name: Optional[str] = None) -> Union[dict, str]:
|
|
||||||
"""Gets value of the request query parameters.
|
|
||||||
:param env: Object containing server and request information.
|
|
||||||
:type env: dict
|
|
||||||
:param query_name: The specific query parameter to fetch.
|
|
||||||
:type query_name: str
|
|
||||||
:return: Query parameters from the request.
|
|
||||||
:rtype: dict | str
|
|
||||||
"""
|
|
||||||
parsed_url = urlparse(env.get('REQUEST_URI'))
|
|
||||||
params = parse_qs(parsed_url.query)
|
|
||||||
if query_name:
|
|
||||||
param = params.get(query_name)[0]
|
|
||||||
return param
|
|
||||||
return params
|
|
||||||
|
|
||||||
|
|
||||||
def get_request_endpoint(env: dict) -> str:
|
|
||||||
"""Gets value of the request url path.
|
|
||||||
:param env: Object containing server and request information
|
|
||||||
:type env: dict
|
|
||||||
:return: Endpoint that has been touched by the call
|
|
||||||
:rtype: str
|
|
||||||
"""
|
|
||||||
return env.get('PATH_INFO')
|
|
||||||
|
|
||||||
|
|
||||||
def get_request_method(env: dict) -> str:
|
|
||||||
"""Gets value of the request method.
|
|
||||||
:param env: Object containing server and request information.
|
|
||||||
:type env: dict
|
|
||||||
:return: Request method.
|
|
||||||
:rtype: str
|
|
||||||
"""
|
|
||||||
return env.get('REQUEST_METHOD').upper()
|
|
||||||
|
|
||||||
|
|
||||||
def get_account_creation_callback_request_data(env: dict) -> tuple:
|
|
||||||
"""This function retrieves data from a callback
|
|
||||||
:param env: Object containing server and request information.
|
|
||||||
:type env: dict
|
|
||||||
:return: A tuple containing the status, result and task_id for a celery task spawned to create a blockchain
|
|
||||||
account.
|
|
||||||
:rtype: tuple
|
|
||||||
"""
|
|
||||||
|
|
||||||
callback_data = env.get('wsgi.input')
|
|
||||||
status = callback_data.get('status')
|
|
||||||
task_id = callback_data.get('root_id')
|
|
||||||
result = callback_data.get('result')
|
|
||||||
|
|
||||||
return status, task_id, result
|
|
||||||
|
|
||||||
|
|
||||||
def process_pin_reset_requests(env: dict, phone_number: str, session: Session):
|
|
||||||
"""This function processes requests that are responsible for the pin reset functionality. It processes GET and PUT
|
|
||||||
requests responsible for returning an account's status and
|
|
||||||
:param env: A dictionary of values representing data sent on the api.
|
|
||||||
:type env: dict
|
|
||||||
:param phone_number: The phone of the user whose pin is being reset.
|
|
||||||
:type phone_number: str
|
|
||||||
:param session:
|
|
||||||
:type session:
|
|
||||||
:return: A response denoting the result of the request to reset the user's pin.
|
|
||||||
:rtype: str
|
|
||||||
"""
|
|
||||||
if not check_known_user(phone_number=phone_number, session=session):
|
|
||||||
return f'No user matching {phone_number} was found.', '404 Not Found'
|
|
||||||
|
|
||||||
if get_request_method(env) == 'PUT':
|
|
||||||
return reset_pin(phone_number=phone_number, session=session), '200 OK'
|
|
||||||
|
|
||||||
if get_request_method(env) == 'GET':
|
|
||||||
status = get_account_status(phone_number=phone_number, session=session)
|
|
||||||
response = {
|
|
||||||
'status': f'{status}'
|
|
||||||
}
|
|
||||||
response = json.dumps(response)
|
|
||||||
return response, '200 OK'
|
|
||||||
|
|
||||||
|
|
||||||
def process_locked_accounts_requests(env: dict, session: Session) -> tuple:
|
|
||||||
"""This function authenticates staff requests and returns a serialized JSON formatted list of blockchain addresses
|
|
||||||
of accounts for which the PIN has been locked due to too many failed attempts.
|
|
||||||
:param env: A dictionary of values representing data sent on the api.
|
|
||||||
:type env: dict
|
|
||||||
:param session:
|
|
||||||
:type session:
|
|
||||||
:return: A tuple containing a serialized list of blockchain addresses for locked accounts and corresponding message
|
|
||||||
for the response.
|
|
||||||
:rtype: tuple
|
|
||||||
"""
|
|
||||||
session = SessionBase.bind_session(session=session)
|
|
||||||
response = ''
|
|
||||||
|
|
||||||
if get_request_method(env) == 'GET':
|
|
||||||
offset = 0
|
|
||||||
limit = 100
|
|
||||||
|
|
||||||
locked_accounts_path = r'/accounts/locked/(\d+)?/?(\d+)?'
|
|
||||||
r = re.match(locked_accounts_path, env.get('PATH_INFO'))
|
|
||||||
|
|
||||||
if r:
|
|
||||||
if r.lastindex > 1:
|
|
||||||
offset = r[1]
|
|
||||||
limit = r[2]
|
|
||||||
else:
|
|
||||||
limit = r[1]
|
|
||||||
|
|
||||||
locked_accounts = session.query(Account.blockchain_address).filter(
|
|
||||||
Account.account_status == AccountStatus.LOCKED.value,
|
|
||||||
Account.failed_pin_attempts >= 3).order_by(desc(Account.updated)).offset(offset).limit(limit).all()
|
|
||||||
|
|
||||||
# convert lists to scalar blockchain addresses
|
|
||||||
locked_accounts = [blockchain_address for (blockchain_address, ) in locked_accounts]
|
|
||||||
|
|
||||||
SessionBase.release_session(session=session)
|
|
||||||
response = json.dumps(locked_accounts)
|
|
||||||
return response, '200 OK'
|
|
||||||
return response, '405 Play by the rules'
|
|
@ -5,7 +5,7 @@ requests offering control of user account states to a staff behind the client.
|
|||||||
|
|
||||||
# standard imports
|
# 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]
|
||||||
|
|
||||||
|
|
||||||
|
@ -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__)
|
||||||
|
|
||||||
@ -147,4 +148,3 @@ def main():
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
@ -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 []
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
248
apps/cic-ussd/cic_ussd/state_machine/logic/account.py
Normal file
248
apps/cic-ussd/cic_ussd/state_machine/logic/account.py
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
# standard imports
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
# third-party imports
|
||||||
|
import celery
|
||||||
|
import i18n
|
||||||
|
from chainlib.hash import strip_0x
|
||||||
|
from cic_types.models.person import get_contact_data_from_vcard, generate_vcard_from_contact_data, manage_identity_data
|
||||||
|
|
||||||
|
# local imports
|
||||||
|
from cic_ussd.account.chain import Chain
|
||||||
|
from cic_ussd.account.maps import gender, language
|
||||||
|
from cic_ussd.account.metadata import get_cached_preferred_language
|
||||||
|
from cic_ussd.db.models.account import Account
|
||||||
|
from cic_ussd.db.models.base import SessionBase
|
||||||
|
from cic_ussd.error import MetadataNotFoundError
|
||||||
|
from cic_ussd.metadata import PersonMetadata
|
||||||
|
from cic_ussd.session.ussd_session import save_session_data
|
||||||
|
from cic_ussd.translation import translation_for
|
||||||
|
from sqlalchemy.orm.session import Session
|
||||||
|
|
||||||
|
logg = logging.getLogger(__file__)
|
||||||
|
|
||||||
|
|
||||||
|
def change_preferred_language(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||||
|
"""
|
||||||
|
:param state_machine_data:
|
||||||
|
:type state_machine_data:
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
user_input, ussd_session, account, session = state_machine_data
|
||||||
|
r_user_input = language().get(user_input)
|
||||||
|
session = SessionBase.bind_session(session)
|
||||||
|
account.preferred_language = r_user_input
|
||||||
|
session.add(account)
|
||||||
|
session.flush()
|
||||||
|
SessionBase.release_session(session)
|
||||||
|
|
||||||
|
preferences_data = {
|
||||||
|
'preferred_language': r_user_input
|
||||||
|
}
|
||||||
|
|
||||||
|
s = celery.signature(
|
||||||
|
'cic_ussd.tasks.metadata.add_preferences_metadata',
|
||||||
|
[account.blockchain_address, preferences_data],
|
||||||
|
queue='cic-ussd'
|
||||||
|
)
|
||||||
|
return s.apply_async()
|
||||||
|
|
||||||
|
|
||||||
|
def update_account_status_to_active(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||||
|
"""This function sets user's account to active.
|
||||||
|
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||||
|
:type state_machine_data: tuple
|
||||||
|
"""
|
||||||
|
user_input, ussd_session, account, session = state_machine_data
|
||||||
|
session = SessionBase.bind_session(session=session)
|
||||||
|
account.activate_account()
|
||||||
|
session.add(account)
|
||||||
|
session.flush()
|
||||||
|
SessionBase.release_session(session=session)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_gender(account: Account, user_input: str):
|
||||||
|
"""
|
||||||
|
:param account:
|
||||||
|
:type account:
|
||||||
|
:param user_input:
|
||||||
|
:type user_input:
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
preferred_language = get_cached_preferred_language(account.blockchain_address)
|
||||||
|
if not preferred_language:
|
||||||
|
preferred_language = i18n.config.get('fallback')
|
||||||
|
r_user_input = gender().get(user_input)
|
||||||
|
return translation_for(f'helpers.{r_user_input}', preferred_language)
|
||||||
|
|
||||||
|
|
||||||
|
def save_metadata_attribute_to_session_data(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||||
|
"""This function saves first name data to the ussd session in the redis cache.
|
||||||
|
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||||
|
:type state_machine_data: tuple
|
||||||
|
"""
|
||||||
|
user_input, ussd_session, account, session = state_machine_data
|
||||||
|
session = SessionBase.bind_session(session=session)
|
||||||
|
current_state = ussd_session.get('state')
|
||||||
|
|
||||||
|
key = ''
|
||||||
|
if 'given_name' in current_state:
|
||||||
|
key = 'given_name'
|
||||||
|
|
||||||
|
if 'date_of_birth' in current_state:
|
||||||
|
key = 'date_of_birth'
|
||||||
|
|
||||||
|
if 'family_name' in current_state:
|
||||||
|
key = 'family_name'
|
||||||
|
|
||||||
|
if 'gender' in current_state:
|
||||||
|
key = 'gender'
|
||||||
|
user_input = parse_gender(account, user_input)
|
||||||
|
|
||||||
|
if 'location' in current_state:
|
||||||
|
key = 'location'
|
||||||
|
|
||||||
|
if 'products' in current_state:
|
||||||
|
key = 'products'
|
||||||
|
|
||||||
|
if ussd_session.get('data'):
|
||||||
|
data = ussd_session.get('data')
|
||||||
|
data[key] = user_input
|
||||||
|
else:
|
||||||
|
data = {
|
||||||
|
key: user_input
|
||||||
|
}
|
||||||
|
save_session_data('cic-ussd', session, data, ussd_session)
|
||||||
|
SessionBase.release_session(session)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_person_metadata(account: Account, metadata: dict):
|
||||||
|
"""
|
||||||
|
:param account:
|
||||||
|
:type account:
|
||||||
|
:param metadata:
|
||||||
|
:type metadata:
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
set_gender = metadata.get('gender')
|
||||||
|
given_name = metadata.get('given_name')
|
||||||
|
family_name = metadata.get('family_name')
|
||||||
|
email = metadata.get('email')
|
||||||
|
|
||||||
|
if isinstance(metadata.get('date_of_birth'), dict):
|
||||||
|
date_of_birth = metadata.get('date_of_birth')
|
||||||
|
else:
|
||||||
|
date_of_birth = {
|
||||||
|
"year": int(metadata.get('date_of_birth')[:4])
|
||||||
|
}
|
||||||
|
if isinstance(metadata.get('location'), dict):
|
||||||
|
location = metadata.get('location')
|
||||||
|
else:
|
||||||
|
location = {
|
||||||
|
"area_name": metadata.get('location')
|
||||||
|
}
|
||||||
|
if isinstance(metadata.get('products'), list):
|
||||||
|
products = metadata.get('products')
|
||||||
|
else:
|
||||||
|
products = metadata.get('products').split(',')
|
||||||
|
|
||||||
|
phone_number = account.phone_number
|
||||||
|
date_registered = int(account.created.replace().timestamp())
|
||||||
|
blockchain_address = account.blockchain_address
|
||||||
|
chain_spec = f'{Chain.spec.common_name()}:{Chain.spec.engine()}: {Chain.spec.chain_id()}'
|
||||||
|
|
||||||
|
if isinstance(metadata.get('identities'), dict):
|
||||||
|
identities = metadata.get('identities')
|
||||||
|
else:
|
||||||
|
identities = manage_identity_data(
|
||||||
|
blockchain_address=blockchain_address,
|
||||||
|
blockchain_type=Chain.spec.engine(),
|
||||||
|
chain_spec=chain_spec
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"date_registered": date_registered,
|
||||||
|
"date_of_birth": date_of_birth,
|
||||||
|
"gender": set_gender,
|
||||||
|
"identities": identities,
|
||||||
|
"location": location,
|
||||||
|
"products": products,
|
||||||
|
"vcard": generate_vcard_from_contact_data(
|
||||||
|
email=email,
|
||||||
|
family_name=family_name,
|
||||||
|
given_name=given_name,
|
||||||
|
tel=phone_number
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def save_complete_person_metadata(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||||
|
"""This function persists elements of the user metadata stored in session data
|
||||||
|
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||||
|
:type state_machine_data: tuple
|
||||||
|
"""
|
||||||
|
user_input, ussd_session, account, session = state_machine_data
|
||||||
|
metadata = ussd_session.get('data')
|
||||||
|
person_metadata = parse_person_metadata(account, metadata)
|
||||||
|
blockchain_address = account.blockchain_address
|
||||||
|
s_create_person_metadata = celery.signature(
|
||||||
|
'cic_ussd.tasks.metadata.create_person_metadata', [blockchain_address, person_metadata], queue='cic-ussd')
|
||||||
|
s_create_person_metadata.apply_async()
|
||||||
|
|
||||||
|
|
||||||
|
def edit_user_metadata_attribute(state_machine_data: Tuple[str, dict, Account, Session]):
|
||||||
|
"""
|
||||||
|
:param state_machine_data:
|
||||||
|
:type state_machine_data:
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
user_input, ussd_session, account, session = state_machine_data
|
||||||
|
blockchain_address = account.blockchain_address
|
||||||
|
identifier = bytes.fromhex(strip_0x(blockchain_address))
|
||||||
|
person_metadata = PersonMetadata(identifier)
|
||||||
|
cached_person_metadata = person_metadata.get_cached_metadata()
|
||||||
|
|
||||||
|
if not cached_person_metadata:
|
||||||
|
raise MetadataNotFoundError(f'Expected user metadata but found none in cache for key: {blockchain_address}')
|
||||||
|
|
||||||
|
person_metadata = json.loads(cached_person_metadata)
|
||||||
|
data = ussd_session.get('data')
|
||||||
|
contact_data = {}
|
||||||
|
vcard = person_metadata.get('vcard')
|
||||||
|
if vcard:
|
||||||
|
contact_data = get_contact_data_from_vcard(vcard)
|
||||||
|
person_metadata.pop('vcard')
|
||||||
|
given_name = data.get('given_name') or contact_data.get('given')
|
||||||
|
family_name = data.get('family_name') or contact_data.get('family')
|
||||||
|
date_of_birth = data.get('date_of_birth') or person_metadata.get('date_of_birth')
|
||||||
|
set_gender = data.get('gender') or person_metadata.get('gender')
|
||||||
|
location = data.get('location') or person_metadata.get('location')
|
||||||
|
products = data.get('products') or person_metadata.get('products')
|
||||||
|
if isinstance(date_of_birth, str):
|
||||||
|
year = int(date_of_birth)
|
||||||
|
person_metadata['date_of_birth'] = {'year': year}
|
||||||
|
person_metadata['gender'] = set_gender
|
||||||
|
person_metadata['given_name'] = given_name
|
||||||
|
person_metadata['family_name'] = family_name
|
||||||
|
if isinstance(location, str):
|
||||||
|
location_data = person_metadata.get('location')
|
||||||
|
location_data['area_name'] = location
|
||||||
|
person_metadata['location'] = location_data
|
||||||
|
person_metadata['products'] = products
|
||||||
|
if contact_data:
|
||||||
|
contact_data.pop('given')
|
||||||
|
contact_data.pop('family')
|
||||||
|
contact_data.pop('tel')
|
||||||
|
person_metadata = {**person_metadata, **contact_data}
|
||||||
|
parsed_person_metadata = parse_person_metadata(account, person_metadata)
|
||||||
|
s_edit_person_metadata = celery.signature(
|
||||||
|
'cic_ussd.tasks.metadata.create_person_metadata',
|
||||||
|
[blockchain_address, parsed_person_metadata]
|
||||||
|
)
|
||||||
|
s_edit_person_metadata.apply_async(queue='cic-ussd')
|
@ -1,21 +0,0 @@
|
|||||||
# standard imports
|
|
||||||
import logging
|
|
||||||
from typing import Tuple
|
|
||||||
|
|
||||||
# third-party imports
|
|
||||||
from sqlalchemy.orm.session import Session
|
|
||||||
|
|
||||||
# local imports
|
|
||||||
from cic_ussd.db.models.account import Account
|
|
||||||
|
|
||||||
logg = logging.getLogger(__file__)
|
|
||||||
|
|
||||||
|
|
||||||
def process_mini_statement_request(state_machine_data: Tuple[str, dict, Account, Session]):
|
|
||||||
"""This function compiles a brief statement of a user's last three inbound and outbound transactions and send the
|
|
||||||
same as a message on their selected avenue for notification.
|
|
||||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
|
||||||
:type state_machine_data: str
|
|
||||||
"""
|
|
||||||
user_input, ussd_session, user, session = state_machine_data
|
|
||||||
logg.debug('This section requires integration with cic-eth. (The last 6 transactions would be sent as an sms.)')
|
|
@ -5,44 +5,47 @@ ussd menu facilitating the return of appropriate menu responses based on said us
|
|||||||
# standard imports
|
# 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'
|
||||||
|
@ -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:
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -1,292 +0,0 @@
|
|||||||
# standard imports
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from typing import Tuple
|
|
||||||
|
|
||||||
# third-party imports
|
|
||||||
import celery
|
|
||||||
from cic_types.models.person import generate_metadata_pointer
|
|
||||||
from cic_types.models.person import generate_vcard_from_contact_data, manage_identity_data
|
|
||||||
from sqlalchemy.orm.session import Session
|
|
||||||
|
|
||||||
# local imports
|
|
||||||
from cic_ussd.chain import Chain
|
|
||||||
from cic_ussd.db.models.account import Account
|
|
||||||
from cic_ussd.db.models.base import SessionBase
|
|
||||||
from cic_ussd.error import MetadataNotFoundError
|
|
||||||
from cic_ussd.metadata import blockchain_address_to_metadata_pointer
|
|
||||||
from cic_ussd.operations import save_to_in_memory_ussd_session_data
|
|
||||||
from cic_ussd.redis import get_cached_data
|
|
||||||
|
|
||||||
logg = logging.getLogger(__file__)
|
|
||||||
|
|
||||||
|
|
||||||
def change_preferred_language_to_en(state_machine_data: Tuple[str, dict, Account, Session]):
|
|
||||||
"""This function changes the user's preferred language to english.
|
|
||||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
|
||||||
:type state_machine_data: tuple
|
|
||||||
"""
|
|
||||||
user_input, ussd_session, user, session = state_machine_data
|
|
||||||
session = SessionBase.bind_session(session=session)
|
|
||||||
user.preferred_language = 'en'
|
|
||||||
session.add(user)
|
|
||||||
session.flush()
|
|
||||||
SessionBase.release_session(session=session)
|
|
||||||
|
|
||||||
preferences_data = {
|
|
||||||
'preferred_language': 'en'
|
|
||||||
}
|
|
||||||
|
|
||||||
s = celery.signature(
|
|
||||||
'cic_ussd.tasks.metadata.add_preferences_metadata',
|
|
||||||
[user.blockchain_address, preferences_data]
|
|
||||||
)
|
|
||||||
s.apply_async(queue='cic-ussd')
|
|
||||||
|
|
||||||
|
|
||||||
def change_preferred_language_to_sw(state_machine_data: Tuple[str, dict, Account, Session]):
|
|
||||||
"""This function changes the user's preferred language to swahili.
|
|
||||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
|
||||||
:type state_machine_data: tuple
|
|
||||||
"""
|
|
||||||
user_input, ussd_session, account, session = state_machine_data
|
|
||||||
session = SessionBase.bind_session(session=session)
|
|
||||||
account.preferred_language = 'sw'
|
|
||||||
session.add(account)
|
|
||||||
session.flush()
|
|
||||||
SessionBase.release_session(session=session)
|
|
||||||
|
|
||||||
preferences_data = {
|
|
||||||
'preferred_language': 'sw'
|
|
||||||
}
|
|
||||||
|
|
||||||
s = celery.signature(
|
|
||||||
'cic_ussd.tasks.metadata.add_preferences_metadata',
|
|
||||||
[account.blockchain_address, preferences_data]
|
|
||||||
)
|
|
||||||
s.apply_async(queue='cic-ussd')
|
|
||||||
|
|
||||||
|
|
||||||
def update_account_status_to_active(state_machine_data: Tuple[str, dict, Account, Session]):
|
|
||||||
"""This function sets user's account to active.
|
|
||||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
|
||||||
:type state_machine_data: tuple
|
|
||||||
"""
|
|
||||||
user_input, ussd_session, account, session = state_machine_data
|
|
||||||
session = SessionBase.bind_session(session=session)
|
|
||||||
account.activate_account()
|
|
||||||
session.add(account)
|
|
||||||
session.flush()
|
|
||||||
SessionBase.release_session(session=session)
|
|
||||||
|
|
||||||
|
|
||||||
def process_gender_user_input(user: Account, user_input: str):
|
|
||||||
"""
|
|
||||||
:param user:
|
|
||||||
:type user:
|
|
||||||
:param user_input:
|
|
||||||
:type user_input:
|
|
||||||
:return:
|
|
||||||
:rtype:
|
|
||||||
"""
|
|
||||||
gender = ""
|
|
||||||
if user.preferred_language == 'en':
|
|
||||||
if user_input == '1':
|
|
||||||
gender = 'Male'
|
|
||||||
elif user_input == '2':
|
|
||||||
gender = 'Female'
|
|
||||||
elif user_input == '3':
|
|
||||||
gender = 'Other'
|
|
||||||
else:
|
|
||||||
if user_input == '1':
|
|
||||||
gender = 'Mwanaume'
|
|
||||||
elif user_input == '2':
|
|
||||||
gender = 'Mwanamke'
|
|
||||||
elif user_input == '3':
|
|
||||||
gender = 'Nyingine'
|
|
||||||
return gender
|
|
||||||
|
|
||||||
|
|
||||||
def save_metadata_attribute_to_session_data(state_machine_data: Tuple[str, dict, Account, Session]):
|
|
||||||
"""This function saves first name data to the ussd session in the redis cache.
|
|
||||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
|
||||||
:type state_machine_data: tuple
|
|
||||||
"""
|
|
||||||
user_input, ussd_session, user, session = state_machine_data
|
|
||||||
session = SessionBase.bind_session(session=session)
|
|
||||||
# get current menu
|
|
||||||
current_state = ussd_session.get('state')
|
|
||||||
|
|
||||||
# define session data key from current state
|
|
||||||
key = ''
|
|
||||||
if 'given_name' in current_state:
|
|
||||||
key = 'given_name'
|
|
||||||
|
|
||||||
if 'date_of_birth' in current_state:
|
|
||||||
key = 'date_of_birth'
|
|
||||||
|
|
||||||
if 'family_name' in current_state:
|
|
||||||
key = 'family_name'
|
|
||||||
|
|
||||||
if 'gender' in current_state:
|
|
||||||
key = 'gender'
|
|
||||||
user_input = process_gender_user_input(user=user, user_input=user_input)
|
|
||||||
|
|
||||||
if 'location' in current_state:
|
|
||||||
key = 'location'
|
|
||||||
|
|
||||||
if 'products' in current_state:
|
|
||||||
key = 'products'
|
|
||||||
|
|
||||||
# check if there is existing session data
|
|
||||||
if ussd_session.get('session_data'):
|
|
||||||
session_data = ussd_session.get('session_data')
|
|
||||||
session_data[key] = user_input
|
|
||||||
else:
|
|
||||||
session_data = {
|
|
||||||
key: user_input
|
|
||||||
}
|
|
||||||
save_to_in_memory_ussd_session_data(
|
|
||||||
queue='cic-ussd',
|
|
||||||
session=session,
|
|
||||||
session_data=session_data,
|
|
||||||
ussd_session=ussd_session)
|
|
||||||
|
|
||||||
|
|
||||||
def format_user_metadata(metadata: dict, user: Account):
|
|
||||||
"""
|
|
||||||
:param metadata:
|
|
||||||
:type metadata:
|
|
||||||
:param user:
|
|
||||||
:type user:
|
|
||||||
:return:
|
|
||||||
:rtype:
|
|
||||||
"""
|
|
||||||
gender = metadata.get('gender')
|
|
||||||
given_name = metadata.get('given_name')
|
|
||||||
family_name = metadata.get('family_name')
|
|
||||||
|
|
||||||
if isinstance(metadata.get('date_of_birth'), dict):
|
|
||||||
date_of_birth = metadata.get('date_of_birth')
|
|
||||||
else:
|
|
||||||
date_of_birth = {
|
|
||||||
"year": int(metadata.get('date_of_birth')[:4])
|
|
||||||
}
|
|
||||||
|
|
||||||
# check whether there's existing location data
|
|
||||||
if isinstance(metadata.get('location'), dict):
|
|
||||||
location = metadata.get('location')
|
|
||||||
else:
|
|
||||||
location = {
|
|
||||||
"area_name": metadata.get('location')
|
|
||||||
}
|
|
||||||
# check whether it is a list
|
|
||||||
if isinstance(metadata.get('products'), list):
|
|
||||||
products = metadata.get('products')
|
|
||||||
else:
|
|
||||||
products = metadata.get('products').split(',')
|
|
||||||
|
|
||||||
phone_number = user.phone_number
|
|
||||||
date_registered = int(user.created.replace().timestamp())
|
|
||||||
blockchain_address = user.blockchain_address
|
|
||||||
chain_spec = f'{Chain.spec.common_name()}:{Chain.spec.network_id()}'
|
|
||||||
identities = manage_identity_data(
|
|
||||||
blockchain_address=blockchain_address,
|
|
||||||
blockchain_type=Chain.spec.engine(),
|
|
||||||
chain_spec=chain_spec
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"date_registered": date_registered,
|
|
||||||
"date_of_birth": date_of_birth,
|
|
||||||
"gender": gender,
|
|
||||||
"identities": identities,
|
|
||||||
"location": location,
|
|
||||||
"products": products,
|
|
||||||
"vcard": generate_vcard_from_contact_data(
|
|
||||||
family_name=family_name,
|
|
||||||
given_name=given_name,
|
|
||||||
tel=phone_number
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def save_complete_user_metadata(state_machine_data: Tuple[str, dict, Account, Session]):
|
|
||||||
"""This function persists elements of the user metadata stored in session data
|
|
||||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
|
||||||
:type state_machine_data: tuple
|
|
||||||
"""
|
|
||||||
user_input, ussd_session, user, session = state_machine_data
|
|
||||||
|
|
||||||
# get session data
|
|
||||||
metadata = ussd_session.get('session_data')
|
|
||||||
|
|
||||||
# format metadata appropriately
|
|
||||||
user_metadata = format_user_metadata(metadata=metadata, user=user)
|
|
||||||
|
|
||||||
blockchain_address = user.blockchain_address
|
|
||||||
s_create_person_metadata = celery.signature(
|
|
||||||
'cic_ussd.tasks.metadata.create_person_metadata',
|
|
||||||
[blockchain_address, user_metadata]
|
|
||||||
)
|
|
||||||
s_create_person_metadata.apply_async(queue='cic-ussd')
|
|
||||||
|
|
||||||
|
|
||||||
def edit_user_metadata_attribute(state_machine_data: Tuple[str, dict, Account, Session]):
|
|
||||||
user_input, ussd_session, user, session = state_machine_data
|
|
||||||
blockchain_address = user.blockchain_address
|
|
||||||
key = generate_metadata_pointer(
|
|
||||||
identifier=blockchain_address_to_metadata_pointer(blockchain_address=user.blockchain_address),
|
|
||||||
cic_type=':cic.person'
|
|
||||||
)
|
|
||||||
user_metadata = get_cached_data(key=key)
|
|
||||||
|
|
||||||
if not user_metadata:
|
|
||||||
raise MetadataNotFoundError(f'Expected user metadata but found none in cache for key: {blockchain_address}')
|
|
||||||
|
|
||||||
given_name = ussd_session.get('session_data').get('given_name')
|
|
||||||
family_name = ussd_session.get('session_data').get('family_name')
|
|
||||||
date_of_birth = ussd_session.get('session_data').get('date_of_birth')
|
|
||||||
gender = ussd_session.get('session_data').get('gender')
|
|
||||||
location = ussd_session.get('session_data').get('location')
|
|
||||||
products = ussd_session.get('session_data').get('products')
|
|
||||||
|
|
||||||
# validate user metadata
|
|
||||||
user_metadata = json.loads(user_metadata)
|
|
||||||
|
|
||||||
# edit specific metadata attribute
|
|
||||||
if given_name:
|
|
||||||
user_metadata['given_name'] = given_name
|
|
||||||
if family_name:
|
|
||||||
user_metadata['family_name'] = family_name
|
|
||||||
if date_of_birth and len(date_of_birth) == 4:
|
|
||||||
year = int(date_of_birth[:4])
|
|
||||||
user_metadata['date_of_birth'] = {
|
|
||||||
'year': year
|
|
||||||
}
|
|
||||||
if gender:
|
|
||||||
user_metadata['gender'] = gender
|
|
||||||
if location:
|
|
||||||
# get existing location metadata:
|
|
||||||
location_data = user_metadata.get('location')
|
|
||||||
location_data['area_name'] = location
|
|
||||||
user_metadata['location'] = location_data
|
|
||||||
if products:
|
|
||||||
user_metadata['products'] = products
|
|
||||||
|
|
||||||
user_metadata = format_user_metadata(metadata=user_metadata, user=user)
|
|
||||||
|
|
||||||
s_edit_person_metadata = celery.signature(
|
|
||||||
'cic_ussd.tasks.metadata.create_person_metadata',
|
|
||||||
[blockchain_address, user_metadata]
|
|
||||||
)
|
|
||||||
s_edit_person_metadata.apply_async(queue='cic-ussd')
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_metadata(state_machine_data: Tuple[str, dict, Account, Session]):
|
|
||||||
user_input, ussd_session, user, session = state_machine_data
|
|
||||||
blockchain_address = user.blockchain_address
|
|
||||||
s_get_user_metadata = celery.signature(
|
|
||||||
'cic_ussd.tasks.metadata.query_person_metadata',
|
|
||||||
[blockchain_address]
|
|
||||||
)
|
|
||||||
s_get_user_metadata.apply_async(queue='cic-ussd')
|
|
@ -4,67 +4,58 @@ import re
|
|||||||
from typing import Tuple
|
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
|
||||||
|
@ -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
|
||||||
|
@ -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):
|
||||||
|
|
||||||
|
@ -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,69 +35,147 @@ def process_account_creation_callback(self, result: str, url: str, status_code:
|
|||||||
:param status_code: The status of the task to create an account
|
:param status_code: The status of the task to create an account
|
||||||
:type status_code: int
|
:type status_code: int
|
||||||
"""
|
"""
|
||||||
session = SessionBase.create_session()
|
task_uuid = self.request.root_id
|
||||||
cache = InMemoryStore.cache
|
cached_account_creation_data = get_cached_data(task_uuid)
|
||||||
task_id = self.request.root_id
|
|
||||||
|
|
||||||
# get account creation status
|
if not cached_account_creation_data:
|
||||||
account_creation_data = cache.get(task_id)
|
raise AccountCreationDataNotFound(f'No account creation data found for task id: {task_uuid}')
|
||||||
|
|
||||||
# check status
|
if status_code != 0:
|
||||||
if account_creation_data:
|
raise ValueError(f'Unexpected status code: {status_code}')
|
||||||
account_creation_data = json.loads(account_creation_data)
|
|
||||||
if status_code == 0:
|
account_creation_data = json.loads(cached_account_creation_data)
|
||||||
# update redis data
|
|
||||||
account_creation_data['status'] = 'CREATED'
|
account_creation_data['status'] = 'CREATED'
|
||||||
cache.set(name=task_id, value=json.dumps(account_creation_data))
|
cache_data(task_uuid, json.dumps(account_creation_data))
|
||||||
cache.persist(task_id)
|
|
||||||
|
|
||||||
phone_number = account_creation_data.get('phone_number')
|
phone_number = account_creation_data.get('phone_number')
|
||||||
|
|
||||||
# create user
|
session = SessionBase.create_session()
|
||||||
user = Account(blockchain_address=result, phone_number=phone_number)
|
account = Account(blockchain_address=result, phone_number=phone_number)
|
||||||
session.add(user)
|
session.add(account)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
queue = self.request.delivery_info.get('routing_key')
|
queue = self.request.delivery_info.get('routing_key')
|
||||||
|
|
||||||
# add phone number metadata lookup
|
|
||||||
s_phone_pointer = celery.signature(
|
s_phone_pointer = celery.signature(
|
||||||
'cic_ussd.tasks.metadata.add_phone_pointer',
|
'cic_ussd.tasks.metadata.add_phone_pointer', [result, phone_number], queue=queue
|
||||||
[result, phone_number]
|
|
||||||
)
|
)
|
||||||
s_phone_pointer.apply_async(queue=queue)
|
s_phone_pointer.apply_async()
|
||||||
|
|
||||||
# add custom metadata tags
|
custom_metadata = {"tags": ["ussd", "individual"]}
|
||||||
custom_metadata = {
|
|
||||||
"tags": ["ussd", "individual"]
|
|
||||||
}
|
|
||||||
s_custom_metadata = celery.signature(
|
s_custom_metadata = celery.signature(
|
||||||
'cic_ussd.tasks.metadata.add_custom_metadata',
|
'cic_ussd.tasks.metadata.add_custom_metadata', [result, custom_metadata], queue=queue
|
||||||
[result, custom_metadata]
|
|
||||||
)
|
)
|
||||||
s_custom_metadata.apply_async(queue=queue)
|
s_custom_metadata.apply_async()
|
||||||
|
Cache.store.expire(task_uuid, timedelta(seconds=180))
|
||||||
|
|
||||||
# expire cache
|
|
||||||
cache.expire(task_id, timedelta(seconds=180))
|
|
||||||
|
|
||||||
else:
|
@celery_app.task
|
||||||
session.close()
|
def balances_callback(result: list, param: str, status_code: int):
|
||||||
cache.expire(task_id, timedelta(seconds=180))
|
"""
|
||||||
|
:param result:
|
||||||
|
:type result:
|
||||||
|
:param param:
|
||||||
|
:type param:
|
||||||
|
:param status_code:
|
||||||
|
:type status_code:
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
if status_code != 0:
|
||||||
|
raise ValueError(f'Unexpected status code: {status_code}.')
|
||||||
|
|
||||||
else:
|
balances = result[0]
|
||||||
session.close()
|
identifier = bytes.fromhex(strip_0x(param))
|
||||||
raise ActionDataNotFoundError(f'Account creation task: {task_id}, returned unexpected response: {status_code}')
|
key = cache_data_key(identifier, ':cic.balances')
|
||||||
|
cache_data(key, json.dumps(balances))
|
||||||
session.close()
|
|
||||||
|
|
||||||
|
|
||||||
@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')
|
||||||
|
statement_transactions = filter_statement_transactions(result)
|
||||||
|
for transaction in statement_transactions:
|
||||||
|
recipient_transaction, sender_transaction = transaction_actors(transaction)
|
||||||
|
if recipient_transaction.get('blockchain_address') == param:
|
||||||
|
generate(param, queue, recipient_transaction)
|
||||||
|
if sender_transaction.get('blockchain_address') == param:
|
||||||
|
generate(param, queue, sender_transaction)
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(bind=True)
|
||||||
|
def transaction_balances_callback(self, result: list, param: dict, status_code: int):
|
||||||
|
"""
|
||||||
|
:param self:
|
||||||
|
:type self:
|
||||||
|
:param result:
|
||||||
|
:type result:
|
||||||
|
:param param:
|
||||||
|
:type param:
|
||||||
|
:param status_code:
|
||||||
|
:type status_code:
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
if status_code != 0:
|
||||||
|
raise ValueError(f'Unexpected status code: {status_code}.')
|
||||||
|
|
||||||
|
balances_data = result[0]
|
||||||
|
available_balance = calculate_available_balance(balances_data)
|
||||||
|
transaction = param
|
||||||
|
blockchain_address = param.get('blockchain_address')
|
||||||
|
transaction['available_balance'] = available_balance
|
||||||
|
queue = self.request.delivery_info.get('routing_key')
|
||||||
|
|
||||||
|
s_preferences_metadata = celery.signature(
|
||||||
|
'cic_ussd.tasks.metadata.query_preferences_metadata', [blockchain_address], queue=queue
|
||||||
|
)
|
||||||
|
s_process_account_metadata = celery.signature(
|
||||||
|
'cic_ussd.tasks.processor.parse_transaction', [transaction], queue=queue
|
||||||
|
)
|
||||||
|
s_notify_account = celery.signature('cic_ussd.tasks.notifications.transaction', queue=queue)
|
||||||
|
|
||||||
|
if param.get('transaction_type') == 'transfer':
|
||||||
|
celery.chain(s_preferences_metadata, s_process_account_metadata, s_notify_account).apply_async()
|
||||||
|
|
||||||
|
if param.get('transaction_type') == 'tokengift':
|
||||||
|
s_process_account_metadata = celery.signature(
|
||||||
|
'cic_ussd.tasks.processor.parse_transaction', [{}, transaction], queue=queue
|
||||||
|
)
|
||||||
|
celery.chain(s_process_account_metadata, s_notify_account).apply_async()
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task
|
||||||
|
def transaction_callback(result: dict, param: str, status_code: int):
|
||||||
|
"""
|
||||||
|
:param result:
|
||||||
|
:type result:
|
||||||
|
:param param:
|
||||||
|
:type param:
|
||||||
|
:param status_code:
|
||||||
|
:type status_code:
|
||||||
|
:return:
|
||||||
|
:rtype:
|
||||||
|
"""
|
||||||
|
if status_code != 0:
|
||||||
|
raise ValueError(f'Unexpected status code: {status_code}.')
|
||||||
|
|
||||||
|
chain_str = Chain.spec.__str__()
|
||||||
destination_token_symbol = result.get('destination_token_symbol')
|
destination_token_symbol = result.get('destination_token_symbol')
|
||||||
destination_token_value = result.get('destination_token_value')
|
destination_token_value = result.get('destination_token_value')
|
||||||
recipient_blockchain_address = result.get('recipient')
|
recipient_blockchain_address = result.get('recipient')
|
||||||
@ -100,196 +183,35 @@ def process_transaction_callback(self, result: dict, param: str, status_code: in
|
|||||||
source_token_symbol = result.get('source_token_symbol')
|
source_token_symbol = result.get('source_token_symbol')
|
||||||
source_token_value = result.get('source_token_value')
|
source_token_value = result.get('source_token_value')
|
||||||
|
|
||||||
# build stakeholder callback params
|
|
||||||
recipient_metadata = {
|
recipient_metadata = {
|
||||||
"token_symbol": destination_token_symbol,
|
"token_symbol": destination_token_symbol,
|
||||||
"token_value": destination_token_value,
|
"token_value": destination_token_value,
|
||||||
"blockchain_address": recipient_blockchain_address,
|
"blockchain_address": recipient_blockchain_address,
|
||||||
"tag": "recipient",
|
"role": "recipient",
|
||||||
"tx_param": param
|
"transaction_type": param
|
||||||
}
|
}
|
||||||
|
|
||||||
# retrieve account balances
|
|
||||||
get_balances(
|
get_balances(
|
||||||
address=recipient_blockchain_address,
|
address=recipient_blockchain_address,
|
||||||
callback_param=recipient_metadata,
|
callback_param=recipient_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=destination_token_symbol,
|
||||||
asynchronous=True)
|
asynchronous=True)
|
||||||
|
|
||||||
# only retrieve sender if transaction is a transfer
|
|
||||||
if param == 'transfer':
|
if param == 'transfer':
|
||||||
sender_metadata = {
|
sender_metadata = {
|
||||||
"blockchain_address": sender_blockchain_address,
|
"blockchain_address": sender_blockchain_address,
|
||||||
"token_symbol": source_token_symbol,
|
"token_symbol": source_token_symbol,
|
||||||
"token_value": source_token_value,
|
"token_value": source_token_value,
|
||||||
"tag": "sender",
|
"role": "sender",
|
||||||
"tx_param": param
|
"transaction_type": param
|
||||||
}
|
}
|
||||||
|
|
||||||
get_balances(
|
get_balances(
|
||||||
address=sender_blockchain_address,
|
address=sender_blockchain_address,
|
||||||
callback_param=sender_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=source_token_symbol,
|
token_symbol=source_token_symbol,
|
||||||
asynchronous=True)
|
asynchronous=True)
|
||||||
else:
|
|
||||||
raise ValueError(f'Unexpected status code: {status_code}.')
|
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(bind=True)
|
|
||||||
def process_transaction_balances_callback(self, result: list, param: dict, status_code: int):
|
|
||||||
queue = self.request.delivery_info.get('routing_key')
|
|
||||||
if status_code == 0:
|
|
||||||
# retrieve balance data
|
|
||||||
balances_data = result[0]
|
|
||||||
operational_balance = compute_operational_balance(balances=balances_data)
|
|
||||||
|
|
||||||
# retrieve account's address
|
|
||||||
blockchain_address = param.get('blockchain_address')
|
|
||||||
|
|
||||||
# append balance to transaction metadata
|
|
||||||
transaction_metadata = param
|
|
||||||
transaction_metadata['operational_balance'] = operational_balance
|
|
||||||
|
|
||||||
# retrieve account's preferences
|
|
||||||
s_preferences_metadata = celery.signature(
|
|
||||||
'cic_ussd.tasks.metadata.query_preferences_metadata',
|
|
||||||
[blockchain_address],
|
|
||||||
queue=queue
|
|
||||||
)
|
|
||||||
|
|
||||||
# parse metadata and run validations
|
|
||||||
s_process_account_metadata = celery.signature(
|
|
||||||
'cic_ussd.tasks.processor.process_tx_metadata_for_notification',
|
|
||||||
[transaction_metadata],
|
|
||||||
queue=queue
|
|
||||||
)
|
|
||||||
|
|
||||||
# issue notification of transaction
|
|
||||||
s_notify_account = celery.signature(
|
|
||||||
'cic_ussd.tasks.notifications.notify_account_of_transaction',
|
|
||||||
queue=queue
|
|
||||||
)
|
|
||||||
|
|
||||||
if param.get('tx_param') == 'transfer':
|
|
||||||
celery.chain(s_preferences_metadata, s_process_account_metadata, s_notify_account).apply_async()
|
|
||||||
|
|
||||||
if param.get('tx_param') == 'tokengift':
|
|
||||||
s_process_account_metadata = celery.signature(
|
|
||||||
'cic_ussd.tasks.processor.process_tx_metadata_for_notification',
|
|
||||||
[{}, transaction_metadata],
|
|
||||||
queue=queue
|
|
||||||
)
|
|
||||||
celery.chain(s_process_account_metadata, s_notify_account).apply_async()
|
|
||||||
else:
|
|
||||||
raise ValueError(f'Unexpected status code: {status_code}.')
|
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task
|
|
||||||
def process_balances_callback(result: list, param: str, status_code: int):
|
|
||||||
if status_code == 0:
|
|
||||||
balances_data = result[0]
|
|
||||||
blockchain_address = balances_data.get('address')
|
|
||||||
key = create_cached_data_key(
|
|
||||||
identifier=bytes.fromhex(blockchain_address[2:]),
|
|
||||||
salt=':cic.balances_data'
|
|
||||||
)
|
|
||||||
cache_data(key=key, data=json.dumps(balances_data))
|
|
||||||
logg.debug(f'caching: {balances_data} with key: {key}')
|
|
||||||
else:
|
|
||||||
raise ValueError(f'Unexpected status code: {status_code}.')
|
|
||||||
|
|
||||||
|
|
||||||
# TODO: clean up this handler
|
|
||||||
def define_transaction_action_tag(
|
|
||||||
preferred_language: str,
|
|
||||||
sender_blockchain_address: str,
|
|
||||||
param: str):
|
|
||||||
# check if out going ot incoming transaction
|
|
||||||
if sender_blockchain_address == param:
|
|
||||||
# check preferred language
|
|
||||||
if preferred_language == 'en':
|
|
||||||
action_tag = 'SENT'
|
|
||||||
direction = 'TO'
|
|
||||||
else:
|
|
||||||
action_tag = 'ULITUMA'
|
|
||||||
direction = 'KWA'
|
|
||||||
else:
|
|
||||||
if preferred_language == 'en':
|
|
||||||
action_tag = 'RECEIVED'
|
|
||||||
direction = 'FROM'
|
|
||||||
else:
|
|
||||||
action_tag = 'ULIPOKEA'
|
|
||||||
direction = 'KUTOKA'
|
|
||||||
return action_tag, direction
|
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task
|
|
||||||
def process_statement_callback(result, param: str, status_code: int):
|
|
||||||
if status_code == 0:
|
|
||||||
# create session
|
|
||||||
processed_transactions = []
|
|
||||||
|
|
||||||
# process transaction data to cache
|
|
||||||
for transaction in result:
|
|
||||||
sender_blockchain_address = transaction.get('sender')
|
|
||||||
recipient_address = transaction.get('recipient')
|
|
||||||
source_token = transaction.get('source_token')
|
|
||||||
|
|
||||||
# filter out any transactions that are "gassy"
|
|
||||||
if '0x0000000000000000000000000000000000000000' in source_token:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
session = SessionBase.create_session()
|
|
||||||
# describe a processed transaction
|
|
||||||
processed_transaction = {}
|
|
||||||
|
|
||||||
# check if sender is in the system
|
|
||||||
sender: Account = session.query(Account).filter_by(blockchain_address=sender_blockchain_address).first()
|
|
||||||
owner: Account = session.query(Account).filter_by(blockchain_address=param).first()
|
|
||||||
if sender:
|
|
||||||
processed_transaction['sender_phone_number'] = sender.phone_number
|
|
||||||
|
|
||||||
action_tag, direction = define_transaction_action_tag(
|
|
||||||
preferred_language=owner.preferred_language,
|
|
||||||
sender_blockchain_address=sender_blockchain_address,
|
|
||||||
param=param
|
|
||||||
)
|
|
||||||
processed_transaction['action_tag'] = action_tag
|
|
||||||
processed_transaction['direction'] = direction
|
|
||||||
|
|
||||||
else:
|
|
||||||
processed_transaction['sender_phone_number'] = 'GRASSROOTS ECONOMICS'
|
|
||||||
|
|
||||||
# check if recipient is in the system
|
|
||||||
recipient: Account = session.query(Account).filter_by(blockchain_address=recipient_address).first()
|
|
||||||
if recipient:
|
|
||||||
processed_transaction['recipient_phone_number'] = recipient.phone_number
|
|
||||||
|
|
||||||
else:
|
|
||||||
logg.warning(f'Tx with recipient not found in cic-ussd')
|
|
||||||
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
# add transaction values
|
|
||||||
processed_transaction['to_value'] = from_wei(value=transaction.get('to_value')).__str__()
|
|
||||||
processed_transaction['from_value'] = from_wei(value=transaction.get('from_value')).__str__()
|
|
||||||
|
|
||||||
raw_timestamp = transaction.get('timestamp')
|
|
||||||
timestamp = datetime.utcfromtimestamp(raw_timestamp).strftime('%d/%m/%y, %H:%M')
|
|
||||||
processed_transaction['timestamp'] = timestamp
|
|
||||||
|
|
||||||
processed_transactions.append(processed_transaction)
|
|
||||||
|
|
||||||
# cache account statement
|
|
||||||
identifier = bytes.fromhex(param[2:])
|
|
||||||
key = create_cached_data_key(identifier=identifier, salt=':cic.statement')
|
|
||||||
data = json.dumps(processed_transactions)
|
|
||||||
|
|
||||||
# cache statement data
|
|
||||||
cache_data(key=key, data=data)
|
|
||||||
else:
|
|
||||||
raise ValueError(f'Unexpected status code: {status_code}.')
|
|
||||||
|
@ -1,11 +0,0 @@
|
|||||||
# third-party imports
|
|
||||||
import celery
|
|
||||||
import logging
|
|
||||||
|
|
||||||
celery_app = celery.current_app
|
|
||||||
logg = logging.getLogger()
|
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task()
|
|
||||||
def log_it_plz(whatever):
|
|
||||||
logg.info('logged it plz: {}'.format(whatever))
|
|
@ -6,11 +6,7 @@ import celery
|
|||||||
from hexathon import strip_0x
|
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()
|
||||||
|
@ -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':
|
||||||
else:
|
notifier.send_sms_notification('sms.sent_tokens',
|
||||||
notifier.send_sms_notification(
|
|
||||||
key='sms.sent_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_recipient_information=transaction_account_metadata,
|
tx_recipient_information=transaction_account_metadata,
|
||||||
timestamp=timestamp,
|
timestamp=timestamp,
|
||||||
balance=balance
|
balance=balance)
|
||||||
)
|
|
||||||
|
@ -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.commit()
|
||||||
session.close()
|
session.close()
|
||||||
raise UnknownUssdRecipient(
|
return transaction
|
||||||
f'Tx for recipient: {blockchain_address} was received but has no matching user in the system.'
|
|
||||||
)
|
|
||||||
|
|
||||||
# get phone number associated with account
|
|
||||||
phone_number = account.phone_number
|
|
||||||
notification_data['phone_number'] = phone_number
|
|
||||||
|
|
||||||
# get account's role in transaction i.e sender / recipient
|
|
||||||
tx_param = transaction_metadata.get('tx_param')
|
|
||||||
notification_data['transaction_type'] = tx_param
|
|
||||||
|
|
||||||
# get token amount and symbol
|
|
||||||
if tag == 'recipient':
|
|
||||||
account_tx_role = tag
|
|
||||||
amount = transaction_metadata.get('token_value')
|
|
||||||
amount = from_wei(value=amount)
|
|
||||||
token_symbol = transaction_metadata.get('token_symbol')
|
|
||||||
else:
|
|
||||||
account_tx_role = tag
|
|
||||||
amount = transaction_metadata.get('token_value')
|
|
||||||
amount = from_wei(value=amount)
|
|
||||||
token_symbol = transaction_metadata.get('token_symbol')
|
|
||||||
notification_data['account_tx_role'] = account_tx_role
|
|
||||||
notification_data['amount'] = amount
|
|
||||||
notification_data['token_symbol'] = token_symbol
|
|
||||||
|
|
||||||
# get account's standard ussd identification pattern
|
|
||||||
if tx_param == 'transfer':
|
|
||||||
tx_account_metadata = define_account_tx_metadata(user=account)
|
|
||||||
notification_data['transaction_account_metadata'] = tx_account_metadata
|
|
||||||
|
|
||||||
if tag == 'recipient':
|
|
||||||
notification_data['notification_key'] = 'sms.received_tokens'
|
|
||||||
else:
|
|
||||||
notification_data['notification_key'] = 'sms.sent_tokens'
|
|
||||||
|
|
||||||
if tx_param == 'tokengift':
|
|
||||||
notification_data['notification_key'] = 'sms.account_successfully_created'
|
|
||||||
|
|
||||||
# get account's balance
|
|
||||||
notification_data['balance'] = transaction_metadata.get('operational_balance')
|
|
||||||
|
|
||||||
return notification_data
|
|
||||||
|
@ -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()
|
||||||
|
@ -1,79 +0,0 @@
|
|||||||
# standard imports
|
|
||||||
import decimal
|
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
# third-party imports
|
|
||||||
from cic_eth.api import Api
|
|
||||||
|
|
||||||
# local imports
|
|
||||||
from cic_ussd.balance import get_balances, get_cached_operational_balance
|
|
||||||
from cic_ussd.notifications import Notifier
|
|
||||||
from cic_ussd.phone_number import Support
|
|
||||||
|
|
||||||
logg = logging.getLogger()
|
|
||||||
notifier = Notifier()
|
|
||||||
|
|
||||||
|
|
||||||
def truncate(value: float, decimals: int):
|
|
||||||
"""This function truncates a value to a specified number of decimals places.
|
|
||||||
:param value: The value to be truncated.
|
|
||||||
:type value: float
|
|
||||||
:param decimals: The number of decimals for the value to be truncated to
|
|
||||||
:type decimals: int
|
|
||||||
:return: The truncated value.
|
|
||||||
:rtype: int
|
|
||||||
"""
|
|
||||||
decimal.getcontext().rounding = decimal.ROUND_DOWN
|
|
||||||
contextualized_value = decimal.Decimal(value)
|
|
||||||
return round(contextualized_value, decimals)
|
|
||||||
|
|
||||||
|
|
||||||
def from_wei(value: int) -> float:
|
|
||||||
"""This function converts values in Wei to a token in the cic network.
|
|
||||||
:param value: Value in Wei
|
|
||||||
:type value: int
|
|
||||||
:return: SRF equivalent of value in Wei
|
|
||||||
:rtype: float
|
|
||||||
"""
|
|
||||||
value = float(value) / 1e+6
|
|
||||||
return truncate(value=value, decimals=2)
|
|
||||||
|
|
||||||
|
|
||||||
def to_wei(value: int) -> int:
|
|
||||||
"""This functions converts values from a token in the cic network to Wei.
|
|
||||||
:param value: Value in SRF
|
|
||||||
:type value: int
|
|
||||||
:return: Wei equivalent of value in SRF
|
|
||||||
:rtype: int
|
|
||||||
"""
|
|
||||||
return int(value * 1e+6)
|
|
||||||
|
|
||||||
|
|
||||||
class OutgoingTransactionProcessor:
|
|
||||||
|
|
||||||
def __init__(self, chain_str: str, from_address: str, to_address: str):
|
|
||||||
"""
|
|
||||||
:param chain_str: The chain name and network id.
|
|
||||||
:type chain_str: str
|
|
||||||
:param from_address: Ethereum address of the sender
|
|
||||||
:type from_address: str, 0x-hex
|
|
||||||
:param to_address: Ethereum address of the recipient
|
|
||||||
:type to_address: str, 0x-hex
|
|
||||||
"""
|
|
||||||
self.chain_str = chain_str
|
|
||||||
self.cic_eth_api = Api(chain_str=chain_str)
|
|
||||||
self.from_address = from_address
|
|
||||||
self.to_address = to_address
|
|
||||||
|
|
||||||
def process_outgoing_transfer_transaction(self, amount: int, token_symbol: str):
|
|
||||||
"""This function initiates standard transfers between one account to another
|
|
||||||
:param amount: The amount of tokens to be sent
|
|
||||||
:type amount: int
|
|
||||||
:param token_symbol: ERC20 token symbol of token to send
|
|
||||||
:type token_symbol: str
|
|
||||||
"""
|
|
||||||
self.cic_eth_api.transfer(from_address=self.from_address,
|
|
||||||
to_address=self.to_address,
|
|
||||||
value=to_wei(value=amount),
|
|
||||||
token_symbol=token_symbol)
|
|
@ -1,15 +1,13 @@
|
|||||||
# standard imports
|
# 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):
|
||||||
|
13
apps/cic-ussd/config/app.ini
Normal file
13
apps/cic-ussd/config/app.ini
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
[app]
|
||||||
|
allowed_ip=0.0.0.0/0
|
||||||
|
max_body_length=1024
|
||||||
|
password_pepper=
|
||||||
|
|
||||||
|
[machine]
|
||||||
|
states=states/
|
||||||
|
transitions=transitions/
|
||||||
|
|
||||||
|
[client]
|
||||||
|
host =
|
||||||
|
port =
|
||||||
|
ssl =
|
3
apps/cic-ussd/config/celery.ini
Normal file
3
apps/cic-ussd/config/celery.ini
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[celery]
|
||||||
|
broker_url=redis://
|
||||||
|
result_url=redis://
|
10
apps/cic-ussd/config/database.ini
Normal file
10
apps/cic-ussd/config/database.ini
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
[database]
|
||||||
|
name=cic_ussd
|
||||||
|
user=postgres
|
||||||
|
password=
|
||||||
|
host=localhost
|
||||||
|
port=5432
|
||||||
|
engine=postgresql
|
||||||
|
driver=psycopg2
|
||||||
|
debug=0
|
||||||
|
pool_size=1
|
5
apps/cic-ussd/config/phone.ini
Normal file
5
apps/cic-ussd/config/phone.ini
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
[e164]
|
||||||
|
region=KE
|
||||||
|
|
||||||
|
[office]
|
||||||
|
support_phone=0757628885
|
5
apps/cic-ussd/config/redis.ini
Normal file
5
apps/cic-ussd/config/redis.ini
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
[redis]
|
||||||
|
host=redis
|
||||||
|
database=0
|
||||||
|
password=
|
||||||
|
port=6379
|
8
apps/cic-ussd/config/test/app.ini
Normal file
8
apps/cic-ussd/config/test/app.ini
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
[app]
|
||||||
|
allowed_ip=127.0.0.1
|
||||||
|
max_body_length=1024
|
||||||
|
password_pepper=QYbzKff6NhiQzY3ygl2BkiKOpER8RE/Upqs/5aZWW+I=
|
||||||
|
|
||||||
|
[machine]
|
||||||
|
states=states/
|
||||||
|
transitions=transitions/
|
3
apps/cic-ussd/config/test/celery.ini
Normal file
3
apps/cic-ussd/config/test/celery.ini
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[celery]
|
||||||
|
broker_url = filesystem://
|
||||||
|
result_url = filesystem://
|
@ -2,4 +2,4 @@
|
|||||||
engine = evm
|
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
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user