Remove submodule cic ussd
This commit is contained in:
0
apps/cic-ussd/cic_ussd/__init__.py
Normal file
0
apps/cic-ussd/cic_ussd/__init__.py
Normal file
39
apps/cic-ussd/cic_ussd/accounts.py
Normal file
39
apps/cic-ussd/cic_ussd/accounts.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# standard imports
|
||||
import logging
|
||||
from collections import deque
|
||||
|
||||
# third-party imports
|
||||
from cic_eth.api import Api
|
||||
|
||||
# local imports
|
||||
from cic_ussd.transactions import from_wei
|
||||
|
||||
logg = logging.getLogger()
|
||||
|
||||
|
||||
class BalanceManager:
|
||||
|
||||
def __init__(self, address: str, chain_str: str, token_symbol: str):
|
||||
"""
|
||||
:param address: Ethereum address of account whose balance is being queried
|
||||
:type address: str, 0x-hex
|
||||
:param chain_str: The chain name and network id.
|
||||
:type chain_str: str
|
||||
:param token_symbol: ERC20 token symbol of whose balance is being queried
|
||||
:type token_symbol: str
|
||||
"""
|
||||
self.address = address
|
||||
self.chain_str = chain_str
|
||||
self.token_symbol = token_symbol
|
||||
|
||||
def get_operational_balance(self) -> float:
|
||||
"""This question queries cic-eth for an account's balance
|
||||
:return: The current balance of the account as reflected on the blockchain.
|
||||
:rtype: int
|
||||
"""
|
||||
cic_eth_api = Api(chain_str=self.chain_str, callback_task=None)
|
||||
balance_request_task = cic_eth_api.balance(address=self.address, token_symbol=self.token_symbol)
|
||||
balance_request_task_results = balance_request_task.collect()
|
||||
balance_result = deque(balance_request_task_results, maxlen=1).pop()
|
||||
balance = from_wei(value=balance_result[-1])
|
||||
return balance
|
||||
36
apps/cic-ussd/cic_ussd/db/__init__.py
Normal file
36
apps/cic-ussd/cic_ussd/db/__init__.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# standard imports
|
||||
import logging
|
||||
|
||||
# third party imports
|
||||
from confini import Config
|
||||
|
||||
logg = logging.getLogger()
|
||||
|
||||
|
||||
def dsn_from_config(config):
|
||||
"""
|
||||
This function builds a data source name mapping to a database from values defined in the config object.
|
||||
:param config: A config object.
|
||||
:type config: Config
|
||||
:return: A database URI.
|
||||
:rtype: str
|
||||
"""
|
||||
scheme = config.get('DATABASE_ENGINE')
|
||||
if config.get('DATABASE_DRIVER') is not None:
|
||||
scheme += '+{}'.format(config.get('DATABASE_DRIVER'))
|
||||
|
||||
dsn = ''
|
||||
if config.get('DATABASE_ENGINE') == 'sqlite':
|
||||
dsn = f'{scheme}:///{config.get("DATABASE_NAME")}'
|
||||
|
||||
else:
|
||||
dsn = '{}://{}:{}@{}:{}/{}'.format(
|
||||
scheme,
|
||||
config.get('DATABASE_USER'),
|
||||
config.get('DATABASE_PASSWORD'),
|
||||
config.get('DATABASE_HOST'),
|
||||
config.get('DATABASE_PORT'),
|
||||
config.get('DATABASE_NAME'),
|
||||
)
|
||||
logg.debug('parsed dsn from config: {}'.format(dsn))
|
||||
return dsn
|
||||
1
apps/cic-ussd/cic_ussd/db/migrations/default/README
Normal file
1
apps/cic-ussd/cic_ussd/db/migrations/default/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
74
apps/cic-ussd/cic_ussd/db/migrations/default/alembic.ini
Normal file
74
apps/cic-ussd/cic_ussd/db/migrations/default/alembic.ini
Normal file
@@ -0,0 +1,74 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = .
|
||||
|
||||
# template used to generate migration files
|
||||
# file_template = %%(rev)s_%%(slug)s
|
||||
|
||||
# timezone to use when rendering the date
|
||||
# within the migration file as well as the filename.
|
||||
# string value is passed to dateutil.tz.gettz()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; this defaults
|
||||
# to ./versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path
|
||||
# version_locations = %(here)s/bar %(here)s/bat ./versions
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
80
apps/cic-ussd/cic_ussd/db/migrations/default/env.py
Normal file
80
apps/cic-ussd/cic_ussd/db/migrations/default/env.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
fileConfig(config.config_file_name, disable_existing_loggers=True)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
target_metadata = None
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = context.config.attributes.get("connection", None)
|
||||
|
||||
if connectable is None:
|
||||
connectable = engine_from_config(
|
||||
context.config.get_section(context.config.config_ini_section),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
24
apps/cic-ussd/cic_ussd/db/migrations/default/script.py.mako
Normal file
24
apps/cic-ussd/cic_ussd/db/migrations/default/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Create ussd session table
|
||||
|
||||
Revision ID: 2a329190a9af
|
||||
Revises: b5ab9371c0b8
|
||||
Create Date: 2020-10-06 00:06:54.354168
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '2a329190a9af'
|
||||
down_revision = 'f289e8510444'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table('ussd_session',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('created', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated', sa.DateTime(), nullable=True),
|
||||
sa.Column('external_session_id', sa.String(), nullable=False),
|
||||
sa.Column('service_code', sa.String(), nullable=False),
|
||||
sa.Column('msisdn', sa.String(), nullable=False),
|
||||
sa.Column('user_input', sa.String(), nullable=True),
|
||||
sa.Column('state', sa.String(), nullable=False),
|
||||
sa.Column('session_data', postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column('version', sa.Integer(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_ussd_session_external_session_id'), 'ussd_session', ['external_session_id'], unique=True)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index(op.f('ix_ussd_session_external_session_id'), table_name='ussd_session')
|
||||
op.drop_table('ussd_session')
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Create task tracker table
|
||||
|
||||
Revision ID: a571d0aee6f8
|
||||
Revises: 2a329190a9af
|
||||
Create Date: 2021-01-04 18:28:00.462228
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'a571d0aee6f8'
|
||||
down_revision = '2a329190a9af'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table('task_tracker',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('created', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated', sa.DateTime(), nullable=True),
|
||||
sa.Column('task_uuid', sa.String(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('task_tracker')
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Create user table
|
||||
|
||||
Revision ID: f289e8510444
|
||||
Revises:
|
||||
Create Date: 2020-07-14 21:37:13.014200
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f289e8510444'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table('user',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('blockchain_address', sa.String(), nullable=False),
|
||||
sa.Column('phone_number', sa.String(), nullable=False),
|
||||
sa.Column('preferred_language', sa.String(), nullable=True),
|
||||
sa.Column('password_hash', sa.String(), nullable=True),
|
||||
sa.Column('failed_pin_attempts', sa.Integer(), nullable=False),
|
||||
sa.Column('account_status', sa.Integer(), nullable=False),
|
||||
sa.Column('created', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_user_phone_number'), 'user', ['phone_number'], unique=True)
|
||||
op.create_index(op.f('ix_user_blockchain_address'), 'user', ['blockchain_address'], unique=True)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index(op.f('ix_user_blockchain_address'), table_name='user')
|
||||
op.drop_index(op.f('ix_user_phone_number'), table_name='user')
|
||||
op.drop_table('user')
|
||||
47
apps/cic-ussd/cic_ussd/db/models/base.py
Normal file
47
apps/cic-ussd/cic_ussd/db/models/base.py
Normal file
@@ -0,0 +1,47 @@
|
||||
# standard imports
|
||||
import datetime
|
||||
|
||||
# third-party imports
|
||||
from sqlalchemy import Column, Integer, DateTime
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
Model = declarative_base(name='Model')
|
||||
|
||||
|
||||
class SessionBase(Model):
|
||||
__abstract__ = True
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
created = Column(DateTime, default=datetime.datetime.utcnow)
|
||||
updated = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
|
||||
|
||||
engine = None
|
||||
session = None
|
||||
query = None
|
||||
|
||||
@staticmethod
|
||||
def create_session():
|
||||
session = sessionmaker(bind=SessionBase.engine)
|
||||
return session()
|
||||
|
||||
@staticmethod
|
||||
def _set_engine(engine):
|
||||
SessionBase.engine = engine
|
||||
|
||||
@staticmethod
|
||||
def build():
|
||||
Model.metadata.create_all(bind=SessionBase.engine)
|
||||
|
||||
@staticmethod
|
||||
# https://docs.sqlalchemy.org/en/13/core/pooling.html#pool-disconnects
|
||||
def connect(data_source_name):
|
||||
engine = create_engine(data_source_name, pool_pre_ping=True)
|
||||
SessionBase._set_engine(engine)
|
||||
|
||||
@staticmethod
|
||||
def disconnect():
|
||||
SessionBase.engine.dispose()
|
||||
SessionBase.engine = None
|
||||
|
||||
19
apps/cic-ussd/cic_ussd/db/models/task_tracker.py
Normal file
19
apps/cic-ussd/cic_ussd/db/models/task_tracker.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# standard imports
|
||||
import logging
|
||||
|
||||
# third-party imports
|
||||
from sqlalchemy import Column, String
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
|
||||
logg = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TaskTracker(SessionBase):
|
||||
__tablename__ = 'task_tracker'
|
||||
|
||||
def __init__(self, task_uuid):
|
||||
self.task_uuid = task_uuid
|
||||
|
||||
task_uuid = Column(String, nullable=False)
|
||||
90
apps/cic-ussd/cic_ussd/db/models/user.py
Normal file
90
apps/cic-ussd/cic_ussd/db/models/user.py
Normal file
@@ -0,0 +1,90 @@
|
||||
# standard imports
|
||||
from enum import IntEnum
|
||||
|
||||
# third party imports
|
||||
from sqlalchemy import Column, Integer, String
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.encoder import check_password_hash, create_password_hash
|
||||
|
||||
|
||||
class AccountStatus(IntEnum):
|
||||
PENDING = 1
|
||||
ACTIVE = 2
|
||||
LOCKED = 3
|
||||
RESET = 4
|
||||
|
||||
|
||||
class User(SessionBase):
|
||||
"""
|
||||
This class defines a user record along with functions responsible for hashing the user's corresponding password and
|
||||
subsequently verifying a password's validity given an input to compare against the persisted hash.
|
||||
"""
|
||||
__tablename__ = 'user'
|
||||
|
||||
blockchain_address = Column(String)
|
||||
phone_number = Column(String)
|
||||
password_hash = Column(String)
|
||||
failed_pin_attempts = Column(Integer)
|
||||
account_status = Column(Integer)
|
||||
preferred_language = Column(String)
|
||||
|
||||
def __init__(self, blockchain_address, phone_number):
|
||||
self.blockchain_address = blockchain_address
|
||||
self.phone_number = phone_number
|
||||
self.password_hash = None
|
||||
self.failed_pin_attempts = 0
|
||||
self.account_status = AccountStatus.PENDING.value
|
||||
|
||||
def __repr__(self):
|
||||
return f'<User: {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)
|
||||
|
||||
def verify_password(self, password):
|
||||
"""This method takes a password value and compares it to the user's corresponding `hashed_password` value to
|
||||
establish password validity.
|
||||
:param password: A password value
|
||||
:type password: str
|
||||
:return: Pin validity
|
||||
:rtype: boolean
|
||||
"""
|
||||
return check_password_hash(password, self.password_hash)
|
||||
|
||||
def reset_account_pin(self):
|
||||
"""This method is used to unlock a user's account."""
|
||||
self.failed_pin_attempts = 0
|
||||
self.account_status = AccountStatus.RESET.value
|
||||
|
||||
def get_account_status(self):
|
||||
"""This method checks whether the account is past the allowed number of failed pin attempts.
|
||||
If so, it changes the accounts status to Locked.
|
||||
:return: The account status for a user object
|
||||
:rtype: str
|
||||
"""
|
||||
if self.failed_pin_attempts > 2:
|
||||
self.account_status = AccountStatus.LOCKED.value
|
||||
return AccountStatus(self.account_status).name
|
||||
|
||||
def activate_account(self):
|
||||
"""This method is used to reset failed pin attempts and change account status to Active."""
|
||||
self.failed_pin_attempts = 0
|
||||
self.account_status = AccountStatus.ACTIVE.value
|
||||
|
||||
def has_valid_pin(self):
|
||||
"""This method checks whether the user's account status and if a pin hash is present which implies
|
||||
pin validity.
|
||||
:return: The presence of a valid pin and status of the account being active.
|
||||
:rtype: bool
|
||||
"""
|
||||
valid_pin = None
|
||||
if self.get_account_status() == 'ACTIVE' and self.password_hash is not None:
|
||||
valid_pin = True
|
||||
return valid_pin
|
||||
71
apps/cic-ussd/cic_ussd/db/models/ussd_session.py
Normal file
71
apps/cic-ussd/cic_ussd/db/models/ussd_session.py
Normal file
@@ -0,0 +1,71 @@
|
||||
# standard imports
|
||||
import logging
|
||||
|
||||
# third-party imports
|
||||
from sqlalchemy import Column, String, Integer
|
||||
from sqlalchemy.dialects.postgresql import JSON
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.error import VersionTooLowError
|
||||
|
||||
logg = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UssdSession(SessionBase):
|
||||
__tablename__ = 'ussd_session'
|
||||
|
||||
external_session_id = Column(String, nullable=False, index=True, unique=True)
|
||||
service_code = Column(String, nullable=False)
|
||||
msisdn = Column(String, nullable=False)
|
||||
user_input = Column(String)
|
||||
state = Column(String, nullable=False)
|
||||
session_data = Column(JSON)
|
||||
version = Column(Integer, nullable=False)
|
||||
|
||||
def set_data(self, key, session, value):
|
||||
if self.session_data is None:
|
||||
self.session_data = {}
|
||||
self.session_data[key] = value
|
||||
|
||||
# https://stackoverflow.com/questions/42559434/updates-to-json-field-dont-persist-to-db
|
||||
flag_modified(self, "session_data")
|
||||
session.add(self)
|
||||
|
||||
def get_data(self, key):
|
||||
if self.session_data is not None:
|
||||
return self.session_data.get(key)
|
||||
else:
|
||||
return None
|
||||
|
||||
def check_version(self, new_version):
|
||||
if new_version <= self.version:
|
||||
raise VersionTooLowError('New session version number is not greater than last saved version!')
|
||||
|
||||
def update(self, user_input, state, version, session):
|
||||
self.check_version(version)
|
||||
self.user_input = user_input
|
||||
self.state = state
|
||||
self.version = version
|
||||
session.add(self)
|
||||
|
||||
@staticmethod
|
||||
def have_session_for_phone(phone):
|
||||
r = UssdSession.session.query(UssdSession).filter_by(msisdn=phone).first()
|
||||
return r is not None
|
||||
|
||||
def to_json(self):
|
||||
""" This function serializes the in db ussd session object to a JSON object
|
||||
:return: A JSON object of a ussd session in db
|
||||
:rtype: dict
|
||||
"""
|
||||
return {
|
||||
"external_session_id": self.external_session_id,
|
||||
"service_code": self.service_code,
|
||||
"msisdn": self.msisdn,
|
||||
"user_input": self.user_input,
|
||||
"state": self.state,
|
||||
"session_data": self.session_data,
|
||||
"version": self.version
|
||||
}
|
||||
214
apps/cic-ussd/cic_ussd/db/ussd_menu.json
Normal file
214
apps/cic-ussd/cic_ussd/db/ussd_menu.json
Normal file
@@ -0,0 +1,214 @@
|
||||
{
|
||||
"ussd_menu": {
|
||||
"1": {
|
||||
"description": "The self signup process has been initiated and the account is being created",
|
||||
"display_key": "ussd.kenya.account_creation_prompt",
|
||||
"name": "account_creation_prompt",
|
||||
"parent": null
|
||||
},
|
||||
"2": {
|
||||
"description": "Start menu. This is the entry point for users to select their preferred language",
|
||||
"display_key": "ussd.kenya.initial_language_selection",
|
||||
"name": "initial_language_selection",
|
||||
"parent": null
|
||||
},
|
||||
"3": {
|
||||
"description": "PIN setup entry menu",
|
||||
"display_key": "ussd.kenya.initial_pin_entry",
|
||||
"name": "initial_pin_entry",
|
||||
"parent": "initial_language_selection"
|
||||
},
|
||||
"4": {
|
||||
"description": "Confirm new PIN menu",
|
||||
"display_key": "ussd.kenya.initial_pin_confirmation",
|
||||
"name": "initial_pin_confirmation",
|
||||
"parent": "initial_pin_entry"
|
||||
},
|
||||
"5": {
|
||||
"description": "Start menu. This is the entry point for activated users",
|
||||
"display_key": "ussd.kenya.start",
|
||||
"name": "start",
|
||||
"parent": null
|
||||
},
|
||||
"6": {
|
||||
"description": "Send Token recipient entry",
|
||||
"display_key": "ussd.kenya.enter_transaction_recipient",
|
||||
"name": "enter_transaction_recipient",
|
||||
"parent": "start"
|
||||
},
|
||||
"7": {
|
||||
"description": "Send Token amount prompt menu",
|
||||
"display_key": "ussd.kenya.enter_transaction_amount",
|
||||
"name": "enter_transaction_amount",
|
||||
"parent": "start"
|
||||
},
|
||||
"8": {
|
||||
"description": "PIN entry for authorization to send token",
|
||||
"display_key": "ussd.kenya.transaction_pin_authorization",
|
||||
"name": "transaction_pin_authorization",
|
||||
"parent": "start"
|
||||
},
|
||||
"9": {
|
||||
"description": "Terminal of a menu flow where an SMS is expected after.",
|
||||
"display_key": "ussd.kenya.complete",
|
||||
"name": "complete",
|
||||
"parent": null
|
||||
},
|
||||
"10": {
|
||||
"description": "Help menu",
|
||||
"display_key": "ussd.kenya.help",
|
||||
"name": "help",
|
||||
"parent": "start"
|
||||
},
|
||||
"11": {
|
||||
"description": "Manage account menu",
|
||||
"display_key": "ussd.kenya.profile_management",
|
||||
"name": "profile_management",
|
||||
"parent": "start"
|
||||
},
|
||||
"12": {
|
||||
"description": "Manage business directory info",
|
||||
"display_key": "ussd.kenya.select_preferred_language",
|
||||
"name": "select_preferred_language",
|
||||
"parent": "account_management"
|
||||
},
|
||||
"13": {
|
||||
"description": "About business directory info",
|
||||
"display_key": "ussd.kenya.mini_statement_pin_authorization",
|
||||
"name": "mini_statement_pin_authorization",
|
||||
"parent": "account_management"
|
||||
},
|
||||
"14": {
|
||||
"description": "Change business directory info",
|
||||
"display_key": "ussd.kenya.enter_current_pin",
|
||||
"name": "enter_current_pin",
|
||||
"parent": "account_management"
|
||||
},
|
||||
"15": {
|
||||
"description": "New PIN entry menu",
|
||||
"display_key": "ussd.kenya.enter_new_pin",
|
||||
"name": "enter_new_pin",
|
||||
"parent": "account_management"
|
||||
},
|
||||
"16": {
|
||||
"description": "First name entry menu",
|
||||
"display_key": "ussd.kenya.enter_first_name",
|
||||
"name": "enter_first_name",
|
||||
"parent": "profile_management"
|
||||
},
|
||||
"17": {
|
||||
"description": "Last name entry menu",
|
||||
"display_key": "ussd.kenya.enter_last_name",
|
||||
"name": "enter_last_name",
|
||||
"parent": "profile_management"
|
||||
},
|
||||
"18": {
|
||||
"description": "Gender entry menu",
|
||||
"display_key": "ussd.kenya.enter_gender",
|
||||
"name": "enter_gender",
|
||||
"parent": "profile_management"
|
||||
},
|
||||
"19": {
|
||||
"description": "Location entry menu",
|
||||
"display_key": "ussd.kenya.enter_location",
|
||||
"name": "enter_location",
|
||||
"parent": "profile_management"
|
||||
},
|
||||
"20": {
|
||||
"description": "Business profile entry menu",
|
||||
"display_key": "ussd.kenya.enter_business_profile",
|
||||
"name": "enter_business_profile",
|
||||
"parent": "profile_management"
|
||||
},
|
||||
"21": {
|
||||
"description": "Menu to display a user's entire profile",
|
||||
"display_key": "ussd.kenya.display_user_profile_data",
|
||||
"name": "display_user_profile_data",
|
||||
"parent": "profile_management"
|
||||
},
|
||||
"22": {
|
||||
"description": "Pin authorization to change name",
|
||||
"display_key": "ussd.kenya.name_management_pin_authorization",
|
||||
"name": "name_management_pin_authorization",
|
||||
"parent": "profile_management"
|
||||
},
|
||||
"23": {
|
||||
"description": "Pin authorization to change gender",
|
||||
"display_key": "ussd.kenya.gender_management_pin_authorization",
|
||||
"name": "gender_management_pin_authorization",
|
||||
"parent": "profile_management"
|
||||
},
|
||||
"24": {
|
||||
"description": "Pin authorization to change location",
|
||||
"display_key": "ussd.kenya.location_management_pin_authorization",
|
||||
"name": "location_management_pin_authorization",
|
||||
"parent": "profile_management"
|
||||
},
|
||||
"26": {
|
||||
"description": "Pin authorization to display user's profile",
|
||||
"display_key": "ussd.kenya.view_profile_pin_authorization",
|
||||
"name": "view_profile_pin_authorization",
|
||||
"parent": "profile_management"
|
||||
},
|
||||
"27": {
|
||||
"description": "Exit menu",
|
||||
"display_key": "ussd.kenya.exit",
|
||||
"name": "exit",
|
||||
"parent": null
|
||||
},
|
||||
"28": {
|
||||
"description": "Invalid menu option",
|
||||
"display_key": "ussd.kenya.exit_invalid_menu_option",
|
||||
"name": "exit_invalid_menu_option",
|
||||
"parent": null
|
||||
},
|
||||
"29": {
|
||||
"description": "PIN policy violation",
|
||||
"display_key": "ussd.kenya.exit_invalid_pin",
|
||||
"name": "exit_invalid_pin",
|
||||
"parent": null
|
||||
},
|
||||
"30": {
|
||||
"description": "PIN mismatch. New PIN and the new PIN confirmation do not match",
|
||||
"display_key": "ussd.kenya.exit_pin_mismatch",
|
||||
"name": "exit_pin_mismatch",
|
||||
"parent": null
|
||||
},
|
||||
"31": {
|
||||
"description": "Ussd PIN Blocked Menu",
|
||||
"display_key": "ussd.kenya.exit_pin_blocked",
|
||||
"name": "exit_pin_blocked",
|
||||
"parent": null
|
||||
},
|
||||
"32": {
|
||||
"description": "Key params missing in request",
|
||||
"display_key": "ussd.kenya.exit_invalid_request",
|
||||
"name": "exit_invalid_request",
|
||||
"parent": null
|
||||
},
|
||||
"33": {
|
||||
"description": "The user did not select a choice",
|
||||
"display_key": "ussd.kenya.exit_invalid_input",
|
||||
"name": "exit_invalid_input",
|
||||
"parent": null
|
||||
},
|
||||
"34": {
|
||||
"description": "Exit following a successful transaction.",
|
||||
"display_key": "ussd.kenya.exit_successful_transaction",
|
||||
"name": "exit_successful_transaction",
|
||||
"parent": null
|
||||
},
|
||||
"35": {
|
||||
"description": "Manage account menu",
|
||||
"display_key": "ussd.kenya.account_management",
|
||||
"name": "account_management",
|
||||
"parent": "start"
|
||||
},
|
||||
"36": {
|
||||
"description": "Exit following insufficient balance to perform a transaction.",
|
||||
"display_key": "ussd.kenya.exit_insufficient_balance",
|
||||
"name": "exit_insufficient_balance",
|
||||
"parent": null
|
||||
}
|
||||
}
|
||||
}
|
||||
69
apps/cic-ussd/cic_ussd/encoder.py
Normal file
69
apps/cic-ussd/cic_ussd/encoder.py
Normal file
@@ -0,0 +1,69 @@
|
||||
# third party imports
|
||||
import bcrypt
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
|
||||
class PasswordEncoder(Fernet):
|
||||
"""This class is responsible for defining the encryption function for password encoding in the application and the
|
||||
provision of a class method that can be used to define the static key attribute at the application's entry point.
|
||||
|
||||
:cvar key: a URL-safe base64-encoded 32-byte
|
||||
:type key: bytes
|
||||
"""
|
||||
|
||||
key = None
|
||||
|
||||
@classmethod
|
||||
def set_key(cls, key: bytes):
|
||||
"""This method sets the value of the static key attribute to make it accessible to all subsequent instances of
|
||||
the class once defined.
|
||||
:param key: key: a URL-safe base64-encoded 32-byte
|
||||
:type key: bytes
|
||||
"""
|
||||
cls.key = key
|
||||
|
||||
def encrypt(self, ciphertext: bytes):
|
||||
"""This overloads the encrypt function of the Fernet class
|
||||
:param ciphertext: The data to be encrypted.
|
||||
:type ciphertext: bytes
|
||||
:return: A fernet token (A set of bytes representing the hashed value succeeding the encryption)
|
||||
:rtype: bytes
|
||||
"""
|
||||
return super(PasswordEncoder, self).encrypt(ciphertext)
|
||||
|
||||
|
||||
def create_password_hash(password):
|
||||
"""This method encrypts a password value using a pre-set pepper and an appended salt. Documentation is brief since
|
||||
symmetric encryption using a unique key (pepper) and salted passwords before hashing is well documented.
|
||||
N/B: Fernet encryption requires the unique key to be a URL-safe base64-encoded 32-byte key.
|
||||
https://cryptography.io/en/latest/fernet/
|
||||
:param password: A password value
|
||||
:type password: str
|
||||
|
||||
:raises ValueError: if a key whose length length is less than 32 bytes.
|
||||
:raises binascii.Error: if base64 key is invalid or corrupted.
|
||||
|
||||
:return: A fernet token (A set of bytes representing the hashed value succeeding the encryption)
|
||||
:rtype: str
|
||||
"""
|
||||
fernet = PasswordEncoder(PasswordEncoder.key)
|
||||
return fernet.encrypt(bcrypt.hashpw(password.encode(), bcrypt.gensalt())).decode()
|
||||
|
||||
|
||||
def check_password_hash(password, hashed_password):
|
||||
"""This method ascertains a password's validity by hashing the provided password value using the original pepper and
|
||||
compares the resultant fernet signature to the one persisted in the db for a given user.
|
||||
:param password: A password value
|
||||
:type password: str
|
||||
:param hashed_password: A hash for a user's password value
|
||||
:type hashed_password: str
|
||||
|
||||
:raises ValueError: if a key whose length length is less than 32 bytes.
|
||||
:raises binascii.Error: if base64 key is invalid or corrupted.
|
||||
|
||||
:return: Password validity
|
||||
:rtype: boolean
|
||||
"""
|
||||
fernet = PasswordEncoder(PasswordEncoder.key)
|
||||
hashed_password = fernet.decrypt(hashed_password.encode())
|
||||
return bcrypt.checkpw(password.encode(), hashed_password)
|
||||
19
apps/cic-ussd/cic_ussd/error.py
Normal file
19
apps/cic-ussd/cic_ussd/error.py
Normal file
@@ -0,0 +1,19 @@
|
||||
class VersionTooLowError(Exception):
|
||||
"""Raised when the session version doesn't match latest version."""
|
||||
pass
|
||||
|
||||
|
||||
class SessionNotFoundError(Exception):
|
||||
"""Raised when queried session is not found in memory."""
|
||||
pass
|
||||
|
||||
|
||||
class InvalidFileFormatError(OSError):
|
||||
"""Raised when the file format is invalid."""
|
||||
pass
|
||||
|
||||
|
||||
class ActionDataNotFoundError(OSError):
|
||||
"""Raised when action data matching a specific task uuid is not found in the redis cache"""
|
||||
pass
|
||||
|
||||
0
apps/cic-ussd/cic_ussd/files/__init__.py
Normal file
0
apps/cic-ussd/cic_ussd/files/__init__.py
Normal file
50
apps/cic-ussd/cic_ussd/files/local_files.py
Normal file
50
apps/cic-ussd/cic_ussd/files/local_files.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# standard imports
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
# third party imports
|
||||
from tinydb import TinyDB
|
||||
|
||||
logg = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_local_file_data_stores(file_location: str, table_name: str):
|
||||
"""
|
||||
This methods creates a file where data can be stored in memory.
|
||||
:param file_location: Path to file to create tiny db in-memory data store.
|
||||
:type file_location: str
|
||||
:param table_name: The name of the tiny db table structure to store the data.
|
||||
:type table_name: str
|
||||
:return: A tinyDB table
|
||||
"""
|
||||
store = TinyDB(file_location, sort_keys=True, indent=4, separators=(',', ': '))
|
||||
return store.table(table_name, cache_size=30)
|
||||
|
||||
|
||||
def json_file_parser(filepath: str) -> list:
|
||||
"""This function takes an entry name for a group of transitions or states, it then reads the
|
||||
successive file and returns a list of the corresponding elements representing a set of transitions or states.
|
||||
|
||||
:param filepath: A path to the JSON file containing data.
|
||||
:type filepath: str
|
||||
:return: A list of objects to add to the state machine's transitions.
|
||||
:rtype: list
|
||||
"""
|
||||
data = []
|
||||
for json_data_file_path in os.listdir(filepath):
|
||||
# get path of data files
|
||||
data_file_path = os.path.join(filepath, json_data_file_path)
|
||||
|
||||
# open data file
|
||||
data_file = open(data_file_path)
|
||||
|
||||
# load json data
|
||||
json_data = json.load(data_file)
|
||||
logg.debug(f'Loading data from: {json_data_file_path}')
|
||||
|
||||
# get all data in one list
|
||||
data += json_data
|
||||
data_file.close()
|
||||
|
||||
return data
|
||||
0
apps/cic-ussd/cic_ussd/menu/__init__.py
Normal file
0
apps/cic-ussd/cic_ussd/menu/__init__.py
Normal file
104
apps/cic-ussd/cic_ussd/menu/ussd_menu.py
Normal file
104
apps/cic-ussd/cic_ussd/menu/ussd_menu.py
Normal file
@@ -0,0 +1,104 @@
|
||||
# standard imports
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
# third party imports
|
||||
from tinydb import Query
|
||||
from tinydb.table import Document, Table
|
||||
|
||||
# define logger.
|
||||
logg = logging.getLogger()
|
||||
|
||||
|
||||
class UssdMenu:
|
||||
"""
|
||||
This class defines the USSD menu object that is called whenever a user makes transitions in the menu.
|
||||
:cvar ussd_menu_db: The tinydb database object.
|
||||
:type ussd_menu_db: Table
|
||||
"""
|
||||
ussd_menu_db = None
|
||||
Menu = Query()
|
||||
|
||||
def __init__(self,
|
||||
name: str,
|
||||
description: str,
|
||||
parent: Optional[str],
|
||||
country: Optional[str] = 'Kenya',
|
||||
gateway: Optional[str] = 'USSD'):
|
||||
"""
|
||||
This function is called whenever a USSD menu object is created and saves the instance to a JSON DB.
|
||||
:param name: The name of the menu and is used as it's unique identifier.
|
||||
:type name: str.
|
||||
:param description: A brief explanation of what the menu does.
|
||||
:type description: str.
|
||||
:param parent: The menu from which the current menu is called. Transitions move from parent to child menus.
|
||||
:type parent: str.
|
||||
:param country: The country from which the menu is created for and being used. Defaults to Kenya.
|
||||
:type country: str
|
||||
:param gateway: The gateway through which the menu is used. Defaults to USSD.
|
||||
:type gateway: str.
|
||||
:raises ValueError: If menu already exists.
|
||||
"""
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.parent = parent
|
||||
self.display_key = f'{gateway.lower()}.{country.lower()}.{name}'
|
||||
|
||||
menu = self.ussd_menu_db.get(UssdMenu.Menu.name == name)
|
||||
if menu:
|
||||
raise ValueError('Menu already exists!')
|
||||
self.ussd_menu_db.insert({
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'parent': self.parent,
|
||||
'display_key': self.display_key
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def find_by_name(name: str) -> Document:
|
||||
"""
|
||||
This function attempts to fetch a menu from the JSON DB using the unique name.
|
||||
:param name: The name of the menu that is being searched for.
|
||||
:type name: str.
|
||||
:return: The function returns the queried menu in JSON format if found,
|
||||
else it returns the menu item for invalid requests.
|
||||
:rtype: Document.
|
||||
"""
|
||||
menu = UssdMenu.ussd_menu_db.get(UssdMenu.Menu.name == name)
|
||||
if not menu:
|
||||
logg.error("No USSD Menu with name {}".format(name))
|
||||
return UssdMenu.ussd_menu_db.get(UssdMenu.Menu.name == 'exit_invalid_request')
|
||||
else:
|
||||
return menu
|
||||
|
||||
@staticmethod
|
||||
def set_description(name: str, description: str):
|
||||
"""
|
||||
This function updates the description for a specific menu in the JSON DB.
|
||||
:param name: The name of the menu whose description should be updated.
|
||||
:type name: str.
|
||||
:param description: The new menu description. On success it should overwrite the menu's previous description.
|
||||
:type description: str.
|
||||
"""
|
||||
menu = UssdMenu.find_by_name(name=name)
|
||||
UssdMenu.ussd_menu_db.update({'description': description}, UssdMenu.Menu.name == menu['name'])
|
||||
|
||||
@staticmethod
|
||||
def parent_menu(menu_name: str) -> Document:
|
||||
"""
|
||||
This function fetches the parent menu of the menu instance it has been called on.
|
||||
:param menu_name: The name of the menu whose parent is to be returned.
|
||||
:type menu_name: str
|
||||
:return: This function returns the menu's parent menu in JSON format.
|
||||
:rtype: Document.
|
||||
"""
|
||||
ussd_menu = UssdMenu.find_by_name(name=menu_name)
|
||||
return UssdMenu.find_by_name(ussd_menu.get('parent'))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
This method return the object representation of the menu.
|
||||
:return: This function returns a string containing the object representation of the menu.
|
||||
:rtype: str.
|
||||
"""
|
||||
return f"<UssdMenu {self.name} - {self.description}>"
|
||||
29
apps/cic-ussd/cic_ussd/notifications.py
Normal file
29
apps/cic-ussd/cic_ussd/notifications.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# standard imports
|
||||
from typing import Union
|
||||
|
||||
# third-party imports
|
||||
from cic_notify.api import Api
|
||||
|
||||
# local imports
|
||||
from cic_ussd.translation import translation_for
|
||||
|
||||
|
||||
class Notifier:
|
||||
|
||||
queue: Union[str, bool, None] = False
|
||||
|
||||
def send_sms_notification(self, key: str, phone_number: str, preferred_language: str, **kwargs):
|
||||
"""This function creates a task to send a message to a user.
|
||||
:param key: The key mapping to a specific message entry in translation files.
|
||||
:type key: str
|
||||
:param phone_number: The recipient's phone number.
|
||||
:type phone_number: str
|
||||
:param preferred_language: A notification recipient's preferred language.
|
||||
:type preferred_language: str
|
||||
"""
|
||||
if self.queue is False:
|
||||
notify_api = Api()
|
||||
else:
|
||||
notify_api = Api(queue=self.queue)
|
||||
message = translation_for(key=key, preferred_language=preferred_language, **kwargs)
|
||||
notify_api.sms(recipient=phone_number, message=message)
|
||||
489
apps/cic-ussd/cic_ussd/operations.py
Normal file
489
apps/cic-ussd/cic_ussd/operations.py
Normal file
@@ -0,0 +1,489 @@
|
||||
# standard imports
|
||||
import json
|
||||
import logging
|
||||
|
||||
# third party imports
|
||||
import celery
|
||||
import i18n
|
||||
import phonenumbers
|
||||
from cic_eth.api.api_task import Api
|
||||
from tinydb.table import Document
|
||||
from typing import Optional
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db.models.user import User
|
||||
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
|
||||
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(task_uuid):
|
||||
"""
|
||||
This function takes tasks spawned over api interfaces and records their creation time for tracking.
|
||||
:param task_uuid: The uuid for an initiated task.
|
||||
:type task_uuid: str
|
||||
"""
|
||||
task_record = TaskTracker(task_uuid=task_uuid)
|
||||
TaskTracker.session.add(task_record)
|
||||
TaskTracker.session.commit()
|
||||
|
||||
|
||||
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 header[0] == 'Content-Length':
|
||||
headers[position] = content_length_header
|
||||
else:
|
||||
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) -> 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
|
||||
: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
|
||||
)
|
||||
return session
|
||||
|
||||
|
||||
def create_or_update_session(
|
||||
external_session_id: str,
|
||||
phone: str,
|
||||
service_code: str,
|
||||
user_input: str,
|
||||
current_menu: str,
|
||||
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_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
|
||||
"""
|
||||
existing_ussd_session = UssdSession.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)
|
||||
return ussd_session
|
||||
|
||||
|
||||
def get_account_status(phone_number) -> str:
|
||||
"""Get the status of a user's account.
|
||||
:param phone_number: The phone number to be checked.
|
||||
:type phone_number: str
|
||||
:return: The user account status.
|
||||
:rtype: str
|
||||
"""
|
||||
user = User.session.query(User).filter_by(phone_number=phone_number).first()
|
||||
status = user.get_account_status()
|
||||
User.session.add(user)
|
||||
User.session.commit()
|
||||
|
||||
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,
|
||||
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 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)
|
||||
|
||||
# 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'),
|
||||
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.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(ussd_session: Optional[dict], user: User, 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 user: A user object.
|
||||
:type user: User
|
||||
: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)
|
||||
current_menu = process_request(ussd_session=ussd_session, user_input=latest_input, user=user)
|
||||
return current_menu
|
||||
|
||||
|
||||
def process_menu_interaction_requests(chain_str: str,
|
||||
external_session_id: str,
|
||||
phone_number: str,
|
||||
queue: str,
|
||||
service_code: str,
|
||||
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 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=phone_number):
|
||||
response = initiate_account_creation_request(chain_str=chain_str,
|
||||
external_session_id=external_session_id,
|
||||
phone_number=phone_number,
|
||||
service_code=service_code,
|
||||
user_input=user_input)
|
||||
|
||||
else:
|
||||
# get user
|
||||
user = User.session.query(User).filter_by(phone_number=phone_number).first()
|
||||
|
||||
# find any existing ussd session
|
||||
existing_ussd_session = UssdSession.session.query(UssdSession).filter_by(
|
||||
external_session_id=external_session_id).first()
|
||||
|
||||
# validate user inputs
|
||||
if existing_ussd_session:
|
||||
current_menu = process_current_menu(
|
||||
ussd_session=existing_ussd_session.to_json(),
|
||||
user=user,
|
||||
user_input=user_input
|
||||
)
|
||||
else:
|
||||
current_menu = process_current_menu(
|
||||
ussd_session=None,
|
||||
user=user,
|
||||
user_input=user_input
|
||||
)
|
||||
|
||||
# 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')
|
||||
)
|
||||
|
||||
# define appropriate response
|
||||
response = custom_display_text(
|
||||
display_key=current_menu.get('display_key'),
|
||||
menu_name=current_menu.get('name'),
|
||||
ussd_session=ussd_session.to_json(),
|
||||
user=user
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def reset_pin(phone_number: str) -> 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
|
||||
:return: The status of the pin reset.
|
||||
:rtype: str
|
||||
"""
|
||||
user = User.session.query(User).filter_by(phone_number=phone_number).first()
|
||||
user.reset_account_pin()
|
||||
User.session.add(user)
|
||||
User.session.commit()
|
||||
|
||||
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_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_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_data=session_data
|
||||
)
|
||||
persist_session_to_db_task(external_session_id=external_session_id, queue=queue)
|
||||
|
||||
|
||||
def process_phone_number(phone_number: str, region: str):
|
||||
"""This function parses any phone number for the provided region
|
||||
:param phone_number: A string with a phone number.
|
||||
:type phone_number: str
|
||||
:param region: Caller defined region
|
||||
:type region: str
|
||||
:return: The parsed phone number value based on the defined region
|
||||
:rtype: str
|
||||
"""
|
||||
if not isinstance(phone_number, str):
|
||||
try:
|
||||
phone_number = str(int(phone_number))
|
||||
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
phone_number_object = phonenumbers.parse(phone_number, region)
|
||||
parsed_phone_number = phonenumbers.format_number(phone_number_object, phonenumbers.PhoneNumberFormat.E164)
|
||||
|
||||
return parsed_phone_number
|
||||
|
||||
|
||||
def get_user_by_phone_number(phone_number: str) -> Optional[User]:
|
||||
"""This function queries the database for a user based on the provided phone number.
|
||||
:param phone_number: A valid phone number.
|
||||
:type phone_number: str
|
||||
:return: A user object matching a given phone number
|
||||
:rtype: User|None
|
||||
"""
|
||||
# consider adding region to user's metadata
|
||||
phone_number = process_phone_number(phone_number=phone_number, region='KE')
|
||||
user = User.session.query(User).filter_by(phone_number=phone_number).first()
|
||||
return user
|
||||
245
apps/cic-ussd/cic_ussd/processor.py
Normal file
245
apps/cic-ussd/cic_ussd/processor.py
Normal file
@@ -0,0 +1,245 @@
|
||||
# standard imports
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
# third party imports
|
||||
from tinydb.table import Document
|
||||
|
||||
# local imports
|
||||
from cic_ussd.accounts import BalanceManager
|
||||
from cic_ussd.db.models.user import AccountStatus, User
|
||||
from cic_ussd.db.models.ussd_session import UssdSession
|
||||
from cic_ussd.menu.ussd_menu import UssdMenu
|
||||
from cic_ussd.state_machine import UssdStateMachine
|
||||
from cic_ussd.transactions import to_wei, from_wei
|
||||
from cic_ussd.translation import translation_for
|
||||
|
||||
logg = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def process_pin_authorization(display_key: str, user: User, **kwargs) -> str:
|
||||
"""
|
||||
This method provides translation for all ussd menu entries that follow the pin authorization pattern.
|
||||
:param display_key: The path in the translation files defining an appropriate ussd response
|
||||
:type display_key: str
|
||||
:param user: The user in a running USSD session.
|
||||
:type user: User
|
||||
: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 user.failed_pin_attempts > 0:
|
||||
return translation_for(
|
||||
key=f'{display_key}.retry',
|
||||
preferred_language=user.preferred_language,
|
||||
remaining_attempts=(remaining_attempts - user.failed_pin_attempts)
|
||||
)
|
||||
else:
|
||||
return translation_for(
|
||||
key=f'{display_key}.first',
|
||||
preferred_language=user.preferred_language,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
def process_exit_insufficient_balance(display_key: str, user: User, ussd_session: dict):
|
||||
"""This function processes the exit menu letting users their account balance is insufficient to perform a specific
|
||||
transaction.
|
||||
:param display_key: The path in the translation files defining an appropriate ussd response
|
||||
:type display_key: str
|
||||
:param user: The user requesting access to the ussd menu.
|
||||
:type user: User
|
||||
: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
|
||||
balance_manager = BalanceManager(address=user.blockchain_address,
|
||||
chain_str=UssdStateMachine.chain_str,
|
||||
token_symbol='SRF')
|
||||
balance = balance_manager.get_operational_balance()
|
||||
|
||||
# compile response data
|
||||
user_input = ussd_session.get('user_input').split('*')[-1]
|
||||
transaction_amount = to_wei(value=int(user_input))
|
||||
token_symbol = 'SRF'
|
||||
recipient_phone_number = ussd_session.get('session_data').get('recipient_phone_number')
|
||||
tx_recipient_information = recipient_phone_number
|
||||
|
||||
return translation_for(
|
||||
key=display_key,
|
||||
preferred_language=user.preferred_language,
|
||||
amount=from_wei(transaction_amount),
|
||||
token_symbol=token_symbol,
|
||||
recipient_information=tx_recipient_information,
|
||||
token_balance=balance
|
||||
)
|
||||
|
||||
|
||||
def process_exit_successful_transaction(display_key: str, user: User, ussd_session: dict):
|
||||
"""This function processes the exit menu after a successful initiation for a transfer of tokens.
|
||||
:param display_key: The path in the translation files defining an appropriate ussd response
|
||||
:type display_key: str
|
||||
:param user: The user requesting access to the ussd menu.
|
||||
:type user: User
|
||||
: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 = 'SRF'
|
||||
recipient_phone_number = ussd_session.get('session_data').get('recipient_phone_number')
|
||||
sender_phone_number = user.phone_number
|
||||
tx_recipient_information = recipient_phone_number
|
||||
tx_sender_information = sender_phone_number
|
||||
|
||||
return translation_for(
|
||||
key=display_key,
|
||||
preferred_language=user.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(user: User, display_key: str, 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 user: The user requesting access to the ussd menu.
|
||||
:type user: User
|
||||
:param display_key: The path in the translation files defining an appropriate ussd response
|
||||
:type display_key: str
|
||||
: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')
|
||||
tx_recipient_information = recipient_phone_number
|
||||
tx_sender_information = user.phone_number
|
||||
logg.debug('Requires integration with cic-meta to get user name.')
|
||||
token_symbol = 'SRF'
|
||||
user_input = ussd_session.get('user_input').split('*')[-1]
|
||||
transaction_amount = to_wei(value=int(user_input))
|
||||
logg.debug('Requires integration to determine user tokens.')
|
||||
return process_pin_authorization(
|
||||
user=user,
|
||||
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_start_menu(display_key: str, user: User):
|
||||
"""This function gets data on an account's balance and token in order to append it to the start of the start menu's
|
||||
title. It passes said arguments to the translation function and returns the appropriate corresponding text from the
|
||||
translation files.
|
||||
:param user: The user requesting access to the ussd menu.
|
||||
:type user: User
|
||||
: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
|
||||
"""
|
||||
balance_manager = BalanceManager(address=user.blockchain_address,
|
||||
chain_str=UssdStateMachine.chain_str,
|
||||
token_symbol='SRF')
|
||||
balance = balance_manager.get_operational_balance()
|
||||
token_symbol = 'SRF'
|
||||
logg.debug("Requires integration to determine user's balance and token.")
|
||||
return translation_for(
|
||||
key=display_key,
|
||||
preferred_language=user.preferred_language,
|
||||
account_balance=balance,
|
||||
account_token_name=token_symbol
|
||||
)
|
||||
|
||||
|
||||
def process_request(user_input: str, user: User, 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 user: The user requesting access to the ussd menu.
|
||||
:type user: User
|
||||
: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(ussd_session=ussd_session, user=user, user_input=user_input)
|
||||
return UssdMenu.find_by_name(name=successive_state)
|
||||
else:
|
||||
if user.has_valid_pin():
|
||||
return UssdMenu.find_by_name(name='start')
|
||||
else:
|
||||
if user.failed_pin_attempts >= 3 and user.get_account_status() == AccountStatus.LOCKED.name:
|
||||
return UssdMenu.find_by_name(name='exit_pin_blocked')
|
||||
elif user.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(ussd_session: dict, user: User, 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 ussd_session: A JSON serialized in-memory ussd session object
|
||||
:type ussd_session: dict
|
||||
:param user: The user requesting access to the ussd menu.
|
||||
:type user: User
|
||||
: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, user))
|
||||
new_state = state_machine.state
|
||||
|
||||
return new_state
|
||||
|
||||
|
||||
def custom_display_text(
|
||||
display_key: str,
|
||||
menu_name: str,
|
||||
ussd_session: dict,
|
||||
user: User) -> 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 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 user: The user in a running USSD session.
|
||||
:type user: User
|
||||
: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(display_key=display_key, user=user, ussd_session=ussd_session)
|
||||
elif menu_name == 'exit_insufficient_balance':
|
||||
return process_exit_insufficient_balance(display_key=display_key, user=user, ussd_session=ussd_session)
|
||||
elif menu_name == 'exit_successful_transaction':
|
||||
return process_exit_successful_transaction(display_key=display_key, user=user, ussd_session=ussd_session)
|
||||
elif menu_name == 'start':
|
||||
return process_start_menu(display_key=display_key, user=user)
|
||||
else:
|
||||
return translation_for(key=display_key, preferred_language=user.preferred_language)
|
||||
6
apps/cic-ussd/cic_ussd/redis.py
Normal file
6
apps/cic-ussd/cic_ussd/redis.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# third-party imports
|
||||
from redis import Redis
|
||||
|
||||
|
||||
class InMemoryStore:
|
||||
cache: Redis = None
|
||||
134
apps/cic-ussd/cic_ussd/requests.py
Normal file
134
apps/cic-ussd/cic_ussd/requests.py
Normal file
@@ -0,0 +1,134 @@
|
||||
# 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
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db.models.user import AccountStatus, User
|
||||
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):
|
||||
"""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
|
||||
:return: A response denoting the result of the request to reset the user's pin.
|
||||
:rtype: str
|
||||
"""
|
||||
if not check_known_user(phone=phone_number):
|
||||
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), '200 OK'
|
||||
|
||||
if get_request_method(env) == 'GET':
|
||||
status = get_account_status(phone_number=phone_number)
|
||||
response = {
|
||||
'status': f'{status}'
|
||||
}
|
||||
response = json.dumps(response)
|
||||
return response, '200 OK'
|
||||
|
||||
|
||||
def process_locked_accounts_requests(env: dict) -> 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
|
||||
:return: A tuple containing a serialized list of blockchain addresses for locked accounts and corresponding message
|
||||
for the response.
|
||||
:rtype: tuple
|
||||
"""
|
||||
logg.debug('Authentication requires integration with cic-auth')
|
||||
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 = User.session.query(User.blockchain_address).filter(
|
||||
User.account_status == AccountStatus.LOCKED.value,
|
||||
User.failed_pin_attempts >= 3).order_by(desc(User.updated)).offset(offset).limit(limit).all()
|
||||
|
||||
# convert lists to scalar blockchain addresses
|
||||
locked_accounts = [blockchain_address for (blockchain_address, ) in locked_accounts]
|
||||
response = json.dumps(locked_accounts)
|
||||
return response, '200 OK'
|
||||
return response, '405 Play by the rules'
|
||||
0
apps/cic-ussd/cic_ussd/runnable/__init__.py
Normal file
0
apps/cic-ussd/cic_ussd/runnable/__init__.py
Normal file
182
apps/cic-ussd/cic_ussd/runnable/server.py
Normal file
182
apps/cic-ussd/cic_ussd/runnable/server.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""Functions defining WSGI interaction with external http requests
|
||||
Defines an application function essential for the uWSGI python loader to run th python application code.
|
||||
"""
|
||||
# standard imports
|
||||
import argparse
|
||||
import celery
|
||||
import i18n
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import redis
|
||||
|
||||
# third-party imports
|
||||
from confini import Config
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db import dsn_from_config
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.encoder import PasswordEncoder
|
||||
from cic_ussd.files.local_files import create_local_file_data_stores, json_file_parser
|
||||
from cic_ussd.menu.ussd_menu import UssdMenu
|
||||
from cic_ussd.operations import (define_response_with_content,
|
||||
process_menu_interaction_requests,
|
||||
define_multilingual_responses)
|
||||
from cic_ussd.redis import InMemoryStore
|
||||
from cic_ussd.requests import (get_request_endpoint,
|
||||
get_request_method,
|
||||
get_query_parameters,
|
||||
process_locked_accounts_requests,
|
||||
process_pin_reset_requests)
|
||||
from cic_ussd.session.ussd_session import UssdSession as InMemoryUssdSession
|
||||
from cic_ussd.state_machine import UssdStateMachine
|
||||
from cic_ussd.validator import check_ip, check_request_content_length, check_service_code, validate_phone_number
|
||||
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
logg = logging.getLogger()
|
||||
|
||||
config_directory = '/usr/local/etc/cic-ussd/'
|
||||
|
||||
# define arguments
|
||||
arg_parser = argparse.ArgumentParser()
|
||||
arg_parser.add_argument('-c', type=str, default=config_directory, help='config directory.')
|
||||
arg_parser.add_argument('-q', type=str, default='cic-ussd', help='queue name for worker tasks')
|
||||
arg_parser.add_argument('-v', action='store_true', help='be verbose')
|
||||
arg_parser.add_argument('-vv', action='store_true', help='be more verbose')
|
||||
arg_parser.add_argument('--env-prefix',
|
||||
default=os.environ.get('CONFINI_ENV_PREFIX'),
|
||||
dest='env_prefix',
|
||||
type=str,
|
||||
help='environment prefix for variables to overwrite configuration')
|
||||
args = arg_parser.parse_args()
|
||||
|
||||
# parse config
|
||||
config = Config(config_dir=args.c, env_prefix=args.env_prefix)
|
||||
config.process()
|
||||
config.censor('PASSWORD', 'DATABASE')
|
||||
|
||||
# define log levels
|
||||
if args.vv:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
elif args.v:
|
||||
logging.getLogger().setLevel(logging.INFO)
|
||||
|
||||
# log config vars
|
||||
logg.debug(config)
|
||||
|
||||
# initialize elements
|
||||
# set up translations
|
||||
i18n.load_path.append(config.get('APP_LOCALE_PATH'))
|
||||
i18n.set('fallback', config.get('APP_LOCALE_FALLBACK'))
|
||||
|
||||
# set Fernet key
|
||||
PasswordEncoder.set_key(config.get('APP_PASSWORD_PEPPER'))
|
||||
|
||||
# create in-memory databases
|
||||
ussd_menu_db = create_local_file_data_stores(file_location=config.get('USSD_MENU_FILE'),
|
||||
table_name='ussd_menu')
|
||||
UssdMenu.ussd_menu_db = ussd_menu_db
|
||||
|
||||
# set up db
|
||||
data_source_name = dsn_from_config(config)
|
||||
SessionBase.connect(data_source_name=data_source_name)
|
||||
# create session for the life time of http request
|
||||
SessionBase.session = SessionBase.create_session()
|
||||
|
||||
# define universal redis cache access
|
||||
InMemoryStore.cache = redis.StrictRedis(host=config.get('REDIS_HOSTNAME'),
|
||||
port=config.get('REDIS_PORT'),
|
||||
password=config.get('REDIS_PASSWORD'),
|
||||
db=config.get('REDIS_DATABASE'),
|
||||
decode_responses=True)
|
||||
InMemoryUssdSession.redis_cache = InMemoryStore.cache
|
||||
|
||||
# initialize celery app
|
||||
celery.Celery(backend=config.get('CELERY_RESULT_URL'), broker=config.get('CELERY_BROKER_URL'))
|
||||
|
||||
# load states and transitions data
|
||||
states = json_file_parser(filepath=config.get('STATEMACHINE_STATES'))
|
||||
transitions = json_file_parser(filepath=config.get('STATEMACHINE_TRANSITIONS'))
|
||||
|
||||
UssdStateMachine.chain_str = config.get('CIC_CHAIN_SPEC')
|
||||
UssdStateMachine.states = states
|
||||
UssdStateMachine.transitions = transitions
|
||||
|
||||
|
||||
def application(env, start_response):
|
||||
"""Loads python code for application to be accessible over web server
|
||||
:param env: Object containing server and request information
|
||||
:type env: dict
|
||||
:param start_response: Callable to define responses.
|
||||
:type start_response: any
|
||||
"""
|
||||
# define headers
|
||||
errors_headers = [('Content-Type', 'text/plain'), ('Content-Length', '0')]
|
||||
headers = [('Content-Type', 'text/plain')]
|
||||
|
||||
if get_request_method(env=env) == 'POST' and get_request_endpoint(env=env) == '/':
|
||||
|
||||
# get post data
|
||||
post_data = json.load(env.get('wsgi.input'))
|
||||
|
||||
service_code = post_data.get('serviceCode')
|
||||
phone_number = post_data.get('phoneNumber')
|
||||
external_session_id = post_data.get('sessionId')
|
||||
user_input = post_data.get('text')
|
||||
|
||||
# validate ip address
|
||||
if not check_ip(config=config, env=env):
|
||||
start_response('403 Sneaky, sneaky', errors_headers)
|
||||
return []
|
||||
|
||||
# validate content length
|
||||
if not check_request_content_length(config=config, env=env):
|
||||
start_response('400 Size matters', errors_headers)
|
||||
return []
|
||||
|
||||
# validate service code
|
||||
if not check_service_code(code=service_code, config=config):
|
||||
response = define_multilingual_responses(
|
||||
key='ussd.kenya.invalid_service_code',
|
||||
locales=['en', 'sw'],
|
||||
prefix='END',
|
||||
valid_service_code=config.get('APP_SERVICE_CODE'))
|
||||
response_bytes, headers = define_response_with_content(headers=errors_headers, response=response)
|
||||
start_response('400 Invalid service code', headers)
|
||||
return [response_bytes]
|
||||
|
||||
# validate phone number
|
||||
if not validate_phone_number(phone_number):
|
||||
start_response('400 Invalid phone number format', errors_headers)
|
||||
return []
|
||||
|
||||
# handle menu interaction requests
|
||||
response = process_menu_interaction_requests(chain_str=config.get('CIC_CHAIN_SPEC'),
|
||||
external_session_id=external_session_id,
|
||||
phone_number=phone_number,
|
||||
queue=args.q,
|
||||
service_code=service_code,
|
||||
user_input=user_input)
|
||||
|
||||
response_bytes, headers = define_response_with_content(headers=headers, response=response)
|
||||
start_response('200 OK,', headers)
|
||||
SessionBase.session.close()
|
||||
return [response_bytes]
|
||||
|
||||
# handle pin requests
|
||||
if get_request_endpoint(env) == '/pin':
|
||||
phone_number = get_query_parameters(env=env, query_name='phoneNumber')
|
||||
phone_number = quote_plus(phone_number)
|
||||
response, message = process_pin_reset_requests(env=env, phone_number=phone_number)
|
||||
response_bytes, headers = define_response_with_content(headers=errors_headers, response=response)
|
||||
SessionBase.session.close()
|
||||
start_response(message, headers)
|
||||
return [response_bytes]
|
||||
|
||||
# handle requests for locked accounts
|
||||
response, message = process_locked_accounts_requests(env=env)
|
||||
response_bytes, headers = define_response_with_content(headers=headers, response=response)
|
||||
start_response(message, headers)
|
||||
SessionBase.session.close()
|
||||
return [response_bytes]
|
||||
113
apps/cic-ussd/cic_ussd/runnable/tasker.py
Normal file
113
apps/cic-ussd/cic_ussd/runnable/tasker.py
Normal file
@@ -0,0 +1,113 @@
|
||||
# standard imports
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
# third party imports
|
||||
import celery
|
||||
import redis
|
||||
from confini import Config
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db import dsn_from_config
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.redis import InMemoryStore
|
||||
from cic_ussd.session.ussd_session import UssdSession as InMemoryUssdSession
|
||||
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
logg = logging.getLogger()
|
||||
|
||||
config_directory = '/usr/local/etc/cic-ussd/'
|
||||
|
||||
# define arguments
|
||||
arg_parser = argparse.ArgumentParser()
|
||||
arg_parser.add_argument('-c', type=str, default=config_directory, help='config directory.')
|
||||
arg_parser.add_argument('-q', type=str, default='cic-ussd', help='queue name for worker tasks')
|
||||
arg_parser.add_argument('-v', action='store_true', help='be verbose')
|
||||
arg_parser.add_argument('-vv', action='store_true', help='be more verbose')
|
||||
arg_parser.add_argument('--env-prefix', default=os.environ.get('CONFINI_ENV_PREFIX'), dest='env_prefix', type=str, help='environment prefix for variables to overwrite configuration')
|
||||
args = arg_parser.parse_args()
|
||||
|
||||
# parse config
|
||||
config = Config(config_dir=args.c, env_prefix=args.env_prefix)
|
||||
config.process()
|
||||
config.censor('PASSWORD', 'DATABASE')
|
||||
|
||||
# define log levels
|
||||
if args.vv:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
elif args.v:
|
||||
logging.getLogger().setLevel(logging.INFO)
|
||||
|
||||
logg.debug(config)
|
||||
|
||||
# connect to database
|
||||
data_source_name = dsn_from_config(config)
|
||||
SessionBase.connect(data_source_name=data_source_name)
|
||||
|
||||
# verify database connection with minimal sanity query
|
||||
session = SessionBase.create_session()
|
||||
session.execute('SELECT version_num FROM alembic_version')
|
||||
session.close()
|
||||
|
||||
# define universal redis cache access
|
||||
InMemoryStore.cache = redis.StrictRedis(host=config.get('REDIS_HOSTNAME'),
|
||||
port=config.get('REDIS_PORT'),
|
||||
password=config.get('REDIS_PASSWORD'),
|
||||
db=config.get('REDIS_DATABASE'),
|
||||
decode_responses=True)
|
||||
InMemoryUssdSession.redis_cache = InMemoryStore.cache
|
||||
|
||||
# set up celery
|
||||
current_app = celery.Celery(__name__)
|
||||
|
||||
# define celery configs
|
||||
broker = config.get('CELERY_BROKER_URL')
|
||||
if broker[:4] == 'file':
|
||||
broker_queue = tempfile.mkdtemp()
|
||||
broker_processed = tempfile.mkdtemp()
|
||||
current_app.conf.update({
|
||||
'broker_url': broker,
|
||||
'broker_transport_options': {
|
||||
'data_folder_in': broker_queue,
|
||||
'data_folder_out': broker_queue,
|
||||
'data_folder_processed': broker_processed
|
||||
},
|
||||
})
|
||||
logg.warning(
|
||||
f'celery broker dirs queue i/o {broker_queue} processed {broker_processed}, will NOT be deleted on shutdown')
|
||||
else:
|
||||
current_app.conf.update({
|
||||
'broker_url': broker
|
||||
})
|
||||
|
||||
result = config.get('CELERY_RESULT_URL')
|
||||
if result[:4] == 'file':
|
||||
result_queue = tempfile.mkdtemp()
|
||||
current_app.conf.update({
|
||||
'result_backend': 'file://{}'.format(result_queue),
|
||||
})
|
||||
logg.warning('celery backend store dir {} created, will NOT be deleted on shutdown'.format(result_queue))
|
||||
else:
|
||||
current_app.conf.update({
|
||||
'result_backend': result,
|
||||
})
|
||||
import cic_ussd.tasks
|
||||
|
||||
|
||||
def main():
|
||||
argv = ['worker']
|
||||
if args.vv:
|
||||
argv.append('--loglevel=DEBUG')
|
||||
elif args.v:
|
||||
argv.append('--loglevel=INFO')
|
||||
argv.append('-Q')
|
||||
argv.append(args.q)
|
||||
|
||||
current_app.worker_main(argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
0
apps/cic-ussd/cic_ussd/session/__init__.py
Normal file
0
apps/cic-ussd/cic_ussd/session/__init__.py
Normal file
107
apps/cic-ussd/cic_ussd/session/ussd_session.py
Normal file
107
apps/cic-ussd/cic_ussd/session/ussd_session.py
Normal file
@@ -0,0 +1,107 @@
|
||||
# standard imports
|
||||
import logging
|
||||
from typing import Optional
|
||||
import json
|
||||
|
||||
# third party imports
|
||||
from redis import Redis
|
||||
|
||||
|
||||
logg = logging.getLogger()
|
||||
|
||||
|
||||
class UssdSession:
|
||||
"""
|
||||
This class defines the USSD session object that is called whenever a user interacts with the system.
|
||||
:cvar redis_cache: The in-memory redis cache.
|
||||
:type redis_cache: Redis
|
||||
"""
|
||||
redis_cache: Redis = None
|
||||
|
||||
def __init__(self,
|
||||
external_session_id: str,
|
||||
service_code: str,
|
||||
msisdn: str,
|
||||
user_input: str,
|
||||
state: str,
|
||||
session_data: Optional[dict] = None):
|
||||
"""
|
||||
This function is called whenever a USSD session object is created and saves the instance to a JSON DB.
|
||||
:param external_session_id: The Africa's Talking session ID.
|
||||
:type external_session_id: str.
|
||||
:param service_code: The USSD service code from which the user used to gain access to the system.
|
||||
:type service_code: str.
|
||||
:param msisdn: The user's phone number.
|
||||
:type msisdn: str.
|
||||
:param user_input: The data or choice the user has made while interacting with the system.
|
||||
:type user_input: str.
|
||||
:param state: The name of the USSD menu that the user was interacting with.
|
||||
:type state: str.
|
||||
:param session_data: Any additional data that was persisted during the user's interaction with the system.
|
||||
:type session_data: dict.
|
||||
"""
|
||||
self.external_session_id = external_session_id
|
||||
self.service_code = service_code
|
||||
self.msisdn = msisdn
|
||||
self.user_input = user_input
|
||||
self.state = state
|
||||
self.session_data = session_data
|
||||
session = self.redis_cache.get(external_session_id)
|
||||
if session:
|
||||
session = json.loads(session)
|
||||
self.version = session.get('version') + 1
|
||||
else:
|
||||
self.version = 1
|
||||
|
||||
self.session = {
|
||||
'external_session_id': self.external_session_id,
|
||||
'service_code': self.service_code,
|
||||
'msisdn': self.msisdn,
|
||||
'user_input': self.user_input,
|
||||
'state': self.state,
|
||||
'session_data': self.session_data,
|
||||
'version': self.version
|
||||
}
|
||||
self.redis_cache.set(self.external_session_id, json.dumps(self.session))
|
||||
self.redis_cache.persist(self.external_session_id)
|
||||
|
||||
def set_data(self, key: str, value: str) -> None:
|
||||
"""
|
||||
This function adds or updates data to the session data.
|
||||
:param key: The name used to identify the data.
|
||||
:type key: str.
|
||||
:param value: The actual data to be stored in the session data.
|
||||
:type value: str.
|
||||
"""
|
||||
if self.session_data is None:
|
||||
self.session_data = {}
|
||||
self.session_data[key] = value
|
||||
self.redis_cache.set(self.external_session_id, json.dumps(self.session))
|
||||
|
||||
def get_data(self, key: str) -> Optional[str]:
|
||||
"""
|
||||
This function attempts to fetch data from the session data using the identifier for the specific data.
|
||||
:param key: The name used as the identifier for the specific data.
|
||||
:type key: str.
|
||||
:return: This function returns the queried data if found, else it doesn't return any value.
|
||||
:rtype: str.
|
||||
"""
|
||||
if self.session_data is not None:
|
||||
return self.session_data.get(key)
|
||||
else:
|
||||
return None
|
||||
|
||||
def to_json(self):
|
||||
""" This function serializes the in memory ussd session object to a JSON object
|
||||
:return: A JSON object of a ussd session in memory
|
||||
:rtype: dict
|
||||
"""
|
||||
return {
|
||||
"external_session_id": self.external_session_id,
|
||||
"service_code": self.service_code,
|
||||
"msisdn": self.msisdn,
|
||||
"user_input": self.user_input,
|
||||
"state": self.state,
|
||||
"session_data": self.session_data,
|
||||
"version": self.version
|
||||
}
|
||||
1
apps/cic-ussd/cic_ussd/state_machine/__init__.py
Normal file
1
apps/cic-ussd/cic_ussd/state_machine/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .state_machine import UssdStateMachine
|
||||
16
apps/cic-ussd/cic_ussd/state_machine/logic/__init__.py
Normal file
16
apps/cic-ussd/cic_ussd/state_machine/logic/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# standard imports
|
||||
import os
|
||||
import re
|
||||
from glob import glob
|
||||
from importlib import import_module
|
||||
from os.path import basename, dirname, isfile, join
|
||||
|
||||
# get all modules in the directory
|
||||
modules = glob(join(dirname(__file__), "*.py"))
|
||||
|
||||
for file in modules:
|
||||
# exclude 'init.py'
|
||||
if isfile(file) and not re.match(r'^__', os.path.basename(file)):
|
||||
# strip .py extension
|
||||
file_name = basename(file[:-3])
|
||||
import_module("." + file_name, package=__name__)
|
||||
20
apps/cic-ussd/cic_ussd/state_machine/logic/balance.py
Normal file
20
apps/cic-ussd/cic_ussd/state_machine/logic/balance.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# standard imports
|
||||
import logging
|
||||
from typing import Tuple
|
||||
|
||||
# third-party imports
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db.models.user import User
|
||||
|
||||
logg = logging.getLogger(__file__)
|
||||
|
||||
|
||||
def process_mini_statement_request(state_machine_data: Tuple[str, dict, User]):
|
||||
"""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 = state_machine_data
|
||||
logg.debug('This section requires integration with cic-eth. (The last 6 transactions would be sent as an sms.)')
|
||||
90
apps/cic-ussd/cic_ussd/state_machine/logic/menu.py
Normal file
90
apps/cic-ussd/cic_ussd/state_machine/logic/menu.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""This module defines functions responsible for interaction with the ussd menu. It takes user input and navigates the
|
||||
ussd menu facilitating the return of appropriate menu responses based on said user input.
|
||||
"""
|
||||
|
||||
# standard imports
|
||||
from typing import Tuple
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db.models.user import User
|
||||
|
||||
|
||||
def menu_one_selected(state_machine_data: Tuple[str, dict, User]) -> bool:
|
||||
"""This function checks that user input matches a string with value '1'
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: str
|
||||
:return: A user input's match with '1'
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
return user_input == '1'
|
||||
|
||||
|
||||
def menu_two_selected(state_machine_data: Tuple[str, dict, User]) -> bool:
|
||||
"""This function checks that user input matches a string with value '2'
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
:return: A user input's match with '2'
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
return user_input == '2'
|
||||
|
||||
|
||||
def menu_three_selected(state_machine_data: Tuple[str, dict, User]) -> bool:
|
||||
"""This function checks that user input matches a string with value '3'
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
:return: A user input's match with '3'
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
return user_input == '3'
|
||||
|
||||
|
||||
def menu_four_selected(state_machine_data: Tuple[str, dict, User]) -> bool:
|
||||
"""
|
||||
This function checks that user input matches a string with value '4'
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
:return: A user input's match with '4'
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
return user_input == '4'
|
||||
|
||||
|
||||
def menu_five_selected(state_machine_data: Tuple[str, dict, User]) -> bool:
|
||||
"""
|
||||
This function checks that user input matches a string with value '5'
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
:return: A user input's match with '5'
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
return user_input == '5'
|
||||
|
||||
|
||||
def menu_zero_zero_selected(state_machine_data: Tuple[str, dict, User]) -> bool:
|
||||
"""
|
||||
This function checks that user input matches a string with value '00'
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
:return: A user input's match with '00'
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
return user_input == '00'
|
||||
|
||||
|
||||
def menu_ninety_nine_selected(state_machine_data: Tuple[str, dict, User]) -> bool:
|
||||
"""
|
||||
This function checks that user input matches a string with value '99'
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
:return: A user input's match with '99'
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
return user_input == '99'
|
||||
143
apps/cic-ussd/cic_ussd/state_machine/logic/pin.py
Normal file
143
apps/cic-ussd/cic_ussd/state_machine/logic/pin.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""This module defines functions responsible for creation, validation, reset and any other manipulations on the
|
||||
user's pin.
|
||||
"""
|
||||
|
||||
# standard imports
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Tuple
|
||||
|
||||
# third party imports
|
||||
import bcrypt
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db.models.user import AccountStatus, User
|
||||
from cic_ussd.encoder import PasswordEncoder, create_password_hash
|
||||
from cic_ussd.operations import persist_session_to_db_task, create_or_update_session
|
||||
from cic_ussd.redis import InMemoryStore
|
||||
|
||||
|
||||
logg = logging.getLogger(__file__)
|
||||
|
||||
|
||||
def is_valid_pin(state_machine_data: Tuple[str, dict, User]) -> bool:
|
||||
"""This function checks a pin's validity by ensuring it has a length of for characters and the characters are
|
||||
numeric.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
:return: A pin's validity
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
pin_is_valid = False
|
||||
matcher = r'^\d{4}$'
|
||||
if re.match(matcher, user_input):
|
||||
pin_is_valid = True
|
||||
return pin_is_valid
|
||||
|
||||
|
||||
def is_authorized_pin(state_machine_data: Tuple[str, dict, User]) -> bool:
|
||||
"""This function checks whether the user input confirming a specific pin matches the initial pin entered.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
:return: A match between two pin values.
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
return user.verify_password(password=user_input)
|
||||
|
||||
|
||||
def is_locked_account(state_machine_data: Tuple[str, dict, User]) -> bool:
|
||||
"""This function checks whether a user's account is locked due to too many failed attempts.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
:return: A match between two pin values.
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
return user.get_account_status() == AccountStatus.LOCKED.name
|
||||
|
||||
|
||||
def save_initial_pin_to_session_data(state_machine_data: Tuple[str, dict, User]):
|
||||
"""This function hashes a pin and stores it in session data.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
|
||||
# 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)
|
||||
session_data = {
|
||||
'initial_pin': initial_pin
|
||||
}
|
||||
|
||||
# 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=user_input,
|
||||
current_menu=in_redis_ussd_session.get('state'),
|
||||
session_data=session_data
|
||||
)
|
||||
persist_session_to_db_task(external_session_id=external_session_id, queue='cic-ussd')
|
||||
|
||||
|
||||
def pins_match(state_machine_data: Tuple[str, dict, User]) -> bool:
|
||||
"""This function checks whether the user input confirming a specific pin matches the initial pin entered.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
:return: A match between two pin values.
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
initial_pin = ussd_session.get('session_data').get('initial_pin')
|
||||
fernet = PasswordEncoder(PasswordEncoder.key)
|
||||
initial_pin = fernet.decrypt(initial_pin.encode())
|
||||
return bcrypt.checkpw(user_input.encode(), initial_pin)
|
||||
|
||||
|
||||
def complete_pin_change(state_machine_data: Tuple[str, dict, User]):
|
||||
"""This function persists the user's pin to the database
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
password_hash = ussd_session.get('session_data').get('initial_pin')
|
||||
user.password_hash = password_hash
|
||||
User.session.add(user)
|
||||
User.session.commit()
|
||||
|
||||
|
||||
def is_blocked_pin(state_machine_data: Tuple[str, dict, User]) -> bool:
|
||||
"""This function checks whether the user input confirming a specific pin matches the initial pin entered.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
:return: A match between two pin values.
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
return user.get_account_status() == AccountStatus.LOCKED.name
|
||||
|
||||
|
||||
def is_valid_new_pin(state_machine_data: Tuple[str, dict, User]) -> bool:
|
||||
"""This function checks whether the user's new pin is a valid pin and that it isn't the same as the old one.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
:return: A match between two pin values.
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
is_old_pin = user.verify_password(password=user_input)
|
||||
return is_valid_pin(state_machine_data=state_machine_data) and not is_old_pin
|
||||
23
apps/cic-ussd/cic_ussd/state_machine/logic/sms.py
Normal file
23
apps/cic-ussd/cic_ussd/state_machine/logic/sms.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# standard imports
|
||||
import logging
|
||||
from typing import Tuple
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db.models.user import User
|
||||
|
||||
logg = logging.getLogger()
|
||||
|
||||
|
||||
def send_terms_to_user_if_required(state_machine_data: Tuple[str, dict, User]):
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
logg.debug('Requires integration to cic-notify.')
|
||||
|
||||
|
||||
def process_mini_statement_request(state_machine_data: Tuple[str, dict, User]):
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
logg.debug('Requires integration to cic-notify.')
|
||||
|
||||
|
||||
def upsell_unregistered_recipient(state_machine_data: Tuple[str, dict, User]):
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
logg.debug('Requires integration to cic-notify.')
|
||||
119
apps/cic-ussd/cic_ussd/state_machine/logic/transaction.py
Normal file
119
apps/cic-ussd/cic_ussd/state_machine/logic/transaction.py
Normal file
@@ -0,0 +1,119 @@
|
||||
# standard imports
|
||||
import logging
|
||||
from typing import Tuple
|
||||
|
||||
# third party imports
|
||||
|
||||
# local imports
|
||||
from cic_ussd.accounts import BalanceManager
|
||||
from cic_ussd.db.models.user import AccountStatus, User
|
||||
from cic_ussd.operations import get_user_by_phone_number, save_to_in_memory_ussd_session_data
|
||||
from cic_ussd.state_machine.state_machine import UssdStateMachine
|
||||
from cic_ussd.transactions import OutgoingTransactionProcessor
|
||||
|
||||
|
||||
logg = logging.getLogger(__file__)
|
||||
|
||||
|
||||
def is_valid_recipient(state_machine_data: Tuple[str, dict, User]) -> bool:
|
||||
"""This function checks that a user exists, is not the initiator of the transaction, has an active account status
|
||||
and is authorized to perform standard transactions.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
:return: A user's validity
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
recipient = get_user_by_phone_number(phone_number=user_input)
|
||||
is_not_initiator = user_input != user.phone_number
|
||||
has_active_account_status = user.get_account_status() == AccountStatus.ACTIVE.name
|
||||
logg.debug('This section requires implementation of checks for user roles and authorization status of an account.')
|
||||
return is_not_initiator and has_active_account_status
|
||||
|
||||
|
||||
def is_valid_token_agent(state_machine_data: Tuple[str, dict, User]) -> bool:
|
||||
"""This function checks that a user exists, is not the initiator of the transaction, has an active account status
|
||||
and is authorized to perform exchange transactions.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
:return: A user's validity
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
# is_token_agent = AccountRole.TOKEN_AGENT.value in user.get_user_roles()
|
||||
logg.debug('This section requires implementation of user roles and authorization to facilitate exchanges.')
|
||||
return is_valid_recipient(state_machine_data=state_machine_data)
|
||||
|
||||
|
||||
def is_valid_transaction_amount(state_machine_data: Tuple[str, dict, User]) -> bool:
|
||||
"""This function checks that the transaction amount provided is valid as per the criteria for the transaction
|
||||
being attempted.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
:return: A transaction amount's validity
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
try:
|
||||
return int(user_input) > 0
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def has_sufficient_balance(state_machine_data: Tuple[str, dict, User]) -> bool:
|
||||
"""This function checks that the transaction amount provided is valid as per the criteria for the transaction
|
||||
being attempted.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
:return: An account balance's validity
|
||||
:rtype: bool
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
balance_manager = BalanceManager(address=user.blockchain_address,
|
||||
chain_str=UssdStateMachine.chain_str,
|
||||
token_symbol='SRF')
|
||||
balance = balance_manager.get_operational_balance()
|
||||
return int(user_input) <= balance
|
||||
|
||||
|
||||
def save_recipient_phone_to_session_data(state_machine_data: Tuple[str, dict, User]):
|
||||
"""This function saves the phone number corresponding the intended recipients blockchain account.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: str
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
session_data = {
|
||||
'recipient_phone_number': user_input
|
||||
}
|
||||
save_to_in_memory_ussd_session_data(queue='cic-ussd', session_data=session_data, ussd_session=ussd_session)
|
||||
|
||||
|
||||
def save_transaction_amount_to_session_data(state_machine_data: Tuple[str, dict, User]):
|
||||
"""This function saves the phone number corresponding the intended recipients blockchain account.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: str
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
session_data = {
|
||||
'transaction_amount': user_input
|
||||
}
|
||||
save_to_in_memory_ussd_session_data(queue='cic-ussd', session_data=session_data, ussd_session=ussd_session)
|
||||
|
||||
|
||||
def process_transaction_request(state_machine_data: Tuple[str, dict, User]):
|
||||
"""This function saves the phone number corresponding the intended recipients blockchain account.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: str
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
|
||||
# get user from phone number
|
||||
recipient_phone_number = ussd_session.get('session_data').get('recipient_phone_number')
|
||||
recipient = get_user_by_phone_number(phone_number=recipient_phone_number)
|
||||
to_address = recipient.blockchain_address
|
||||
from_address = user.blockchain_address
|
||||
amount = int(ussd_session.get('session_data').get('transaction_amount'))
|
||||
outgoing_tx_processor = OutgoingTransactionProcessor(chain_str=UssdStateMachine.chain_str,
|
||||
from_address=from_address,
|
||||
to_address=to_address)
|
||||
outgoing_tx_processor.process_outgoing_transfer_transaction(amount=amount)
|
||||
89
apps/cic-ussd/cic_ussd/state_machine/logic/user.py
Normal file
89
apps/cic-ussd/cic_ussd/state_machine/logic/user.py
Normal file
@@ -0,0 +1,89 @@
|
||||
# standard imports
|
||||
import logging
|
||||
from typing import Tuple
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db.models.user import User
|
||||
from cic_ussd.operations import save_to_in_memory_ussd_session_data
|
||||
|
||||
logg = logging.getLogger(__file__)
|
||||
|
||||
|
||||
def change_preferred_language_to_en(state_machine_data: Tuple[str, dict, User]):
|
||||
"""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 = state_machine_data
|
||||
user.preferred_language = 'en'
|
||||
User.session.add(user)
|
||||
User.session.commit()
|
||||
|
||||
|
||||
def change_preferred_language_to_sw(state_machine_data: Tuple[str, dict, User]):
|
||||
"""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, user = state_machine_data
|
||||
user.preferred_language = 'sw'
|
||||
User.session.add(user)
|
||||
User.session.commit()
|
||||
|
||||
|
||||
def update_account_status_to_active(state_machine_data: Tuple[str, dict, User]):
|
||||
"""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, user = state_machine_data
|
||||
user.activate_account()
|
||||
User.session.add(user)
|
||||
User.session.commit()
|
||||
|
||||
|
||||
def save_profile_attribute_to_session_data(state_machine_data: Tuple[str, dict, User]):
|
||||
"""This function saves first name data to the ussd session in the redis cache.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
|
||||
# get current menu
|
||||
current_state = ussd_session.get('state')
|
||||
|
||||
# define session data key from current state
|
||||
key = ''
|
||||
if 'first_name' in current_state:
|
||||
key = 'first_name'
|
||||
elif 'last_name' in current_state:
|
||||
key = 'last_name'
|
||||
elif 'gender' in current_state:
|
||||
key = 'gender'
|
||||
elif 'location' in current_state:
|
||||
key = 'location'
|
||||
elif 'business_profile' in current_state:
|
||||
key = 'business_profile'
|
||||
|
||||
# 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_data=session_data, ussd_session=ussd_session)
|
||||
|
||||
|
||||
def persist_profile_data(state_machine_data: Tuple[str, dict, User]):
|
||||
"""This function persists elements of the user profile stored in session data
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: tuple
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
|
||||
# get session data
|
||||
profile_data = ussd_session.get('session_data')
|
||||
logg.debug('This section requires implementation of user metadata.')
|
||||
|
||||
68
apps/cic-ussd/cic_ussd/state_machine/logic/validator.py
Normal file
68
apps/cic-ussd/cic_ussd/state_machine/logic/validator.py
Normal file
@@ -0,0 +1,68 @@
|
||||
# standard imports
|
||||
import logging
|
||||
import re
|
||||
from typing import Tuple
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db.models.user import User
|
||||
|
||||
logg = logging.getLogger()
|
||||
|
||||
|
||||
def has_complete_profile_data(state_machine_data: Tuple[str, dict, User]):
|
||||
"""This function checks whether the attributes of the user's metadata constituting a profile are filled out.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: str
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
logg.debug('This section requires implementation of user metadata.')
|
||||
|
||||
|
||||
def has_empty_username_data(state_machine_data: Tuple[str, dict, User]):
|
||||
"""This function checks whether the aspects of the user's name metadata is filled out.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: str
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
logg.debug('This section requires implementation of user metadata.')
|
||||
|
||||
|
||||
def has_empty_gender_data(state_machine_data: Tuple[str, dict, User]):
|
||||
"""This function checks whether the aspects of the user's gender metadata is filled out.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: str
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
logg.debug('This section requires implementation of user metadata.')
|
||||
|
||||
|
||||
def has_empty_location_data(state_machine_data: Tuple[str, dict, User]):
|
||||
"""This function checks whether the aspects of the user's location metadata is filled out.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: str
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
logg.debug('This section requires implementation of user metadata.')
|
||||
|
||||
|
||||
def has_empty_business_profile_data(state_machine_data: Tuple[str, dict, User]):
|
||||
"""This function checks whether the aspects of the user's business profile metadata is filled out.
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: str
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
logg.debug('This section requires implementation of user metadata.')
|
||||
|
||||
|
||||
def is_valid_name(state_machine_data: Tuple[str, dict, User]):
|
||||
"""This function checks that a user provided name is valid
|
||||
:param state_machine_data: A tuple containing user input, a ussd session and user object.
|
||||
:type state_machine_data: str
|
||||
"""
|
||||
user_input, ussd_session, user = state_machine_data
|
||||
name_matcher = "^[a-zA-Z]+$"
|
||||
valid_name = re.match(name_matcher, user_input)
|
||||
if valid_name:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
42
apps/cic-ussd/cic_ussd/state_machine/state_machine.py
Normal file
42
apps/cic-ussd/cic_ussd/state_machine/state_machine.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# standard imports
|
||||
import logging
|
||||
|
||||
# third party imports
|
||||
from transitions import Machine
|
||||
|
||||
# local imports
|
||||
|
||||
logg = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UssdStateMachine(Machine):
|
||||
"""This class describes a finite state machine responsible for maintaining all the states that describe the ussd
|
||||
menu as well as providing a means for navigating through these states based on different user inputs.
|
||||
It defines different helper functions that co-ordinate with the stakeholder components of the ussd menu: i.e the
|
||||
User, UssdSession, UssdMenu to facilitate user interaction with ussd menu.
|
||||
|
||||
:cvar chain_str: The chain name and network id.
|
||||
:type chain_str: str
|
||||
:cvar states: A list of pre-defined states.
|
||||
:type states: list
|
||||
:cvar transitions: A list of pre-defined transitions.
|
||||
:type transitions: list
|
||||
"""
|
||||
chain_str = None
|
||||
states = []
|
||||
transitions = []
|
||||
|
||||
def __repr__(self):
|
||||
return f'<KenyaUssdStateMachine: {self.state}>'
|
||||
|
||||
def __init__(self, ussd_session: dict):
|
||||
"""
|
||||
:param ussd_session: A Ussd session object that contains contextual data that informs the state machine's state
|
||||
changes.
|
||||
:type ussd_session: dict
|
||||
"""
|
||||
self.ussd_session = ussd_session
|
||||
super(UssdStateMachine, self).__init__(initial=ussd_session.get('state'),
|
||||
model=self,
|
||||
states=self.states,
|
||||
transitions=self.transitions)
|
||||
15
apps/cic-ussd/cic_ussd/tasks/__init__.py
Normal file
15
apps/cic-ussd/cic_ussd/tasks/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
# standard import
|
||||
import os
|
||||
import logging
|
||||
import urllib
|
||||
import json
|
||||
|
||||
# third-party imports
|
||||
# this must be included for the package to be recognized as a tasks package
|
||||
import celery
|
||||
|
||||
celery_app = celery.current_app
|
||||
# export external celery task modules
|
||||
from .foo import log_it_plz
|
||||
from .ussd import persist_session_to_db
|
||||
from .callback_handler import process_account_creation_callback
|
||||
114
apps/cic-ussd/cic_ussd/tasks/callback_handler.py
Normal file
114
apps/cic-ussd/cic_ussd/tasks/callback_handler.py
Normal file
@@ -0,0 +1,114 @@
|
||||
# standard imports
|
||||
import json
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
# third-party imports
|
||||
import celery
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.db.models.user import User
|
||||
from cic_ussd.error import ActionDataNotFoundError
|
||||
from cic_ussd.redis import InMemoryStore
|
||||
from cic_ussd.transactions import IncomingTransactionProcessor
|
||||
|
||||
logg = logging.getLogger(__file__)
|
||||
celery_app = celery.current_app
|
||||
|
||||
|
||||
@celery_app.task(bind=True)
|
||||
def process_account_creation_callback(self, result: str, url: str, status_code: int):
|
||||
"""This function defines a task that creates a user and
|
||||
:param result: The blockchain address for the created account
|
||||
:type result: str
|
||||
:param url: URL provided to callback task in cic-eth should http be used for callback.
|
||||
:type url: str
|
||||
:param status_code: The status of the task to create an account
|
||||
:type status_code: int
|
||||
"""
|
||||
session = SessionBase.create_session()
|
||||
cache = InMemoryStore.cache
|
||||
task_id = self.request.root_id
|
||||
|
||||
# get account creation status
|
||||
account_creation_data = cache.get(task_id)
|
||||
|
||||
# check status
|
||||
if account_creation_data:
|
||||
account_creation_data = json.loads(account_creation_data)
|
||||
if status_code == 0:
|
||||
# update redis data
|
||||
account_creation_data['status'] = 'CREATED'
|
||||
cache.set(name=task_id, value=json.dumps(account_creation_data))
|
||||
cache.persist(task_id)
|
||||
|
||||
phone_number = account_creation_data.get('phone_number')
|
||||
|
||||
# create user
|
||||
user = User(blockchain_address=result, phone_number=phone_number)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
|
||||
# expire cache
|
||||
cache.expire(task_id, timedelta(seconds=30))
|
||||
session.close()
|
||||
|
||||
else:
|
||||
cache.expire(task_id, timedelta(seconds=30))
|
||||
session.close()
|
||||
|
||||
else:
|
||||
session.close()
|
||||
raise ActionDataNotFoundError(f'Account creation task: {task_id}, returned unexpected response: {status_code}')
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def process_incoming_transfer_callback(result: dict, param: str, status_code: int):
|
||||
logg.debug(f'PARAM: {param}, RESULT: {result}, STATUS_CODE: {status_code}')
|
||||
session = SessionBase.create_session()
|
||||
if result and status_code == 0:
|
||||
|
||||
# collect result data
|
||||
recipient_blockchain_address = result.get('recipient')
|
||||
sender_blockchain_address = result.get('sender')
|
||||
token_symbol = result.get('token_symbol')
|
||||
value = result.get('destination_value')
|
||||
|
||||
# try to find users in system
|
||||
recipient_user = session.query(User).filter_by(blockchain_address=recipient_blockchain_address).first()
|
||||
sender_user = session.query(User).filter_by(blockchain_address=sender_blockchain_address).first()
|
||||
|
||||
# check whether recipient is in the system
|
||||
if not recipient_user:
|
||||
session.close()
|
||||
raise ValueError(
|
||||
f'Tx for recipient: {recipient_blockchain_address} was received but has no matching user in the system.'
|
||||
)
|
||||
|
||||
# process incoming transactions
|
||||
incoming_tx_processor = IncomingTransactionProcessor(phone_number=recipient_user.phone_number,
|
||||
preferred_language=recipient_user.preferred_language,
|
||||
token_symbol=token_symbol,
|
||||
value=value)
|
||||
|
||||
if param == 'tokengift':
|
||||
logg.debug('Name information would require integration with cic meta.')
|
||||
incoming_tx_processor.process_token_gift_incoming_transactions(first_name="")
|
||||
elif param == 'transfer':
|
||||
logg.debug('Name information would require integration with cic meta.')
|
||||
if sender_user:
|
||||
sender_information = f'{sender_user.phone_number}, {""}, {""}'
|
||||
incoming_tx_processor.process_transfer_incoming_transaction(sender_information=sender_information)
|
||||
else:
|
||||
logg.warning(
|
||||
f'Tx with sender: {sender_blockchain_address} was received but has no matching user in the system.'
|
||||
)
|
||||
incoming_tx_processor.process_transfer_incoming_transaction(
|
||||
sender_information=sender_blockchain_address)
|
||||
else:
|
||||
session.close()
|
||||
raise ValueError(f'Unexpected transaction param: {param}.')
|
||||
else:
|
||||
session.close()
|
||||
raise ValueError(f'Unexpected status code: {status_code}.')
|
||||
11
apps/cic-ussd/cic_ussd/tasks/foo.py
Normal file
11
apps/cic-ussd/cic_ussd/tasks/foo.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# 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))
|
||||
72
apps/cic-ussd/cic_ussd/tasks/ussd.py
Normal file
72
apps/cic-ussd/cic_ussd/tasks/ussd.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# standard imports
|
||||
import json
|
||||
from datetime import timedelta
|
||||
|
||||
# third party imports
|
||||
import celery
|
||||
from celery.utils.log import get_logger
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db.models.base import SessionBase
|
||||
from cic_ussd.db.models.ussd_session import UssdSession
|
||||
from cic_ussd.error import SessionNotFoundError
|
||||
from cic_ussd.session.ussd_session import UssdSession as InMemoryUssdSession
|
||||
|
||||
celery_app = celery.current_app
|
||||
logg = get_logger(__file__)
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def persist_session_to_db(external_session_id: str):
|
||||
"""
|
||||
This task initiates the saving of the session object to the database and it's removal from the in-memory storage.
|
||||
:param external_session_id: The session id of the session to be saved.
|
||||
:type external_session_id: str.
|
||||
:return: The representation of the newly created database object or en error message if session is not found.
|
||||
:rtype: str.
|
||||
:raises SessionNotFoundError: If the session object is not found in memory.
|
||||
:raises VersionTooLowError: If the session's version doesn't match the latest version.
|
||||
"""
|
||||
# create session
|
||||
session = SessionBase.create_session()
|
||||
|
||||
# get ussd session in redis cache
|
||||
in_memory_session = InMemoryUssdSession.redis_cache.get(external_session_id)
|
||||
|
||||
# process persistence to db
|
||||
if in_memory_session:
|
||||
in_memory_session = json.loads(in_memory_session)
|
||||
in_db_ussd_session = session.query(UssdSession).filter_by(external_session_id=external_session_id).first()
|
||||
if in_db_ussd_session:
|
||||
in_db_ussd_session.update(
|
||||
session=session,
|
||||
user_input=in_memory_session.get('user_input'),
|
||||
state=in_memory_session.get('state'),
|
||||
version=in_memory_session.get('version'),
|
||||
)
|
||||
else:
|
||||
in_db_ussd_session = UssdSession(
|
||||
external_session_id=external_session_id,
|
||||
service_code=in_memory_session.get('service_code'),
|
||||
msisdn=in_memory_session.get('msisdn'),
|
||||
user_input=in_memory_session.get('user_input'),
|
||||
state=in_memory_session.get('state'),
|
||||
version=in_memory_session.get('version'),
|
||||
)
|
||||
|
||||
# handle the updating of session data for persistence to db
|
||||
session_data = in_memory_session.get('session_data')
|
||||
|
||||
if session_data:
|
||||
for key, value in session_data.items():
|
||||
in_db_ussd_session.set_data(key=key, value=value, session=session)
|
||||
|
||||
session.add(in_db_ussd_session)
|
||||
InMemoryUssdSession.redis_cache.expire(external_session_id, timedelta(minutes=1))
|
||||
else:
|
||||
session.close()
|
||||
raise SessionNotFoundError('Session does not exist!')
|
||||
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
132
apps/cic-ussd/cic_ussd/transactions.py
Normal file
132
apps/cic-ussd/cic_ussd/transactions.py
Normal file
@@ -0,0 +1,132 @@
|
||||
# standard imports
|
||||
import decimal
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
# third-party imports
|
||||
from cic_eth.api import Api
|
||||
|
||||
# local imports
|
||||
from cic_ussd.notifications import Notifier
|
||||
|
||||
|
||||
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+18
|
||||
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+18)
|
||||
|
||||
|
||||
class IncomingTransactionProcessor:
|
||||
|
||||
def __init__(self, phone_number: str, preferred_language: str, token_symbol: str, value: int):
|
||||
"""
|
||||
:param phone_number: The recipient's phone number.
|
||||
:type phone_number: str
|
||||
:param preferred_language: The user's preferred language.
|
||||
:type preferred_language: str
|
||||
:param token_symbol: The symbol for the token the recipient receives.
|
||||
:type token_symbol: str
|
||||
:param value: The amount of tokens received in the transactions.
|
||||
:type value: int
|
||||
"""
|
||||
self.phone_number = phone_number
|
||||
self.preferred_language = preferred_language
|
||||
self.token_symbol = token_symbol
|
||||
self.value = value
|
||||
|
||||
def process_token_gift_incoming_transactions(self, first_name: str):
|
||||
"""This function processes incoming transactions with a "tokengift" param, it collects all appropriate data to
|
||||
send out notifications to users when their accounts are successfully created.
|
||||
:param first_name: The first name of the recipient of the token gift transaction.
|
||||
:type first_name: str
|
||||
|
||||
"""
|
||||
balance = from_wei(value=self.value)
|
||||
key = 'sms.account_successfully_created'
|
||||
notifier.send_sms_notification(key=key,
|
||||
phone_number=self.phone_number,
|
||||
preferred_language=self.preferred_language,
|
||||
balance=balance,
|
||||
first_name=first_name,
|
||||
token_symbol=self.token_symbol)
|
||||
|
||||
def process_transfer_incoming_transaction(self, sender_information: str):
|
||||
"""This function processes incoming transactions with the "transfer" param and issues notifications to users
|
||||
about reception of funds into their accounts.
|
||||
:param sender_information: A string with a user's full name and phone number.
|
||||
:type sender_information: str
|
||||
"""
|
||||
key = 'sms.received_tokens'
|
||||
amount = from_wei(value=self.value)
|
||||
timestamp = datetime.now().strftime('%d-%m-%y, %H:%M %p')
|
||||
|
||||
logg.debug('Balance requires implementation of cic-eth integration with balance.')
|
||||
notifier.send_sms_notification(key=key,
|
||||
phone_number=self.phone_number,
|
||||
preferred_language=self.preferred_language,
|
||||
amount=amount,
|
||||
token_symbol=self.token_symbol,
|
||||
tx_sender_information=sender_information,
|
||||
timestamp=timestamp,
|
||||
balance='')
|
||||
|
||||
|
||||
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.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='SRF'):
|
||||
"""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)
|
||||
22
apps/cic-ussd/cic_ussd/translation.py
Normal file
22
apps/cic-ussd/cic_ussd/translation.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
This module is responsible for translation of ussd menu text based on a user's set preferred language.
|
||||
"""
|
||||
import i18n
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def translation_for(key: str, preferred_language: Optional[str] = None, **kwargs) -> str:
|
||||
"""
|
||||
Translates text mapped to a specific YAML key into the user's set preferred language.
|
||||
:param preferred_language: User's preferred language in which to view the ussd menu.
|
||||
:type preferred_language str
|
||||
:param key: Key to a specific YAML test entry
|
||||
:type key: str
|
||||
:param kwargs: Dynamic values to be interpolated into the YAML texts for specific keys
|
||||
:type kwargs: any
|
||||
:return: Appropriately translated text for corresponding provided key
|
||||
:rtype: str
|
||||
"""
|
||||
if preferred_language:
|
||||
i18n.set('locale', preferred_language)
|
||||
return i18n.t(key, **kwargs)
|
||||
128
apps/cic-ussd/cic_ussd/validator.py
Normal file
128
apps/cic-ussd/cic_ussd/validator.py
Normal file
@@ -0,0 +1,128 @@
|
||||
# standard imports
|
||||
import logging
|
||||
import re
|
||||
|
||||
# third-party imports
|
||||
from confini import Config
|
||||
|
||||
# local imports
|
||||
from cic_ussd.db.models.user import User
|
||||
|
||||
logg = logging.getLogger(__file__)
|
||||
|
||||
|
||||
def check_ip(config: Config, env: dict):
|
||||
"""Check whether request origin IP is whitelisted
|
||||
:param config: A dictionary object containing configuration values
|
||||
:type config: Config
|
||||
:param env: Object containing server and request information
|
||||
:type env: dict
|
||||
:return: Request IP validity
|
||||
:rtype: boolean
|
||||
"""
|
||||
return env.get('REMOTE_ADDR') == config.get('APP_ALLOWED_IP')
|
||||
|
||||
|
||||
def check_request_content_length(config: Config, env: dict):
|
||||
"""Checks whether the request's content is less than or equal to the system's set maximum content length
|
||||
:param config: A dictionary object containing configuration values
|
||||
:type config: Config
|
||||
:param env: Object containing server and request information
|
||||
:type env: dict
|
||||
:return: Content length validity
|
||||
:rtype: boolean
|
||||
"""
|
||||
return env.get('CONTENT_LENGTH') is not None and int(env.get('CONTENT_LENGTH')) <= int(
|
||||
config.get('APP_MAX_BODY_LENGTH'))
|
||||
|
||||
|
||||
def check_service_code(code: str, config: Config):
|
||||
"""Checks whether provided code matches expected service code
|
||||
:param config: A dictionary object containing configuration values
|
||||
:type config: Config
|
||||
:param code: Service code passed over request
|
||||
:type code: str
|
||||
|
||||
:return: Service code validity
|
||||
:rtype: boolean
|
||||
"""
|
||||
return code == config.get('APP_SERVICE_CODE')
|
||||
|
||||
|
||||
def check_known_user(phone: str):
|
||||
"""
|
||||
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: A valid phone number
|
||||
:type phone: str
|
||||
:return: Is known phone number
|
||||
:rtype: boolean
|
||||
"""
|
||||
user = User.session.query(User).filter_by(phone_number=phone).first()
|
||||
return user is not None
|
||||
|
||||
|
||||
def check_phone_number(number: str):
|
||||
"""
|
||||
Checks whether phone number is present
|
||||
:param number: A valid phone number
|
||||
:type number: str
|
||||
:return: Phone number presence
|
||||
:rtype: boolean
|
||||
"""
|
||||
return number is not None
|
||||
|
||||
|
||||
def check_request_method(env: dict):
|
||||
"""
|
||||
Checks whether request method is POST
|
||||
:param env: Object containing server and request information
|
||||
:type env: dict
|
||||
:return: Request method validity
|
||||
:rtype: boolean
|
||||
"""
|
||||
return env.get('REQUEST_METHOD').upper() == 'POST'
|
||||
|
||||
|
||||
def check_session_id(session_id: str):
|
||||
"""
|
||||
Checks whether session id is present
|
||||
:param session_id: Session id value provided by AT
|
||||
:type session_id: str
|
||||
:return: Session id presence
|
||||
:rtype: boolean
|
||||
"""
|
||||
return session_id is not None
|
||||
|
||||
|
||||
def validate_phone_number(phone: str):
|
||||
"""
|
||||
Check if phone number is in the correct format.
|
||||
:param phone: The phone number to be validated.
|
||||
:rtype phone: str
|
||||
:return: Whether the phone number is of the correct format.
|
||||
:rtype: bool
|
||||
"""
|
||||
if phone and re.match('[+]?[0-9]{10,12}$', phone):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def validate_response_type(processor_response: str) -> bool:
|
||||
"""
|
||||
This function checks the prefix for a corresponding menu's text from the response offered by the Ussd Processor and
|
||||
determines whether the response should prompt the end of a ussd session or the
|
||||
:param processor_response: A ussd menu's text value.
|
||||
:type processor_response: str
|
||||
:return: Value representing validity of a response.
|
||||
:rtype: bool
|
||||
"""
|
||||
matcher = r'^(CON|END)'
|
||||
if len(processor_response) > 164:
|
||||
logg.warning(f'Warning, text has length {len(processor_response)}, display may be truncated')
|
||||
|
||||
if re.match(matcher, processor_response):
|
||||
return True
|
||||
return False
|
||||
|
||||
13
apps/cic-ussd/cic_ussd/version.py
Normal file
13
apps/cic-ussd/cic_ussd/version.py
Normal file
@@ -0,0 +1,13 @@
|
||||
# standard imports
|
||||
import semver
|
||||
|
||||
version = (0, 3, 0, 'alpha.1')
|
||||
|
||||
version_object = semver.VersionInfo(
|
||||
major=version[0],
|
||||
minor=version[1],
|
||||
patch=version[2],
|
||||
prerelease=version[3],
|
||||
)
|
||||
|
||||
version_string = str(version_object)
|
||||
Reference in New Issue
Block a user