├── .dockerignore ├── .env_example ├── .gitignore ├── Dockerfile ├── LICENSE ├── Procfile ├── README.md ├── app ├── AfricasTalkingGateway.py ├── __init__.py ├── database.py ├── models.py ├── ussd │ ├── __init__.py │ ├── airtime.py │ ├── base_menu.py │ ├── decorators.py │ ├── deposit.py │ ├── home.py │ ├── register.py │ ├── tasks.py │ ├── utils.py │ ├── views.py │ └── withdraw.py └── util.py ├── app_logger.yaml ├── azure-pipelines.yml ├── config.py ├── docker-compose.yml ├── manage.py ├── migrations ├── README ├── alembic.ini ├── env.py ├── script.py.mako └── versions │ └── eb45bca11515_.py ├── requirements.txt ├── start_app.sh ├── start_worker.sh └── worker.py /.dockerignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | /data-dev.sqlite 103 | /.idea/ 104 | .idea/encodings.xml 105 | .idea/fileTemplates/ 106 | data-dev.sqlite 107 | data-test.sqlite 108 | /.env/ 109 | .vscode/settings.json 110 | -------------------------------------------------------------------------------- /.env_example: -------------------------------------------------------------------------------- 1 | FLASK_ENVIRONMENT=production 2 | ADMIN_PHONENUMBER=+254XXXXXXXX 3 | DB_USER=apps 4 | DB_PASSWORD=apps_pass 5 | DB_HOST=localhost 6 | DB_NAME=nerds_microfincance 7 | REDIS_URL=redis://localhost:6379/0 8 | AT_USERNAME=awesome_nerd 9 | AT_APIKEY=some_api_key 10 | AT_ENVIRONMENT=sandbox -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | /data-dev.sqlite 103 | /.idea/ 104 | .idea/encodings.xml 105 | .idea/fileTemplates/ 106 | data-dev.sqlite 107 | data-test.sqlite 108 | /.env/ 109 | .vscode/settings.json 110 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6-jessie as base 2 | 3 | # prepare 4 | RUN apt-get update && \ 5 | apt-get install -y software-properties-common && \ 6 | apt-get install -y curl && \ 7 | apt-get install -y unzip && \ 8 | apt-get install -y build-essential && \ 9 | apt-get install -y libfreetype6-dev && \ 10 | apt-get install -y libhdf5-serial-dev && \ 11 | apt-get install -y libpng-dev && \ 12 | apt-get install -y libzmq3-dev && \ 13 | apt-get install -y pkg-config 14 | 15 | 16 | # Build 17 | FROM base as builder 18 | 19 | RUN pip install virtualenv 20 | 21 | RUN virtualenv -p python3 /appenv 22 | 23 | WORKDIR /var/src/ 24 | 25 | COPY requirements.txt . 26 | 27 | RUN . /appenv/bin/activate; pip install -r requirements.txt 28 | 29 | # run 30 | FROM base 31 | 32 | COPY --from=builder /appenv /appenv 33 | 34 | WORKDIR /var/src/ 35 | 36 | COPY . . 37 | 38 | ENTRYPOINT [ "sh" ] 39 | 40 | EXPOSE 8000 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Pius Dan Nyongesa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn manage:app --reload --preload -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![DUB](https://img.shields.io/dub/l/vibe-d.svg)]() 2 | [![PyPI](https://img.shields.io/pypi/v/nine.svg)]() 3 | [![Build Status](https://dev.azure.com/dannyongesa/ussd%20python%20demo/_apis/build/status/Piusdan.USSD-Python-Demo?branchName=master)](https://dev.azure.com/dannyongesa/ussd%20python%20demo/_build/latest?definitionId=2&branchName=master) 4 | 5 | # Setting Up a USSD Service for MicroFinance Institutions 6 | #### A step-by-step guide 7 | 8 | - Setting up the logic for USSD is easy with the [Africa's Talking API](docs.africastalking.com/ussd). This is a guide to how to use the code provided on this [repository](https://github.com/Piusdan/USSD-Python-Demo) to create a USSD that allows users to get registered and then access a menu of the following services: 9 | 10 | | USSD APP Features | 11 | | --------------------------------------------:| 12 | | Request to get a call from support | 13 | | Deposit Money to user's account | 14 | | Withdraw money from users account | 15 | | Send money from users account to another | 16 | | Repay loan | 17 | | Buy Airtime | 18 | 19 | ---- 20 | 21 | ## INSTALLATION AND GUIDE 22 | 23 | 1. clone/download the project into the directory of your choice 24 | 25 | 1. Create a .env file on your root directory 26 | 27 | $ cp .env_example .env 28 | 29 | Be sure to substitute the example variables with your credentials 30 | 31 | #### Docker 32 | 33 | - To install using docker, run 34 | 35 | $ docker-compose up -b 8080:8000 36 | 37 | This will start your application on port 8080 38 | 39 | #### Using a virtual environment 40 | 41 | 1. Create a virtual environment 42 | 43 | $ python3 -m venv venv 44 | $ . venv/bin/activate 45 | 46 | 1. Install the project's dependancies 47 | 48 | $ pip install requirements.txt 49 | 50 | 51 | 1. Configure your flask path 52 | 53 | $ export FLASK_APP=manage.py 54 | 55 | 1. Initialise your database 56 | 57 | $ flask initdb 58 | 59 | 1. Launch application 60 | 61 | $ flask run 62 | 63 | 1. Head to https://localhost:5000 64 | 65 | - You need to set up on the sandbox and [create](https://sandbox.africastalking.com/ussd/createchannel) a USSD channel that you will use to test by dialing into it via our [simulator](https://simulator.africastalking.com:1517/). 66 | 67 | - Assuming that you are doing your development on a localhost, you have to expose your application living in the webroot of your localhost to the internet via a tunneling application like [Ngrok](https://ngrok.com/). Otherwise, if your server has a public IP, you are good to go! Your URL callback for this demo will become: 68 | http:///MfUSSD/microfinanceUSSD.php 69 | 70 | - This application has been developed on an Ubuntu 16.04LTS and lives in the web root at /var/www/html/MfUSSD. Courtesy of Ngrok, the publicly accessible url is: https://49af2317.ngrok.io (instead of http://localhost) which is referenced in the code as well. 71 | (Create your own which will be different.) 72 | 73 | - The webhook or callback to this application therefore becomes: 74 | https://49af2317.ngrok.io/api/v1.1/ussd/callback. 75 | To allow the application to talk to the Africa's Talking USSD gateway, this callback URL is placed in the dashboard, [under ussd callbacks here](https://account.africastalking.com/ussd/callback). 76 | 77 | - Finally, this application works with a connection to an sqlite database. This is the default database shipped with python, however its recomended switching to a proper database when deploying the application. Also create a session_levels table and a users table. These details are configured in the models.py and this is required in the main application script app/apiv2/views.py 78 | 79 | 80 | | Field | Type | Null | Key | Default | Extra | 81 | | ------------- |:----------------------------:| -----:|----:| -----------------:| ---------------------------:| 82 | | id | int(6) | YES | | NULL | | 83 | | name | varchar(30) | YES | | NULL | | 84 | | phonenumber | varchar(20) | YES | | NULL | | 85 | | city | varchar(30) | YES | | NULL | | 86 | | validation | varchar(30) | YES | | NULL | | 87 | | reg_date | timestamp | NO | | CURRENT_TIMESTAMP | on update CURRENT_TIMESTAMP | 88 | 89 | - The application uses redis for session management. User sessions are stored as key value pairs in redis. 90 | 91 | 92 | ## Features on the Services List 93 | This USSD application has the following user journey. 94 | 95 | - The user dials the ussd code - something like `*384*303#` 96 | 97 | - The application checks if the user is registered or not. If the user is registered, the services menu is served which allows the user to: receive SMS, receive a call with an IVR menu. 98 | 99 | - In case the user is not registered, the application prompts the user for their name and city (with validations), before successfully serving the services menu. 100 | 101 | ## Code walkthrough 102 | This documentation is for the USSD application that lives in https://49af2317.ngrok.io/api/v1.1/ussd/callback. 103 | - The applications entrypoint is at `app/ussd/views.py` 104 | ```python 105 | #1. This code only runs after a post request from AT 106 | @ussd.route('/ussd/callback', methods=['POST']) 107 | def ussd_callback(): 108 | """ 109 | Handles post call back from AT 110 | 111 | :return: 112 | """ 113 | ``` 114 | Import all the necessary scripts to run this application 115 | 116 | ```python 117 | # 2. Import all neccesary modules 118 | from flask import g, make_response 119 | 120 | from app.models import AnonymousUser 121 | from . import ussd 122 | from .airtime import Airtime 123 | from .deposit import Deposit 124 | from .home import LowerLevelMenu 125 | from .register import RegistrationMenu 126 | from .withdraw import WithDrawal 127 | ``` 128 | 129 | Receive the HTTP POST from AT. `app/ussd/decorators.py` 130 | 131 | We will use a decorator that hooks on to the application request, to query and initialize session metadata stored in redis. 132 | 133 | ```python 134 | # 3. get data from ATs post payload 135 | session_id = request.values.get("sessionId", None) 136 | phone_number = request.values.get("phoneNumber", None) 137 | text = request.values.get("text", "default") 138 | ``` 139 | 140 | The AT USSD gateway keeps chaining the user response. We want to grab the latest input from a string like 1*1*2 141 | ```python 142 | text_array = text.split("*") 143 | user_response = text_array[len(text_array) - 1] 144 | ``` 145 | 146 | Interactions with the user can be managed using the received sessionId and a level management process that your application implements as follows. 147 | 148 | - The USSD session has a set time limit(20-180 secs based on provider) under which the sessionId does not change. Using this sessionId, it is easy to navigate your user across the USSD menus by graduating their level(menu step) so that you dont serve them the same menu or lose track of where the user is. 149 | - Query redis for the user's session level using the sessionID as the key. If this exists, the user is returning and they therefore have a stored level. Grab that level and serve that user the right menu. Otherwise, serve the user the home menu. 150 | - The session metadata is stored in flask's `g` global variable to allow for access within the current request context. 151 | ```python 152 | # 4. Query session metadata from redis or initialize a new session for this user if the session does not exist 153 | # get session 154 | session = redis.get(session_id) 155 | if session is None: 156 | session = {"level": 0, "session_id": session_id} 157 | redis.set(session_id, json.dumps(session)) 158 | else: 159 | session = json.loads(session.decode()) 160 | # add user, response and session to the request variable g 161 | g.user_response = text_array[len(text_array) - 1] 162 | g.session = session 163 | g.current_user = user 164 | g.phone_number = phone_number 165 | g.session_id = session_id 166 | return func(*args, **kwargs) 167 | ``` 168 | 169 | Before serving the menu, check if the incoming phone number request belongs to a registered user(sort of a login). If they are registered, they can access the menu, otherwise, they should first register. 170 | 171 | `app/ussd/views.py` 172 | ```python 173 | # 5. Check if the user is in the db 174 | session_id = g.session_id 175 | user = g.current_user 176 | session = g.session 177 | user_response = g.user_response 178 | if isinstance(user, AnonymousUser): 179 | # register user 180 | menu = RegistrationMenu(session_id=session_id, session=session, phone_number=g.phone_number, 181 | user_response=user_response, user=user) 182 | return menu.execute() 183 | ``` 184 | 185 | If the user is available and all their mandatory fields are complete, then the application switches between their responses to figure out which menu to serve. The first menu is usually a result of receiving a blank text -- the user just dialed in. 186 | ```python 187 | # 7. Serve the Services Menu 188 | if level < 2: 189 | menu = LowerLevelMenu(session_id=session_id, session=session, phone_number=g.phone_number, 190 | user_response=user_response, user=user) 191 | return menu.execute() 192 | 193 | if level >= 50: 194 | menu = Deposit(session_id=session_id, session=session, phone_number=g.phone_number, 195 | user_response=user_response, user=user, level=level) 196 | return menu.execute() 197 | 198 | if level >= 40: 199 | menu = WithDrawal(session_id=session_id, session=session, phone_number=g.phone_number, 200 | user_response=user_response, user=user, level=level) 201 | return menu.execute() 202 | 203 | if level >= 10: 204 | menu = Airtime(session_id=session_id, session=session, phone_number=g.phone_number, user_response=user_response, 205 | user=user, level=level) 206 | return menu.execute() 207 | 208 | ``` 209 | If the user is not registered, we use the users level - purely to take the user through the registration process. We also enclose the logic in a condition that prevents the user from sending empty responses. 210 | ```python 211 | if isinstance(user, AnonymousUser): 212 | # register user 213 | menu = RegistrationMenu(session_id=session_id, session=session, phone_number=g.phone_number, 214 | user_response=user_response, user=user) 215 | return menu.execute() 216 | 217 | ``` 218 | 219 | ## Complexities of Voice. 220 | - The voice service included in this script requires a few juggling acts and probably requires a short review of its own. 221 | When the user requests a to get a call, the following happens. 222 | a) The script at https://49af2317.ngrok.io/api/v1.1/ussd/callback requests the call() method through the Africa's Talking Voice Gateway, passing the number to be called and the caller/dialer Id. The call is made and it comes into the users phone. When they answer isActive becomes 1. 223 | 224 | ```python 225 | def please_call(self): 226 | # call the user and bridge to a sales person 227 | menu_text = "END Please wait while we place your call.\n" 228 | 229 | # make a call 230 | caller = current_app.config["AT_NUMBER"] 231 | to = self.user.phone_number 232 | 233 | # create a new instance of our awesome gateway 234 | gateway = AfricasTalkingGateway( 235 | current_app.config["AT_USERNAME"], current_app.config["AT_APIKEY"]) 236 | try: 237 | gateway.call(caller, to) 238 | except AfricasTalkingGateway as e: 239 | print "Encountered an error when calling: {}".format(str(e)) 240 | 241 | # print the response on to the page so that our gateway can read it 242 | return respond(menu_text) case "2": 243 | ``` 244 | b) As a result, Africa's Talking gateway check the callback for the voice number in this case +254703554404. 245 | c) The callback is a route on our views.py file whose URL is: https://49af2317.ngrok.io/api/v1.1/voice/callback 246 | d) The instructions are to respond with a text to speech message for the user to enter dtmf digits. 247 | 248 | ```python 249 | @ussd.route('/voice/callback', methods=['POST']) 250 | def voice_callback(): 251 | """ 252 | voice_callback from AT's gateway is handled here 253 | 254 | """ 255 | sessionId = request.get('sessionId') 256 | isActive = request.get('isActive') 257 | 258 | if isActive == "1": 259 | callerNumber = request.get('callerNumber') 260 | # GET values from the AT's POST request 261 | session_id = request.values.get("sessionId", None) 262 | isActive = request.values.get('isActive') 263 | serviceCode = request.values.get("serviceCode", None) 264 | text = request.values.get("text", "default") 265 | text_array = text.split("*") 266 | user_response = text_array[len(text_array) - 1] 267 | 268 | # Compose the response 269 | menu_text = '' 270 | menu_text += '' 271 | menu_text += '' 272 | menu_text += '"Thank you for calling. Press 0 to talk to sales, 1 to talk to support or 2 to hear this message again."' 273 | menu_text += '' 274 | menu_text += '"Thank you for calling. Good bye!"' 275 | menu_text += '' 276 | 277 | # Print the response onto the page so that our gateway can read it 278 | return respond(menu_text) 279 | 280 | else: 281 | # Read in call details (duration, cost). This flag is set once the call is completed. 282 | # Note that the gateway does not expect a response in thie case 283 | 284 | duration = request.get('durationInSeconds') 285 | currencyCode = request.get('currencyCode') 286 | amount = request.get('amount') 287 | 288 | # You can then store this information in the database for your records 289 | ``` 290 | e) When the user enters the digit - in this case 0, 1 or 2, this digit is submitted to another route also in our views.py file which lives at https://49af2317.ngrok.io/api/v1.1/voice/menu and which switches between the various dtmf digits to make an outgoing call to the right recipient, who will be bridged to speak to the person currently listening to music on hold. We specify this music with the ringtone flag as follows: ringbackTone="url_to/static/media/SautiFinaleMoney.mp3" 291 | 292 | ```python 293 | @ussd.route('/voice/menu') 294 | def voice_menu(): 295 | """ 296 | When the user enters the digit - in this case 0, 1 or 2, this route 297 | switches between the various dtmf digits to 298 | make an outgoing call to the right recipient, who will be 299 | bridged to speak to the person currently listening to music on hold. 300 | We specify this music with the ringtone flag as follows: 301 | ringbackTone="url_to/static/media/SautiFinaleMoney.mp3" 302 | """ 303 | 304 | # 1. Receive POST from AT 305 | isActive = request.get('isActive') 306 | callerNumber = request.get('callerNumber') 307 | dtmfDigits = request.get('dtmfDigits') 308 | sessionId = request.get('sessionId') 309 | # Check if isActive=1 to act on the call or isActive=='0' to store the 310 | # result 311 | 312 | if (isActive == '1'): 313 | # 2a. Switch through the DTMFDigits 314 | if (dtmfDigits == "0"): 315 | # Compose response - talk to sales- 316 | response = '' 317 | response += '' 318 | response += 'Please hold while we connect you to Sales.' 319 | response += ''.format(url_for('media', path='SautiFinaleMoney.mp3')) 320 | response += '' 321 | 322 | # Print the response onto the page so that our gateway can read it 323 | return respond(response) 324 | 325 | elif (dtmfDigits == "1"): 326 | # 2c. Compose response - talk to support- 327 | response = '' 328 | response += '' 329 | response += 'Please hold while we connect you to Support.' 330 | response += ''.format(url_for('media', path='SautiFinaleMoney.mp3')) 331 | response += '' 332 | 333 | # Print the response onto the page so that our gateway can read it 334 | return respond(response) 335 | elif (dtmfDigits == "2"): 336 | # 2d. Redirect to the main IVR- 337 | response = '' 338 | response += '' 339 | response += '{}'.format(url_for('voice_callback')) 340 | response += '' 341 | 342 | # Print the response onto the page so that our gateway can read it 343 | return respond(response) 344 | else: 345 | # 2e. By default talk to support 346 | response = '' 347 | response += '' 348 | response += 'Please hold while we connect you to Support.' 349 | response += ''.format(url_for('media', path='SautiFinaleMoney.mp3')) 350 | response += '' 351 | 352 | # Print the response onto the page so that our gateway can read it 353 | return respond(response) 354 | else: 355 | # 3. Store the data from the POST 356 | durationInSeconds = request.get('durationInSeconds') 357 | direction = request.get('direction') 358 | amount = request.get('amount') 359 | callerNumber = request.get('callerNumber') 360 | destinationNumber = request.get('destinationNumber') 361 | sessionId = request.get('sessionId') 362 | callStartTime = request.get('callStartTime') 363 | isActive = request.get('isActive') 364 | currencyCode = request.get('currencyCode') 365 | status = request.get('status') 366 | 367 | # 3a. Store the data, write your SQL statements here- 368 | ``` 369 | 370 | When the agent/person picks up, the conversation can go on. 371 | 372 | - That is basically our application! Happy coding! -------------------------------------------------------------------------------- /app/AfricasTalkingGateway.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from africastalking.AfricasTalkingGateway import AfricasTalkingGateway, AfricasTalkingGatewayException 4 | 5 | 6 | class NerdsMicrofinanceGatewayGatewayException(AfricasTalkingGatewayException): 7 | pass 8 | 9 | 10 | class NerdsMicrofinanceGateway(AfricasTalkingGateway): 11 | def __init__(self): 12 | pass # override default constructor 13 | 14 | @classmethod 15 | def init_app(cls, app): 16 | # this initialises an AfricasTalking Gateway instanse similar to calling 17 | # africastalking.gateway(username, apikey, environment) 18 | # this enables us to initialise one gateway to use throughout the app 19 | 20 | cls.username = app.config['AT_USERNAME'] 21 | cls.apiKey = app.config['AT_APIKEY'] 22 | cls.environment = app.config['AT_ENVIRONMENT'] 23 | cls.HTTP_RESPONSE_OK = 200 24 | cls.HTTP_RESPONSE_CREATED = 201 25 | 26 | # Turn this on if you run into problems. It will print the raw HTTP response from our server 27 | if os.environ.get('APP_CONFIG') == 'development': 28 | cls.Debug = True 29 | else: 30 | cls.Debug = False 31 | 32 | 33 | gateway = NerdsMicrofinanceGateway() 34 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import logging.config 3 | import os 4 | 5 | from celery.utils.log import get_task_logger 6 | from dotenv import load_dotenv 7 | from flask import Flask 8 | from flask_login import LoginManager 9 | 10 | from config import config, Config 11 | from .AfricasTalkingGateway import gateway 12 | from .database import db, redis 13 | 14 | dotenv_path = os.path.join(os.path.join(os.path.dirname(__file__), ".."), ".env") 15 | load_dotenv(dotenv_path) 16 | 17 | __version__ = "0.2.0" 18 | __author__ = "npiusdan@gmail.com" 19 | __description__ = "Nerds Microfinance application" 20 | __email__ = "npiusdan@gmail.com" 21 | __copyright__ = "MIT LICENCE" 22 | 23 | login_manager = LoginManager() 24 | 25 | celery_logger = get_task_logger(__name__) 26 | 27 | 28 | def create_celery(): 29 | from celery import Celery 30 | 31 | celery = Celery( 32 | __name__, 33 | backend=Config.CELERY_RESULT_BACKEND, 34 | broker=Config.CELERY_BROKER_URL 35 | ) 36 | return celery 37 | 38 | 39 | celery = create_celery() 40 | 41 | 42 | def create_app(config_name): 43 | app = Flask(__name__) 44 | # configure application 45 | app.config.from_object(config[config_name]) 46 | config[config_name].init_app(app) 47 | 48 | # setup login manager 49 | login_manager.init_app(app) 50 | 51 | # setup database 52 | redis.init_app(app) 53 | db.init_app(app) 54 | 55 | # initialize africastalking gateway 56 | gateway.init_app(app=app) 57 | 58 | # setup celery 59 | celery.conf.update(app.config) 60 | 61 | class ContextTask(celery.Task): 62 | def __call__(self, *args, **kwargs): 63 | with app.app_context(): 64 | return self.run(*args, **kwargs) 65 | 66 | celery.Task = ContextTask 67 | 68 | # register blueprints 69 | from app.ussd import ussd as ussd_bp 70 | 71 | app.register_blueprint(ussd_bp) 72 | 73 | # setup logging 74 | from app.util import setup_logging 75 | from config import basedir 76 | 77 | if app.debug: 78 | logging_level = logging.DEBUG 79 | else: 80 | logging_level = logging.INFO 81 | path = os.path.join(basedir, "app_logger.yaml") 82 | setup_logging(default_level=logging_level, logger_file_path=path) 83 | return app 84 | -------------------------------------------------------------------------------- /app/database.py: -------------------------------------------------------------------------------- 1 | from flask_redis import FlaskRedis 2 | from flask_sqlalchemy import SQLAlchemy 3 | 4 | from .util import kenya_time 5 | 6 | redis = FlaskRedis() 7 | db = SQLAlchemy() 8 | 9 | 10 | class CRUDMixin(object): 11 | def __repr__(self): 12 | return "<{}>".format(self.__class__.__name__) 13 | 14 | @classmethod 15 | def create(cls, **kwargs): 16 | instance = cls(**kwargs) 17 | return instance.save() 18 | 19 | def save(self): 20 | """Saves object to database""" 21 | db.session.add(self) 22 | db.session.commit() 23 | return self 24 | 25 | def delete(self): 26 | """deletes object from db""" 27 | db.session.delete(self) 28 | db.session.commit() 29 | return self 30 | 31 | 32 | class AuditColumns(CRUDMixin): 33 | created_by = db.Column(db.String(128), nullable=True) 34 | created_date = db.Column(db.DateTime, default=kenya_time) 35 | last_edited_date = db.Column(db.DateTime, onupdate=kenya_time, default=kenya_time) 36 | last_edited_by = db.Column(db.String(128), nullable=True) 37 | active = db.Column(db.Boolean, default=False) 38 | -------------------------------------------------------------------------------- /app/models.py: -------------------------------------------------------------------------------- 1 | # from app.ussd.utils import kenya_time 2 | 3 | from .database import db, AuditColumns 4 | from .util import kenya_time 5 | 6 | 7 | class User(AuditColumns, db.Model): 8 | __tablename__ = 'users' 9 | id = db.Column( 10 | db.Integer, 11 | primary_key=True 12 | ) 13 | username = db.Column( 14 | db.String(64), 15 | index=True 16 | ) 17 | phone_number = db.Column( 18 | db.String(64), 19 | unique=True, 20 | index=True, 21 | nullable=False 22 | ) 23 | account = db.Column( 24 | db.Float, 25 | default=10.00 26 | ) 27 | reg_date = db.Column( 28 | db.DateTime, 29 | default=kenya_time 30 | ) 31 | validation = db.Column( 32 | db.String, 33 | default="" 34 | ) 35 | city = db.Column(db.String(32), default="") 36 | 37 | def __repr__(self): 38 | return "User {}".format(self.name) 39 | 40 | @staticmethod 41 | def by_phoneNumber(phone_number): 42 | return User.query.filter_by(phone_number=phone_number).first() 43 | 44 | @staticmethod 45 | def by_username(username): 46 | return User.query.filter_by(username=username).first() 47 | 48 | def deposit(self, amount): 49 | self.account += amount 50 | self.save() 51 | 52 | def withdraw(self, amount): 53 | if self.amount > self.account: 54 | raise Exception("Cannot overwithdraw") 55 | self.account -= amount 56 | self.save() 57 | 58 | 59 | class AnonymousUser(): pass 60 | -------------------------------------------------------------------------------- /app/ussd/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | ussd = Blueprint('ussd', __name__) 4 | 5 | from . import views, decorators 6 | -------------------------------------------------------------------------------- /app/ussd/airtime.py: -------------------------------------------------------------------------------- 1 | from .base_menu import Menu 2 | from .tasks import buyAirtime 3 | 4 | 5 | class Airtime(Menu): 6 | def get_phone_number(self): # 10 7 | if self.user_response == '1': 8 | self.session["phone_number"] = self.phone_number 9 | menu_text = "Buy Airtime\nPlease Enter Amount(Ksh)" 10 | self.session['level'] = 12 11 | return self.ussd_proceed(menu_text) 12 | if self.user_response == '2': 13 | menu_text = "Buy Airtime\nPlease enter phone number as (+2547XXXXXXXX)" 14 | self.session['level'] = 11 15 | return self.ussd_proceed(menu_text) 16 | return self.home() 17 | 18 | def another_number(self): # 11 19 | if not self.user_response.startswith("+"): 20 | menu_text = "Buy Airtime\nPlease enter a valid phone number as (+2547XXXXXXXX)" 21 | return self.ussd_proceed(menu_text) 22 | self.session['phone_number'] = self.phone_number 23 | menu_text = "Buy Airtime\nPlease Enter Amount(Ksh)" 24 | self.session['level'] = 12 25 | return self.ussd_proceed(menu_text) 26 | 27 | def get_amount(self, amount=None): # level 12 28 | if int(self.user_response) < 5 and amount is None: 29 | menu_text = "Buy Airtime\nYou can only buy airtime above Ksh 5.00. Please enter amount" 30 | return self.ussd_proceed(menu_text) 31 | if amount is None: 32 | self.session['amount'] = int(self.user_response) 33 | self.session['level'] = 13 34 | menu_text = "Purchase Ksh{:.2f} worth of airtime for {}\n".format(self.session.get('amount'), 35 | self.session.get("phone_number")) 36 | menu_text += "1.Confirm\n2.Cancel" 37 | return self.ussd_proceed(menu_text) 38 | 39 | def confirm(self): # 13 40 | if self.user_response == "1": 41 | menu_text = "Please wait as we load your account." 42 | buyAirtime.apply_async( 43 | kwargs={'phone_number': self.session['phone_number'], 'amount': self.session['amount'], 44 | 'account_phoneNumber': self.user.phone_number}) 45 | return self.ussd_end(menu_text) 46 | if self.user_response == "2": 47 | menu_text = "Thank you for doing business with us" 48 | return self.ussd_end(menu_text) 49 | return self.get_amount(amount=True) 50 | 51 | def execute(self): 52 | level = self.session.get('level') 53 | menu = { 54 | 10: self.get_phone_number, 55 | 11: self.another_number, 56 | 12: self.get_amount, 57 | 13: self.confirm 58 | } 59 | return menu.get(level, self.home)() 60 | -------------------------------------------------------------------------------- /app/ussd/base_menu.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from flask import make_response, current_app 4 | 5 | from ..database import redis 6 | 7 | 8 | class Menu(object): 9 | def __init__(self, session_id, session, user, user_response, phone_number=None, level=None): 10 | self.session = session 11 | self.session_id = session_id 12 | self.user = user 13 | self.user_response = user_response 14 | self.phone_number = phone_number 15 | self.level = level 16 | 17 | def execute(self): 18 | raise NotImplementedError 19 | 20 | def ussd_proceed(self, menu_text): 21 | redis.set(self.session_id, json.dumps(self.session)) 22 | menu_text = "CON {}".format(menu_text) 23 | response = make_response(menu_text, 200) 24 | response.headers['Content-Type'] = "text/plain" 25 | return response 26 | 27 | def ussd_end(self, menu_text): 28 | redis.delete(self.session_id) 29 | menu_text = "END {}".format(menu_text) 30 | response = make_response(menu_text, 200) 31 | response.headers['Content-Type'] = "text/plain" 32 | return response 33 | 34 | def home(self): 35 | """serves the home menu""" 36 | menu_text = "Hello {}, welcome to {},\n Choose a service\n".format(self.user.username, 37 | current_app.config['APP_NAME']) 38 | menu_text += " 1. Deposit Money\n" 39 | menu_text += " 2. Withdraw Money\n" 40 | menu_text += " 3. Buy Airtime\n" 41 | menu_text += " 4. Check Wallet Balance\n" 42 | self.session['level'] = 1 43 | # print the response on to the page so that our gateway can read it 44 | return self.ussd_proceed(menu_text) 45 | -------------------------------------------------------------------------------- /app/ussd/decorators.py: -------------------------------------------------------------------------------- 1 | import json 2 | from functools import wraps 3 | import uuid 4 | 5 | from flask import g, request 6 | 7 | from . import ussd 8 | from .. import redis 9 | from ..models import User, AnonymousUser 10 | 11 | 12 | def validate_ussd_user(func): 13 | @wraps(func) 14 | def wrapper(*args, **kwargs): 15 | """Get user trying to access to USSD session and the session id and adds them to the g request variable""" 16 | # get user response 17 | text = request.values.get("text", "default") 18 | text_array = text.split("*") 19 | # get phone number 20 | phone_number = request.values.get("phoneNumber") 21 | # get session id 22 | session_id = request.values.get("sessionId") or str(uuid.uuid4()) 23 | # get user 24 | user = User.by_phoneNumber(phone_number) or AnonymousUser() 25 | # get session 26 | session = redis.get(session_id) 27 | if session is None: 28 | session = {"level": 0, "session_id": session_id} 29 | redis.set(session_id, json.dumps(session)) 30 | else: 31 | session = json.loads(session.decode()) 32 | # add user, response and session to the request variable g 33 | g.user_response = text_array[len(text_array) - 1] 34 | g.session = session 35 | g.current_user = user 36 | g.phone_number = phone_number 37 | g.session_id = session_id 38 | return func(*args, **kwargs) 39 | 40 | return wrapper 41 | 42 | 43 | @ussd.before_app_request 44 | @validate_ussd_user 45 | def before_request(): 46 | pass 47 | -------------------------------------------------------------------------------- /app/ussd/deposit.py: -------------------------------------------------------------------------------- 1 | from .base_menu import Menu 2 | from .tasks import makeC2Brequest 3 | 4 | 5 | class Deposit(Menu): 6 | def get_amount(self): # 50 7 | try: 8 | amount = int(self.user_response) 9 | except ValueError as exc: 10 | return self.home() 11 | self.session['amount'] = int(self.user_response) 12 | self.session['level'] = 51 13 | menu_text = "Deposit Ksh{:.2f} to your Wallet\n".format(self.session.get('amount')) 14 | menu_text += "1.Confirm\n2.Cancel" 15 | return self.ussd_proceed(menu_text) 16 | 17 | def confirm(self): # 51 18 | amount = self.session.get('amount') 19 | if self.user_response == "1": 20 | menu_text = "We are sending you an Mpesa Checkout for deposit of KES{} shortly".format(amount) 21 | makeC2Brequest.apply_async( 22 | kwargs={'phone_number': self.user.phone_number, 'amount': amount}) 23 | return self.ussd_end(menu_text) 24 | 25 | if self.user_response == "2": 26 | menu_text = "Thank you for doing business with us" 27 | return self.ussd_end(menu_text) 28 | 29 | return self.home() 30 | 31 | def execute(self): 32 | menu = { 33 | 50: self.get_amount, 34 | 51: self.confirm 35 | } 36 | return menu.get(self.level)() 37 | -------------------------------------------------------------------------------- /app/ussd/home.py: -------------------------------------------------------------------------------- 1 | from .base_menu import Menu 2 | from .tasks import check_balance 3 | 4 | 5 | class LowerLevelMenu(Menu): 6 | """serves the home menu""" 7 | def deposit(self): # 1 8 | menu_text = "Enter amount you wish to deposit?\n" 9 | self.session['level'] = 50 10 | return self.ussd_proceed(menu_text) 11 | 12 | def withdraw(self): # 2 13 | menu_text = "Enter amount you wish to withdraw?\n" 14 | self.session['level'] = 40 15 | return self.ussd_proceed(menu_text) 16 | 17 | def buy_airtime(self): # level 10 18 | menu_text = "Buy Airtime\n" \ 19 | "1. My Number\n" \ 20 | "2. Another Number\n" \ 21 | "0. Back" 22 | self.session['level'] = 10 23 | return self.ussd_proceed(menu_text) 24 | 25 | def check_balance(self): # 4 26 | menu_text = "Please wait as we load your account\nYou will receive an SMS notification shortly" 27 | # send balance async 28 | check_balance.apply_async(kwargs={'user_id': self.user.id}) 29 | return self.ussd_end(menu_text) 30 | 31 | def execute(self): 32 | menus = { 33 | '1': self.deposit, 34 | '2': self.withdraw, 35 | '3': self.buy_airtime, 36 | '4': self.check_balance 37 | } 38 | return menus.get(self.user_response, self.home)() 39 | -------------------------------------------------------------------------------- /app/ussd/register.py: -------------------------------------------------------------------------------- 1 | from .base_menu import Menu 2 | from ..models import User 3 | 4 | 5 | class RegistrationMenu(Menu): 6 | """Serves registration callbacks""" 7 | 8 | def get_number(self): 9 | # insert user's phone number 10 | self.session["level"] = 21 11 | menu_text = "Please choose a username" 12 | return self.ussd_proceed(menu_text) 13 | 14 | def get_username(self): 15 | username = self.user_response 16 | if username or not User.by_username(username): # check if user entered an option or username exists 17 | self.user = User.create(username=username, phone_number=self.phone_number) 18 | self.session["level"] = 0 19 | # go to home 20 | return self.home() 21 | else: # Request again for name - level has not changed... 22 | menu_text = "Username is already in use. Please enter your username \n" 23 | return self.ussd_proceed(menu_text) 24 | 25 | def execute(self): 26 | if self.session["level"] == 0: 27 | return self.get_number() 28 | else: 29 | return self.get_username() 30 | -------------------------------------------------------------------------------- /app/ussd/tasks.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from flask import current_app 4 | 5 | from .utils import iso_format 6 | from .. import celery 7 | from .. import celery_logger 8 | from ..AfricasTalkingGateway import NerdsMicrofinanceGatewayGatewayException as AfricasTalkingGatewayException 9 | from ..AfricasTalkingGateway import gateway as africastalkinggateway 10 | from ..models import User 11 | from ..util import kenya_time 12 | 13 | 14 | @celery.task(ignore_result=True) 15 | def check_balance(user_id): 16 | user = User.query.get(user_id) 17 | balance = iso_format(user.account) 18 | timestamp = kenya_time() 19 | transaction_cost = 0.00 20 | 21 | message = "{status}. Your Wallet balance was {balance} on {date} at {time} " \ 22 | "Transaction cost {transaction_cost:0.2f}\n".format(balance=balance, 23 | status='Confirmed', 24 | transaction_cost=transaction_cost, 25 | date=timestamp.date().strftime('%d/%m/%y'), 26 | time=timestamp.time().strftime('%H:%M %p')) 27 | try: 28 | resp = africastalkinggateway.sendMessage(to_=user.phone_number, message_=message) 29 | celery_logger.warn("Balance message sent to {}".format(user.phone_number)) 30 | except AfricasTalkingGatewayException as exc: 31 | celery_logger.error("Could not send account balance message to {} " 32 | "error {}".format(user.phone_number, exc)) 33 | 34 | 35 | @celery.task(bind=True, ignore_result=True) 36 | def buyAirtime(self, phone_number, amount, account_phoneNumber): 37 | """ 38 | :param phone_number: phone number to purchase airtime for 39 | :param amount: airtime worth 40 | :param account_phoneNumber: phone number linked to account making transaction 41 | :return: 42 | """ 43 | user = User.by_phoneNumber(account_phoneNumber) 44 | celery_logger.warn("{}".format(amount)) 45 | if not isinstance(amount, int): 46 | celery_logger.error("Invalid format for amount") 47 | value = iso_format(amount) 48 | timestamp = kenya_time() # generate timestamp 49 | if phone_number.startswith('0'): # transform phone number to ISO format 50 | phone_number = "+254" + phone_number[1:] 51 | 52 | if user.account < amount: # check if user has enough cash 53 | message = "Failed. There is not enough money in your account to buy airtime worth {amount}. " \ 54 | "Your Cash Value Wallet balance is {balance}\n".format(amount=value, 55 | balance=iso_format(user.account) 56 | ) 57 | africastalkinggateway.sendMessage(to_=user.phone_number, message_=message) 58 | celery_logger.error(message) 59 | return False 60 | recepients = [{"phoneNumber": phone_number, "amount": value}] 61 | try: 62 | response = africastalkinggateway.sendAirtime(recipients_=recepients)[0] # get response from AT 63 | if response['status'] == 'Success': 64 | user.account -= amount 65 | else: 66 | celery_logger.error("Airtime wasn't sent to {}".format(phone_number)) 67 | except AfricasTalkingGatewayException as exc: 68 | celery_logger.error("Encountered an error while sending airtime: {}".format(exc)) 69 | 70 | 71 | @celery.task(bind=True, ignore_result=True) 72 | def make_B2Crequest(self, phone_number, amount, reason): 73 | """ 74 | :param phone_number: 75 | :param amount: 76 | :param reason: 77 | :return: 78 | """ 79 | user = User.by_phoneNumber(phone_number) 80 | value = iso_format(amount) 81 | recipients = [ 82 | {"phoneNumber": phone_number, 83 | "currencyCode": user.address.code.currency_code, 84 | "amount": amount, 85 | "reason": reason, 86 | "metadata": { 87 | "phone_number": user.phone_number, 88 | "reason": reason 89 | } 90 | } 91 | ] 92 | if user.account < amount: 93 | message = "Failed. There is not enough money in your account to buy withdraw {amount}. " \ 94 | "Your Cash Value Wallet balance is {balance}\n".format(amount=value, 95 | balance=iso_format(user.account) 96 | ) 97 | africastalkinggateway.sendMessage(to_=user.phone_number, message_=message) 98 | celery_logger.error(message) 99 | try: 100 | response = africastalkinggateway.mobilePaymentB2CRequest(productName_=current_app.config["PRODUCT_NAME"], 101 | recipients_=recipients)[0] 102 | # withdrawal request 103 | transaction_fees = 0.00 104 | africastalkinggateway.sendMessage(to_=user.phone_number, 105 | message_="Confirmed. " 106 | "You have withdrwan {amount} from your Wallet to your" 107 | "Mpesa account." 108 | "Your new wallet balance is {balance}." 109 | "Transaction fee is {fees}" 110 | "".format(amount=value, 111 | fees=iso_format(transaction_fees), 112 | balance=iso_format(user.balance) 113 | ) 114 | ) 115 | 116 | 117 | except AfricasTalkingGatewayException as exc: 118 | celery_logger.error("B2C request experienced an errorr {}".format(exc)) 119 | raise self.retry(exc=exc, countdown=5) 120 | 121 | 122 | @celery.task(bind=True, ignore_result=True) 123 | def makeC2Brequest(self, phone_number, amount): 124 | metadata = { 125 | "transaction_id": str(uuid.uuid1()), 126 | "reason": 'Deposit' 127 | } 128 | # get user 129 | user = User.by_phoneNumber(phone_number) 130 | # get currency code 131 | currency_code = 'KES' 132 | timestamp = kenya_time() 133 | transaction_id = metadata.get('transaction_id') 134 | 135 | try: 136 | payments = africastalkinggateway.initiateMobilePaymentCheckout( 137 | productName_=current_app.config['PRODUCT_NAME'], 138 | currencyCode_=currency_code, 139 | amount_=amount, 140 | metadata_=metadata, 141 | providerChannel_="9142", 142 | phoneNumber_=phone_number 143 | ) 144 | user.account += amount 145 | user.save() 146 | celery_logger.warn( 147 | "New transaction id: {} logged".format( 148 | transaction_id 149 | ) 150 | ) 151 | except AfricasTalkingGatewayException as exc: 152 | celery_logger.error( 153 | "Could not complete transaction {exc}".format( 154 | exc=exc 155 | ) 156 | ) 157 | -------------------------------------------------------------------------------- /app/ussd/utils.py: -------------------------------------------------------------------------------- 1 | from flask import make_response 2 | 3 | 4 | def iso_format(amount): 5 | return "KES{}".format(amount) 6 | 7 | 8 | def respond(response): 9 | response = make_response(response, 200) 10 | response.headers['Content-Type'] = "text/plain" 11 | return response 12 | -------------------------------------------------------------------------------- /app/ussd/views.py: -------------------------------------------------------------------------------- 1 | from flask import g, make_response, request, url_for 2 | 3 | from . import ussd 4 | from .airtime import Airtime 5 | from .deposit import Deposit 6 | from .home import LowerLevelMenu 7 | from .register import RegistrationMenu 8 | from .utils import respond 9 | from .withdraw import WithDrawal 10 | from ..models import AnonymousUser 11 | 12 | 13 | @ussd.route('/', methods=['POST', 'GET']) 14 | def index(): 15 | response = make_response("END connection ok") 16 | response.headers['Content-Type'] = "text/plain" 17 | return response 18 | 19 | 20 | @ussd.route('/ussd/callback', methods=['POST']) 21 | def ussd_callback(): 22 | """Handles post call back from AT""" 23 | session_id = g.session_id 24 | user = g.current_user 25 | session = g.session 26 | user_response = g.user_response 27 | if isinstance(user, AnonymousUser): 28 | # register user 29 | menu = RegistrationMenu(session_id=session_id, session=session, phone_number=g.phone_number, 30 | user_response=user_response, user=user) 31 | return menu.execute() 32 | level = session.get('level') 33 | if level < 2: 34 | menu = LowerLevelMenu(session_id=session_id, session=session, phone_number=g.phone_number, 35 | user_response=user_response, user=user) 36 | return menu.execute() 37 | 38 | if level >= 50: 39 | menu = Deposit(session_id=session_id, session=session, phone_number=g.phone_number, 40 | user_response=user_response, user=user, level=level) 41 | return menu.execute() 42 | 43 | if level >= 40: 44 | menu = WithDrawal(session_id=session_id, session=session, phone_number=g.phone_number, 45 | user_response=user_response, user=user, level=level) 46 | return menu.execute() 47 | 48 | if level >= 10: 49 | menu = Airtime(session_id=session_id, session=session, phone_number=g.phone_number, user_response=user_response, 50 | user=user, level=level) 51 | return menu.execute() 52 | 53 | response = make_response("END nothing here", 200) 54 | response.headers['Content-Type'] = "text/plain" 55 | return response 56 | 57 | 58 | @ussd.route('/voice/menu') 59 | def voice_menu(): 60 | """ 61 | When the user enters the digit - in this case 0, 1 or 2, this route 62 | switches between the various dtmf digits to 63 | make an outgoing call to the right recipient, who will be 64 | bridged to speak to the person currently listening to music on hold. 65 | We specify this music with the ringtone flag as follows: 66 | ringbackTone="url_to/static/media/SautiFinaleMoney.mp3" 67 | """ 68 | 69 | # 1. Receive POST from AT 70 | isActive = request.get('isActive') 71 | callerNumber = request.get('callerNumber') 72 | dtmfDigits = request.get('dtmfDigits') 73 | sessionId = request.get('sessionId') 74 | # Check if isActive=1 to act on the call or isActive=='0' to store the 75 | # result 76 | 77 | if (isActive == '1'): 78 | # 2a. Switch through the DTMFDigits 79 | if (dtmfDigits == "0"): 80 | # Compose response - talk to sales- 81 | response = '' 82 | response += '' 83 | response += 'Please hold while we connect you to Sales.' 84 | response += ''.format( 85 | url_for('media', path='SautiFinaleMoney.mp3')) 86 | response += '' 87 | 88 | # Print the response onto the page so that our gateway can read it 89 | return respond(response) 90 | 91 | elif (dtmfDigits == "1"): 92 | # 2c. Compose response - talk to support- 93 | response = '' 94 | response += '' 95 | response += 'Please hold while we connect you to Support.' 96 | response += ''.format( 97 | url_for('media', path='SautiFinaleMoney.mp3')) 98 | response += '' 99 | 100 | # Print the response onto the page so that our gateway can read it 101 | return respond(response) 102 | elif (dtmfDigits == "2"): 103 | # 2d. Redirect to the main IVR- 104 | response = '' 105 | response += '' 106 | response += '{}'.format(url_for('voice_callback')) 107 | response += '' 108 | 109 | # Print the response onto the page so that our gateway can read it 110 | return respond(response) 111 | else: 112 | # 2e. By default talk to support 113 | response = '' 114 | response += '' 115 | response += 'Please hold while we connect you to Support.' 116 | response += ''.format( 117 | url_for('media', path='SautiFinaleMoney.mp3')) 118 | response += '' 119 | 120 | # Print the response onto the page so that our gateway can read it 121 | return respond(response) 122 | else: 123 | # 3. Store the data from the POST 124 | durationInSeconds = request.get('durationInSeconds') 125 | direction = request.get('direction') 126 | amount = request.get('amount') 127 | callerNumber = request.get('callerNumber') 128 | destinationNumber = request.get('destinationNumber') 129 | sessionId = request.get('sessionId') 130 | callStartTime = request.get('callStartTime') 131 | isActive = request.get('isActive') 132 | currencyCode = request.get('currencyCode') 133 | status = request.get('status') 134 | 135 | 136 | @ussd.route('/voice/callback', methods=['POST']) 137 | def voice_callback(): 138 | """ 139 | voice_callback from AT's gateway is handled here 140 | 141 | """ 142 | sessionId = request.get('sessionId') 143 | isActive = request.get('isActive') 144 | 145 | if isActive == "1": 146 | callerNumber = request.get('callerNumber') 147 | # GET values from the AT's POST request 148 | session_id = request.values.get("sessionId", None) 149 | isActive = request.values.get('isActive') 150 | serviceCode = request.values.get("serviceCode", None) 151 | text = request.values.get("text", "default") 152 | text_array = text.split("*") 153 | user_response = text_array[len(text_array) - 1] 154 | 155 | # Compose the response 156 | menu_text = '' 157 | menu_text += '' 158 | menu_text += '' 159 | menu_text += '"Thank you for calling. Press 0 to talk to sales, 1 to talk to support or 2 to hear this message again."' 160 | menu_text += '' 161 | menu_text += '"Thank you for calling. Good bye!"' 162 | menu_text += '' 163 | 164 | # Print the response onto the page so that our gateway can read it 165 | return respond(menu_text) 166 | 167 | else: 168 | # Read in call details (duration, cost). This flag is set once the call is completed. 169 | # Note that the gateway does not expect a response in thie case 170 | 171 | duration = request.get('durationInSeconds') 172 | currencyCode = request.get('currencyCode') 173 | amount = request.get('amount') 174 | 175 | # You can then store this information in the database for your records 176 | -------------------------------------------------------------------------------- /app/ussd/withdraw.py: -------------------------------------------------------------------------------- 1 | from .base_menu import Menu 2 | from .tasks import make_B2Crequest 3 | 4 | 5 | class WithDrawal(Menu): 6 | def get_amount(self): # 40 7 | try: 8 | amount = int(self.user_response) 9 | except ValueError as exc: 10 | return self.home() 11 | self.session['amount'] = int(self.user_response) 12 | self.session['level'] = 51 13 | menu_text = "Withdraw Ksh{:.2f} from your Wallet\n".format(self.session.get('amount')) 14 | menu_text += "1.Confirm\n2.Cancel" 15 | return self.ussd_proceed(menu_text) 16 | 17 | def confirm(self): # 41 18 | amount = self.session.get('amount') 19 | if self.user_response == "1": 20 | menu_text = "We are sending your withrawal deposit of KES{} shortly".format(amount) 21 | make_B2Crequest.apply_async( 22 | kwargs={'phone_number': self.user.phone_number, 'amount': amount, 'reason': 'Withdraw'}) 23 | return self.ussd_end(menu_text) 24 | 25 | if self.user_response == "2": 26 | menu_text = "Thank you for doing business with us" 27 | return self.ussd_end(menu_text) 28 | return self.home() 29 | 30 | def execute(self): 31 | menu = { 32 | 40: self.get_amount, 33 | 41: self.confirm 34 | } 35 | return menu.get(self.level, self.home)() -------------------------------------------------------------------------------- /app/util.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import logging.config 4 | import os 5 | 6 | import yaml 7 | 8 | 9 | def kenya_time(): 10 | return datetime.datetime.utcnow() + datetime.timedelta(hours=3) 11 | 12 | 13 | def setup_logging(file_name="app_logger.yaml", default_level=logging.INFO, logger_file_path=None): 14 | """ 15 | Logging configuration. 16 | """ 17 | if logger_file_path is None: 18 | path = os.path.abspath(os.path.join(os.path.dirname(__file__), 19 | "../", file_name)) 20 | if os.path.exists(logger_file_path): 21 | with open(logger_file_path, 'rt') as f: 22 | config = yaml.safe_load(f.read()) 23 | logging.config.dictConfig(config) 24 | -------------------------------------------------------------------------------- /app_logger.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1 3 | disable_existing_loggers: False 4 | formatters: 5 | simple: 6 | format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s" 7 | 8 | handlers: 9 | console: 10 | class: logging.StreamHandler 11 | level: DEBUG 12 | formatter: simple 13 | stream: ext://sys.stdout 14 | 15 | root: 16 | level: INFO 17 | handlers: [console] -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | # Starter pipeline 2 | 3 | # Start with a minimal pipeline that you can customize to build and deploy your code. 4 | 5 | # Add steps that build, run tests, deploy, and more: 6 | 7 | # https://aka.ms/yaml 8 | 9 | 10 | 11 | trigger: 12 | 13 | - master 14 | 15 | 16 | 17 | variables: 18 | 19 | imageName: ussd-demo 20 | 21 | vmImageName: "ubuntu-latest" 22 | 23 | dockerfilePath: "**/Dockerfile" 24 | 25 | dockerRegistryServiceConnection: workPrawnACR 26 | 27 | k8sNamespace: ussd-demo 28 | 29 | acrRegistryName: workprawnacr.azurecr.io 30 | 31 | revisionID: $(Build.BuildId) 32 | 33 | azureResourceGroup: phoenix 34 | 35 | kubernetesCluster: phoenix-k8s 36 | 37 | azureSubscriptionEndpoint: workPrawnARM 38 | 39 | kubernetesServiceEndpoint: "" 40 | 41 | 42 | 43 | 44 | 45 | stages: 46 | 47 | - stage: Build 48 | 49 | displayName: Build and push stage 50 | 51 | jobs: 52 | 53 | - job: Build 54 | 55 | displayName: Build job 56 | 57 | pool: 58 | 59 | vmImage: $(vmImageName) 60 | 61 | steps: 62 | 63 | - task: Docker@2 64 | 65 | displayName: Login to ACR 66 | 67 | inputs: 68 | 69 | command: login 70 | 71 | containerRegistry: $(dockerRegistryServiceConnection) 72 | 73 | - task: Docker@2 74 | 75 | displayName: Build and push an image to container registry 76 | 77 | inputs: 78 | 79 | command: buildAndPush 80 | 81 | repository: $(imageName) 82 | 83 | dockerfile: $(dockerfilePath) 84 | 85 | containerRegistry: $(dockerRegistryServiceConnection) 86 | 87 | tags: | 88 | 89 | $(Build.BuildId) 90 | 91 | latest 92 | 93 | - task: Docker@2 94 | 95 | displayName: Logout from ACR 96 | 97 | inputs: 98 | 99 | command: logout 100 | 101 | containerRegistry: $(dockerRegistryServiceConnection) 102 | 103 | 104 | 105 | - stage: Deploy 106 | 107 | displayName: Deploy 108 | 109 | jobs: 110 | 111 | - deployment: production 112 | 113 | displayName: Deploy to production 114 | 115 | pool: 116 | 117 | vmImage: $(vmImageName) 118 | 119 | environment: prod 120 | 121 | strategy: 122 | 123 | runOnce: 124 | 125 | deploy: 126 | 127 | steps: 128 | 129 | - task: Kubernetes@1 130 | 131 | displayName: Deploy web 132 | 133 | inputs: 134 | 135 | connectionType: Azure Resource Manager 136 | 137 | azureSubscriptionEndpoint: $(azureSubscriptionEndpoint) 138 | 139 | azureResourceGroup: $(azureResourceGroup) 140 | 141 | kubernetesCluster: $(kubernetesCluster) 142 | 143 | namespace: $(k8sNamespace) 144 | 145 | command: set 146 | 147 | arguments: image deployment/ussd-demo ussd-demo=$(acrRegistryName)/$(imageName):$(revisionID) -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | # usr/bin/python 2 | """ 3 | Configuration for the USSD application 4 | """ 5 | import os 6 | 7 | basedir = os.path.abspath(os.path.dirname(__file__)) # base directory 8 | 9 | 10 | class Config: 11 | """General configuration variables""" 12 | 13 | # flask specific application configurations 14 | DEBUG = False 15 | TESTING = False 16 | 17 | # security credentials 18 | SECRET_KEY = b"I\xf9\x9cF\x1e\x04\xe6\xfaF\x8f\xe6)-\xa432" # use a secure key 19 | CSRF_ENABLED = True 20 | 21 | # persistance layer configs - configure database, redis and celery backend 22 | CELERY_BROKER_URL = os.getenv('REDIS_URL', "redis://localhost:6379") 23 | CELERY_RESULT_BACKEND = os.getenv('REDIS_URL', "redis://localhost:6379") 24 | REDIS_URL = os.getenv('REDIS_URL', "redis://localhost:6379") 25 | SQLALCHEMY_DATABASE_URI = os.getenv("SQLALCHEMY_DATABASE_URI") or 'sqlite:///' + os.path.join(basedir, 'data.sqlite') 26 | SQLALCHEMY_TRACK_MODIFICATIONS = False 27 | SQLALCHEMY_COMMIT_ON_TEARDOWN = True 28 | SSL_DISABLE = True 29 | CELERY_ACCEPT_CONTENT = ['pickle', 'json', 'msgpack', 'yaml'] 30 | 31 | # africastalking credentials 32 | AT_ENVIRONMENT = os.getenv('AT_ENVIRONMENT') 33 | AT_USERNAME = os.getenv('AT_USERNAME') 34 | AT_APIKEY = os.getenv('AT_APIKEY') 35 | APP_NAME = 'nerds-microfinance-ussd-application' 36 | 37 | # application credentials 38 | ADMIN_PHONENUMBER = os.getenv('ADMIN_PHONENUMBER') 39 | 40 | @classmethod 41 | def init_app(cls, app): 42 | pass 43 | 44 | 45 | class DevelopmentConfig(Config): 46 | """ 47 | Configuration variables when in development mode 48 | """ 49 | """Development Mode configuration""" 50 | DEBUG = True 51 | CSRF_ENABLED = False 52 | 53 | @classmethod 54 | def init_app(cls, app): 55 | Config.init_app(app) 56 | 57 | 58 | class ProductionConfig(Config): 59 | """Production Mode configuration""" 60 | CSRF_ENABLED = True 61 | 62 | @classmethod 63 | def init_app(cls, app): 64 | Config.init_app(app) 65 | # handle proxy server errors 66 | from werkzeug.contrib.fixers import ProxyFix 67 | app.wsgi_app = ProxyFix(app.wsgi_app) 68 | 69 | SSL_DISABLE = bool(os.environ.get('SSL_DISABLE')) 70 | 71 | 72 | class TestingConfig(Config): 73 | """ 74 | Testing configuration variables 75 | """ 76 | TESTING = True 77 | # use a temporary database for testing 78 | SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or \ 79 | 'sqlite:///' + os.path.join(basedir, 'data-test.sqlite') 80 | 81 | 82 | config = { 83 | 'development': DevelopmentConfig, 84 | 'testing': TestingConfig, 85 | 'production': ProductionConfig, 86 | 'default': DevelopmentConfig 87 | 88 | } 89 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # NOTE: This docker-compose.yml is meant to be just an example of how 2 | # you could accomplish this on your own. It is not intended to work in 3 | # all use-cases and must be adapted to fit your needs. This is merely 4 | # a guideline. 5 | 6 | # instructions 7 | 8 | version: '3.4' 9 | 10 | x-defaults: &defaults 11 | restart: unless-stopped 12 | image: darklotus/nerds-microfinance:latest 13 | build: . 14 | depends_on: 15 | - redis 16 | - postgres 17 | command: ["start_app.sh"] 18 | environment: 19 | FLASK_DEBUG: 1 # set this to 0 to disable debug mode 20 | FLASK_APP: manage.py 21 | FLASK_ENVIRONMENT : production 22 | ADMIN_PHONENUMBER : +254703554404 23 | DATABASE_URL : postgresql://apps:apps_apps@postgres/nerds_microfinance 24 | REDIS_URL : redis://redis:6379/0 25 | AT_USERNAME : awesome_nerd 26 | AT_APIKEY : some_api_key 27 | AT_ENVIRONMENT : sandbox 28 | volumes: 29 | - .:/opt/apps/app 30 | 31 | services: 32 | redis: 33 | restart: unless-stopped 34 | image: redis:3.2-alpine 35 | 36 | postgres: 37 | restart: unless-stopped 38 | image: postgres:11 39 | environment: 40 | POSTGRES_USER: apps 41 | POSTGRES_PASSWORD: apps_pass 42 | POSTGRES_DB: nerds_microfinance 43 | 44 | web: 45 | <<: *defaults 46 | ports: 47 | - '0.0.0.0:8000:8000' 48 | 49 | worker: 50 | <<: *defaults 51 | command: ["start_worker.sh"] 52 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | # -*-coding:utf-8-*- 2 | """Main application script""" 3 | 4 | import os 5 | 6 | import click 7 | from flask_migrate import Migrate 8 | 9 | from app import create_app, db 10 | from app.models import User 11 | 12 | app = create_app(os.getenv('APP_CONFIG') or 'default') 13 | migrate = Migrate(app, db) 14 | 15 | 16 | # set up code coverage 17 | COV = None 18 | if os.environ.get('APP_COVERAGE'): 19 | import coverage 20 | 21 | COV = coverage.coverage(branch=True, include='app/*') 22 | COV.start() 23 | 24 | 25 | @app.shell_context_processor 26 | def make_shell_context(): 27 | return dict(app=app, db=db, User=User) 28 | 29 | 30 | @app.cli.command() 31 | def initdb(): 32 | """Initialize the database.""" 33 | click.echo('Init the db') 34 | from flask_migrate import upgrade 35 | # migrate database to latest revision 36 | upgrade() 37 | 38 | 39 | @app.cli.command() 40 | def test(coverage=False): 41 | """Run the unit tests.""" 42 | if coverage and not os.environ.get('FLASK_COVERAGE'): 43 | import sys 44 | os.environ['FLASK_COVERAGE'] = '1' 45 | os.execvp(sys.executable, [sys.executable] + sys.argv) 46 | import unittest 47 | tests = unittest.TestLoader().discover('tests') 48 | unittest.TextTestRunner(verbosity=2).run(tests) 49 | if COV: 50 | COV.stop() 51 | COV.save() 52 | print("Coverage Summary:") 53 | COV.report() 54 | basedir = os.path.abspath(os.path.dirname(__file__)) 55 | covdir = os.path.join(basedir, 'temp/coverage') 56 | COV.html_report(directory=covdir) 57 | print('HTML version: file://{covdir}index.html'.format(covdir=covdir)) 58 | COV.erase() 59 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | from alembic import context 3 | from sqlalchemy import engine_from_config, pool 4 | from logging.config import fileConfig 5 | import logging 6 | 7 | # this is the Alembic Config object, which provides 8 | # access to the values within the .ini file in use. 9 | config = context.config 10 | 11 | # Interpret the config file for Python logging. 12 | # This line sets up loggers basically. 13 | fileConfig(config.config_file_name) 14 | logger = logging.getLogger('alembic.env') 15 | 16 | # add your model's MetaData object here 17 | # for 'autogenerate' support 18 | # from myapp import mymodel 19 | # target_metadata = mymodel.Base.metadata 20 | from flask import current_app 21 | config.set_main_option('sqlalchemy.url', 22 | current_app.config.get('SQLALCHEMY_DATABASE_URI')) 23 | target_metadata = current_app.extensions['migrate'].db.metadata 24 | 25 | # other values from the config, defined by the needs of env.py, 26 | # can be acquired: 27 | # my_important_option = config.get_main_option("my_important_option") 28 | # ... etc. 29 | 30 | 31 | def run_migrations_offline(): 32 | """Run migrations in 'offline' mode. 33 | 34 | This configures the context with just a URL 35 | and not an Engine, though an Engine is acceptable 36 | here as well. By skipping the Engine creation 37 | we don't even need a DBAPI to be available. 38 | 39 | Calls to context.execute() here emit the given string to the 40 | script output. 41 | 42 | """ 43 | url = config.get_main_option("sqlalchemy.url") 44 | context.configure(url=url) 45 | 46 | with context.begin_transaction(): 47 | context.run_migrations() 48 | 49 | 50 | def run_migrations_online(): 51 | """Run migrations in 'online' mode. 52 | 53 | In this scenario we need to create an Engine 54 | and associate a connection with the context. 55 | 56 | """ 57 | 58 | # this callback is used to prevent an auto-migration from being generated 59 | # when there are no changes to the schema 60 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 61 | def process_revision_directives(context, revision, directives): 62 | if getattr(config.cmd_opts, 'autogenerate', False): 63 | script = directives[0] 64 | if script.upgrade_ops.is_empty(): 65 | directives[:] = [] 66 | logger.info('No changes in schema detected.') 67 | 68 | engine = engine_from_config(config.get_section(config.config_ini_section), 69 | prefix='sqlalchemy.', 70 | poolclass=pool.NullPool) 71 | 72 | connection = engine.connect() 73 | context.configure(connection=connection, 74 | target_metadata=target_metadata, 75 | process_revision_directives=process_revision_directives, 76 | **current_app.extensions['migrate'].configure_args) 77 | 78 | try: 79 | with context.begin_transaction(): 80 | context.run_migrations() 81 | finally: 82 | connection.close() 83 | 84 | if context.is_offline_mode(): 85 | run_migrations_offline() 86 | else: 87 | run_migrations_online() 88 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /migrations/versions/eb45bca11515_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: eb45bca11515 4 | Revises: 5 | Create Date: 2019-07-17 22:18:05.254132 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'eb45bca11515' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('users', 22 | sa.Column('created_by', sa.String(length=128), nullable=True), 23 | sa.Column('created_date', sa.DateTime(), nullable=True), 24 | sa.Column('last_edited_date', sa.DateTime(), nullable=True), 25 | sa.Column('last_edited_by', sa.String(length=128), nullable=True), 26 | sa.Column('active', sa.Boolean(), nullable=True), 27 | sa.Column('id', sa.Integer(), nullable=False), 28 | sa.Column('username', sa.String(length=64), nullable=True), 29 | sa.Column('phone_number', sa.String(length=64), nullable=False), 30 | sa.Column('account', sa.Float(), nullable=True), 31 | sa.Column('reg_date', sa.DateTime(), nullable=True), 32 | sa.Column('validation', sa.String(), nullable=True), 33 | sa.Column('city', sa.String(length=32), nullable=True), 34 | sa.PrimaryKeyConstraint('id') 35 | ) 36 | op.create_index(op.f('ix_users_phone_number'), 'users', ['phone_number'], unique=True) 37 | op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=False) 38 | # ### end Alembic commands ### 39 | 40 | 41 | def downgrade(): 42 | # ### commands auto generated by Alembic - please adjust! ### 43 | op.drop_index(op.f('ix_users_username'), table_name='users') 44 | op.drop_index(op.f('ix_users_phone_number'), table_name='users') 45 | op.drop_table('users') 46 | # ### end Alembic commands ### 47 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | AfricastalkingGateway==1.9 2 | blinker==1.4 3 | celery==4.3.0 4 | Flask==1.1.1 5 | Flask-Migrate==2.5.2 6 | Flask-Redis==0.3.0 7 | Flask-SQLAlchemy==2.4.0 8 | Flask-SSLify==0.1.5 9 | gunicorn==19.9.0 10 | psycopg2==2.8.3 11 | pyaml==19.4.1 12 | PyYAML==5.1.1 13 | redis==3.2.1 14 | requests==2.22.0 15 | SQLAlchemy==1.3.5 16 | urllib3==1.25.3 17 | watchdog==0.9.0 18 | Werkzeug==0.15.4 19 | python-dotenv 20 | Flask-Login -------------------------------------------------------------------------------- /start_app.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | export LC_ALL=C.UTF-8 4 | export LANG=C.UTF-8 5 | export FLASK_APP=manage.py 6 | 7 | /appenv/bin/flask initdb 8 | 9 | exec /appenv/bin/flask run --host 0.0.0.0 --port 8000 10 | -------------------------------------------------------------------------------- /start_worker.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | export PYTHONPATH=/opt/apps/app 3 | export C_FORCE_ROOT=true 4 | exec /appenv/bin/celery worker -A worker.celery --loglevel=INFO -------------------------------------------------------------------------------- /worker.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from app import celery, create_app 4 | 5 | app = create_app(os.getenv('USSD_CONFIG') or 'default') 6 | app.app_context().push() 7 | --------------------------------------------------------------------------------