The great bump

This commit is contained in:
2021-08-06 16:29:01 +00:00
parent f764b73f66
commit 0672a17d2e
195 changed files with 5791 additions and 4983 deletions

View File

@@ -26,7 +26,7 @@ def upgrade():
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('data', postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column('version', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id')
)

View File

@@ -24,7 +24,7 @@ def upgrade():
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('status', sa.Integer(), nullable=False),
sa.Column('created', sa.DateTime(), nullable=False),
sa.Column('updated', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')

View File

@@ -1,11 +1,17 @@
# standard imports
import json
# external imports
from chainlib.hash import strip_0x
from cic_eth.api import Api
# local imports
from cic_ussd.account.metadata import get_cached_preferred_language, parse_account_metadata
from cic_ussd.cache import Cache, cache_data_key, get_cached_data
from cic_ussd.db.enum import AccountStatus
from cic_ussd.db.models.base import SessionBase
from cic_ussd.db.models.task_tracker import TaskTracker
from cic_ussd.encoder import check_password_hash, create_password_hash
# third party imports
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm.session import Session
@@ -21,9 +27,32 @@ class Account(SessionBase):
phone_number = Column(String)
password_hash = Column(String)
failed_pin_attempts = Column(Integer)
account_status = Column(Integer)
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.status = AccountStatus.PENDING.value
def __repr__(self):
return f'<Account: {self.blockchain_address}>'
def activate_account(self):
"""This method is used to reset failed pin attempts and change account status to Active."""
self.failed_pin_attempts = 0
self.status = AccountStatus.ACTIVE.value
def create_password(self, password):
"""This method takes a password value and hashes the value before assigning it to the corresponding
`hashed_password` attribute in the user record.
:param password: A password value
:type password: str
"""
self.password_hash = create_password_hash(password)
@staticmethod
def get_by_phone_number(phone_number: str, session: Session):
"""Retrieves an account from a phone number.
@@ -39,23 +68,68 @@ class Account(SessionBase):
SessionBase.release_session(session=session)
return account
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 has_preferred_language(self) -> bool:
return get_cached_preferred_language(self.blockchain_address) is not None
def __repr__(self):
return f'<Account: {self.blockchain_address}>'
def create_password(self, password):
"""This method takes a password value and hashes the value before assigning it to the corresponding
`hashed_password` attribute in the user record.
:param password: A password value
:type password: str
def has_valid_pin(self, session: Session):
"""
self.password_hash = create_password_hash(password)
:param session:
:type session:
:return:
:rtype:
"""
return self.get_status(session) == AccountStatus.ACTIVE.name and self.password_hash is not None
def pin_is_blocked(self, session: Session) -> bool:
"""
:param session:
:type session:
:return:
:rtype:
"""
return self.failed_pin_attempts == 3 and self.get_status(session) == AccountStatus.LOCKED.name
def reset_pin(self, session: Session) -> str:
"""This function resets the number of failed pin attempts to zero. It places the account in pin reset status
enabling users to reset their pins.
:param session: Database session object.
:type session: Session
"""
session = SessionBase.bind_session(session=session)
self.failed_pin_attempts = 0
self.status = AccountStatus.RESET.value
session.add(self)
session.flush()
SessionBase.release_session(session=session)
return f'Pin reset successful.'
def standard_metadata_id(self) -> str:
"""This function creates an account's standard metadata identification information that contains an account owner's
given name, family name and phone number and defaults to a phone number in the absence of metadata.
:return: Standard metadata identification information | e164 formatted phone number.
:rtype: str
"""
identifier = bytes.fromhex(strip_0x(self.blockchain_address))
key = cache_data_key(identifier, ':cic.person')
account_metadata = get_cached_data(key)
if not account_metadata:
return self.phone_number
account_metadata = json.loads(account_metadata)
return parse_account_metadata(account_metadata)
def get_status(self, session: Session):
"""This function handles account status queries, it checks whether an account's failed pin attempts exceed 2 and
updates the account status locked, it then returns the account status
:return: The account status for a user object
:rtype: str
"""
session = SessionBase.bind_session(session=session)
if self.failed_pin_attempts > 2:
self.status = AccountStatus.LOCKED.value
session.add(self)
session.flush()
SessionBase.release_session(session=session)
return AccountStatus(self.status).name
def verify_password(self, password):
"""This method takes a password value and compares it to the user's corresponding `hashed_password` value to
@@ -67,33 +141,41 @@ class Account(SessionBase):
"""
return check_password_hash(password, self.password_hash)
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 create(chain_str: str, phone_number: str, session: Session):
"""
:param chain_str:
:type chain_str:
:param phone_number:
:type phone_number:
:param session:
:type session:
:return:
:rtype:
"""
api = Api(callback_task='cic_ussd.tasks.callback_handler.account_creation_callback',
callback_queue='cic-ussd',
callback_param='',
chain_str=chain_str)
task_uuid = api.create_account().id
TaskTracker.add(session=session, task_uuid=task_uuid)
cache_creation_task_uuid(phone_number=phone_number, task_uuid=task_uuid)
def 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
def cache_creation_task_uuid(phone_number: str, task_uuid: str):
"""This function stores the task id that is returned from a task spawned to create a blockchain account in the redis
cache.
:param phone_number: The phone number for the user whose account is being created.
:type phone_number: str
:param task_uuid: A celery task id
:type task_uuid: str
"""
cache = Cache.store
account_creation_request_data = {
'phone_number': phone_number,
'sms_notification_sent': False,
'status': 'PENDING',
'task_uuid': task_uuid
}
cache.set(task_uuid, json.dumps(account_creation_request_data))
cache.persist(name=task_uuid)

View File

@@ -8,11 +8,11 @@ from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import (
StaticPool,
QueuePool,
AssertionPool,
NullPool,
)
StaticPool,
QueuePool,
AssertionPool,
NullPool,
)
logg = logging.getLogger().getChild(__name__)
@@ -42,14 +42,12 @@ class SessionBase(Model):
localsessions = {}
"""Contains dictionary of sessions initiated by db model components"""
@staticmethod
def create_session():
"""Creates a new database session.
"""
return SessionBase.sessionmaker()
@staticmethod
def _set_engine(engine):
"""Sets the database engine static property
@@ -57,7 +55,6 @@ class SessionBase(Model):
SessionBase.engine = engine
SessionBase.sessionmaker = sessionmaker(bind=SessionBase.engine)
@staticmethod
def connect(dsn, pool_size=16, debug=False):
"""Create new database connection engine and connect to database backend.
@@ -71,14 +68,14 @@ class SessionBase(Model):
if pool_size > 1:
logg.info('db using queue pool')
e = create_engine(
dsn,
max_overflow=pool_size*3,
pool_pre_ping=True,
pool_size=pool_size,
pool_recycle=60,
poolclass=poolclass,
echo=debug,
)
dsn,
max_overflow=pool_size * 3,
pool_pre_ping=True,
pool_size=pool_size,
pool_recycle=60,
poolclass=poolclass,
echo=debug,
)
else:
if pool_size == 0:
poolclass = NullPool
@@ -87,20 +84,19 @@ class SessionBase(Model):
else:
poolclass = StaticPool
e = create_engine(
dsn,
poolclass=poolclass,
echo=debug,
)
dsn,
poolclass=poolclass,
echo=debug,
)
else:
logg.info('db connection not poolable')
e = create_engine(
dsn,
echo=debug,
)
dsn,
echo=debug,
)
SessionBase._set_engine(e)
@staticmethod
def disconnect():
"""Disconnect from database and free resources.
@@ -108,18 +104,16 @@ class SessionBase(Model):
SessionBase.engine.dispose()
SessionBase.engine = None
@staticmethod
def bind_session(session=None):
localsession = session
if localsession == None:
if localsession is None:
localsession = SessionBase.create_session()
localsession_key = str(id(localsession))
logg.debug('creating new session {}'.format(localsession_key))
SessionBase.localsessions[localsession_key] = localsession
return localsession
@staticmethod
def release_session(session=None):
session_key = str(id(session))

View File

@@ -3,6 +3,7 @@ import logging
# third-party imports
from sqlalchemy import Column, String
from sqlalchemy.orm.session import Session
# local imports
from cic_ussd.db.models.base import SessionBase
@@ -17,3 +18,17 @@ class TaskTracker(SessionBase):
self.task_uuid = task_uuid
task_uuid = Column(String, nullable=False)
@staticmethod
def add(session: Session, task_uuid: str):
"""This function persists celery tasks uuids to storage.
:param session: Database session object.
:type session: Session
:param task_uuid: The uuid for an initiated task.
:type task_uuid: str
"""
session = SessionBase.bind_session(session=session)
task_record = TaskTracker(task_uuid=task_uuid)
session.add(task_record)
session.flush()
SessionBase.release_session(session=session)

View File

@@ -2,9 +2,10 @@
import logging
# third-party imports
from sqlalchemy import Column, String, Integer
from sqlalchemy import Column, desc, Integer, String
from sqlalchemy.dialects.postgresql import JSON
from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy.orm.session import Session
# local imports
from cic_ussd.db.models.base import SessionBase
@@ -16,26 +17,26 @@ logg = logging.getLogger(__name__)
class UssdSession(SessionBase):
__tablename__ = 'ussd_session'
data = Column(JSON)
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)
service_code = Column(String, nullable=False)
state = Column(String, nullable=False)
session_data = Column(JSON)
user_input = Column(String)
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
if self.data is None:
self.data = {}
self.data[key] = value
# https://stackoverflow.com/questions/42559434/updates-to-json-field-dont-persist-to-db
flag_modified(self, "session_data")
flag_modified(self, "data")
session.add(self)
def get_data(self, key):
if self.session_data is not None:
return self.session_data.get(key)
if self.data is not None:
return self.data.get(key)
else:
return None
@@ -51,9 +52,37 @@ class UssdSession(SessionBase):
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 has_record_for_phone_number(phone_number: str, session: Session):
"""
:param phone_number:
:type phone_number:
:param session:
:type session:
:return:
:rtype:
"""
session = SessionBase.bind_session(session=session)
ussd_session = session.query(UssdSession).filter_by(msisdn=phone_number).first()
SessionBase.release_session(session=session)
return ussd_session is not None
@staticmethod
def last_ussd_session(phone_number: str, session: Session):
"""
:param phone_number:
:type phone_number:
:param session:
:type session:
:return:
:rtype:
"""
session = SessionBase.bind_session(session=session)
ussd_session = session.query(UssdSession) \
.filter_by(msisdn=phone_number) \
.order_by(desc(UssdSession.created)) \
.first()
SessionBase.release_session(session=session)
return ussd_session
def to_json(self):
""" This function serializes the in db ussd session object to a JSON object
@@ -61,11 +90,11 @@ class UssdSession(SessionBase):
:rtype: dict
"""
return {
"data": self.data,
"external_session_id": self.external_session_id,
"service_code": self.service_code,
"msisdn": self.msisdn,
"user_input": self.user_input,
"service_code": self.service_code,
"state": self.state,
"session_data": self.session_data,
"user_input": self.user_input,
"version": self.version
}