Remove submodule cic ussd

This commit is contained in:
2021-02-06 15:13:47 +00:00
parent 8680d57a67
commit f386625844
221 changed files with 10030 additions and 4 deletions

View File

View 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

View 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

View File

@@ -0,0 +1 @@
Generic single-database configuration.

View 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

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

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

View File

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

View File

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

View File

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

View 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

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

View 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

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

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

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

View 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

View File

View 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

View File

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

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

View 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

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

View File

@@ -0,0 +1,6 @@
# third-party imports
from redis import Redis
class InMemoryStore:
cache: Redis = None

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

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

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

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

View File

@@ -0,0 +1 @@
from .state_machine import UssdStateMachine

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

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

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

View 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

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

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

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

View 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

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

View 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

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

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

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

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

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

View 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

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