The great bump
This commit is contained in:
@@ -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')
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user