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

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