Remove submodule cic ussd
							
								
								
									
										3
									
								
								.gitmodules
									
									
									
									
										vendored
									
									
								
							
							
						
						@ -1,6 +1,3 @@
 | 
			
		||||
[submodule "apps/cic-ussd"]
 | 
			
		||||
	path = apps/cic-ussd
 | 
			
		||||
	url = git@gitlab.com:grassrootseconomics/cic-ussd.git
 | 
			
		||||
[submodule "apps/cic-notify"]
 | 
			
		||||
	path = apps/cic-notify
 | 
			
		||||
	url = git@gitlab.com:grassrootseconomics/cic-notify.git
 | 
			
		||||
 | 
			
		||||
@ -1 +0,0 @@
 | 
			
		||||
Subproject commit c5ffcc19c5a48b101de689385c1ae971de0a5439
 | 
			
		||||
							
								
								
									
										16
									
								
								apps/cic-ussd/.config/app.ini
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,16 @@
 | 
			
		||||
[app]
 | 
			
		||||
ALLOWED_IP=127.0.0.1
 | 
			
		||||
LOCALE_FALLBACK=en
 | 
			
		||||
LOCALE_PATH=var/lib/locale/
 | 
			
		||||
MAX_BODY_LENGTH=1024
 | 
			
		||||
PASSWORD_PEPPER=QYbzKff6NhiQzY3ygl2BkiKOpER8RE/Upqs/5aZWW+I=
 | 
			
		||||
SERVICE_CODE=*483*46#
 | 
			
		||||
 | 
			
		||||
[ussd]
 | 
			
		||||
MENU_FILE=/usr/local/lib/python3.8/site-packages/cic_ussd/db/ussd_menu.json
 | 
			
		||||
 | 
			
		||||
[statemachine]
 | 
			
		||||
STATES=/usr/src/cic-ussd/states/
 | 
			
		||||
TRANSITIONS=/usr/src/cic-ussd/transitions/
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										2
									
								
								apps/cic-ussd/.config/cic.ini
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,2 @@
 | 
			
		||||
[cic]
 | 
			
		||||
chain_spec = Bloxberg:8995
 | 
			
		||||
							
								
								
									
										8
									
								
								apps/cic-ussd/.config/database.ini
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,8 @@
 | 
			
		||||
[database]
 | 
			
		||||
NAME=cic_ussd
 | 
			
		||||
USER=postgres
 | 
			
		||||
PASSWORD=password
 | 
			
		||||
HOST=localhost
 | 
			
		||||
PORT=5432
 | 
			
		||||
ENGINE=postgresql
 | 
			
		||||
DRIVER=psycopg2
 | 
			
		||||
							
								
								
									
										5
									
								
								apps/cic-ussd/.config/pip.ini
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,5 @@
 | 
			
		||||
[PIP]
 | 
			
		||||
extra_index_host = pip.grassrootseconomics.net
 | 
			
		||||
extra_index_port = 8433
 | 
			
		||||
extra_index_path = /
 | 
			
		||||
extra_index_proto = https
 | 
			
		||||
							
								
								
									
										9
									
								
								apps/cic-ussd/.config/redis.ini
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,9 @@
 | 
			
		||||
[celery]
 | 
			
		||||
BROKER_URL=redis://
 | 
			
		||||
RESULT_URL=redis://
 | 
			
		||||
 | 
			
		||||
[redis]
 | 
			
		||||
HOSTNAME=localhost
 | 
			
		||||
PASSWORD=
 | 
			
		||||
PORT=6379
 | 
			
		||||
DATABASE=0
 | 
			
		||||
							
								
								
									
										14
									
								
								apps/cic-ussd/.config/test/app.ini
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,14 @@
 | 
			
		||||
[app]
 | 
			
		||||
ALLOWED_IP=127.0.0.1
 | 
			
		||||
LOCALE_FALLBACK=en
 | 
			
		||||
LOCALE_PATH=var/lib/locale/
 | 
			
		||||
MAX_BODY_LENGTH=1024
 | 
			
		||||
PASSWORD_PEPPER=QYbzKff6NhiQzY3ygl2BkiKOpER8RE/Upqs/5aZWW+I=
 | 
			
		||||
SERVICE_CODE=*483*46#
 | 
			
		||||
 | 
			
		||||
[ussd]
 | 
			
		||||
MENU_FILE=cic_ussd/db/ussd_menu.json
 | 
			
		||||
 | 
			
		||||
[statemachine]
 | 
			
		||||
STATES=states/
 | 
			
		||||
TRANSITIONS=transitions/
 | 
			
		||||
							
								
								
									
										2
									
								
								apps/cic-ussd/.config/test/cic.ini
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,2 @@
 | 
			
		||||
[cic]
 | 
			
		||||
chain_spec = Bloxberg:8995
 | 
			
		||||
							
								
								
									
										8
									
								
								apps/cic-ussd/.config/test/database.ini
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,8 @@
 | 
			
		||||
[database]
 | 
			
		||||
NAME=cic_ussd_test
 | 
			
		||||
USER=postgres
 | 
			
		||||
PASSWORD=
 | 
			
		||||
HOST=localhost
 | 
			
		||||
PORT=5432
 | 
			
		||||
ENGINE=sqlite
 | 
			
		||||
DRIVER=pysqlite
 | 
			
		||||
							
								
								
									
										5
									
								
								apps/cic-ussd/.config/test/pip.ini
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,5 @@
 | 
			
		||||
[PIP]
 | 
			
		||||
extra_index_host = pip.grassrootseconomics.net
 | 
			
		||||
extra_index_port = 8433
 | 
			
		||||
extra_index_path = /
 | 
			
		||||
extra_index_proto = https
 | 
			
		||||
							
								
								
									
										9
									
								
								apps/cic-ussd/.config/test/redis.ini
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,9 @@
 | 
			
		||||
[celery]
 | 
			
		||||
BROKER_URL = filesystem://
 | 
			
		||||
RESULT_URL = filesystem://
 | 
			
		||||
 | 
			
		||||
[redis]
 | 
			
		||||
HOSTNAME=localhost
 | 
			
		||||
PASSWORD=
 | 
			
		||||
PORT=6379
 | 
			
		||||
DATABASE=0
 | 
			
		||||
							
								
								
									
										6
									
								
								apps/cic-ussd/.coveragerc
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,6 @@
 | 
			
		||||
[report]
 | 
			
		||||
omit =
 | 
			
		||||
	venv/*
 | 
			
		||||
	scripts/*
 | 
			
		||||
	cic_ussd/db/migrations/*
 | 
			
		||||
	cic_ussd/runnable/*
 | 
			
		||||
							
								
								
									
										5
									
								
								apps/cic-ussd/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,5 @@
 | 
			
		||||
__pycache__
 | 
			
		||||
.idea
 | 
			
		||||
venv
 | 
			
		||||
node_modules
 | 
			
		||||
.coverage
 | 
			
		||||
							
								
								
									
										42
									
								
								apps/cic-ussd/.gitlab-ci.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,42 @@
 | 
			
		||||
image: docker:19.03.13
 | 
			
		||||
 | 
			
		||||
variables:
 | 
			
		||||
  # docker host
 | 
			
		||||
  DOCKER_HOST: tcp://docker:2376
 | 
			
		||||
  # container, thanks to volume mount from config.toml
 | 
			
		||||
  DOCKER_TLS_CERTDIR: "/certs"
 | 
			
		||||
  # These are usually specified by the entrypoint, however the
 | 
			
		||||
  # Kubernetes executor doesn't run entrypoints
 | 
			
		||||
  # https://gitlab.com/gitlab-org/gitlab-runner/-/issues/4125
 | 
			
		||||
  DOCKER_TLS_VERIFY: 1
 | 
			
		||||
  DOCKER_CERT_PATH: "$DOCKER_TLS_CERTDIR/client"
 | 
			
		||||
 | 
			
		||||
services:
 | 
			
		||||
  - docker:19.03.13-dind
 | 
			
		||||
 | 
			
		||||
before_script:
 | 
			
		||||
  - docker info
 | 
			
		||||
 | 
			
		||||
build_merge_request:
 | 
			
		||||
  stage: build
 | 
			
		||||
  rules:
 | 
			
		||||
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
 | 
			
		||||
      when: always
 | 
			
		||||
  script:
 | 
			
		||||
    - docker build -t $CI_PROJECT_PATH_SLUG:$CI_COMMIT_SHORT_SHA -f docker/Dockerfile .
 | 
			
		||||
 | 
			
		||||
build_image:
 | 
			
		||||
  stage: build
 | 
			
		||||
  variables:
 | 
			
		||||
    IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG-$CI_COMMIT_SHORT_SHA
 | 
			
		||||
    LATEST_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG-latest
 | 
			
		||||
  script:
 | 
			
		||||
    # - docker build -t $IMAGE_TAG .
 | 
			
		||||
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" $CI_REGISTRY --password-stdin
 | 
			
		||||
    - docker build -t $IMAGE_TAG -f docker/Dockerfile .
 | 
			
		||||
    - docker push $IMAGE_TAG
 | 
			
		||||
    - docker tag $IMAGE_TAG $LATEST_TAG
 | 
			
		||||
    - docker push $LATEST_TAG
 | 
			
		||||
  only:
 | 
			
		||||
    - master
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										674
									
								
								apps/cic-ussd/LICENSE.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,674 @@
 | 
			
		||||
                    GNU GENERAL PUBLIC LICENSE
 | 
			
		||||
                       Version 3, 29 June 2007
 | 
			
		||||
 | 
			
		||||
 Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
 | 
			
		||||
 Everyone is permitted to copy and distribute verbatim copies
 | 
			
		||||
 of this license document, but changing it is not allowed.
 | 
			
		||||
 | 
			
		||||
                            Preamble
 | 
			
		||||
 | 
			
		||||
  The GNU General Public License is a free, copyleft license for
 | 
			
		||||
software and other kinds of works.
 | 
			
		||||
 | 
			
		||||
  The licenses for most software and other practical works are designed
 | 
			
		||||
to take away your freedom to share and change the works.  By contrast,
 | 
			
		||||
the GNU General Public License is intended to guarantee your freedom to
 | 
			
		||||
share and change all versions of a program--to make sure it remains free
 | 
			
		||||
software for all its users.  We, the Free Software Foundation, use the
 | 
			
		||||
GNU General Public License for most of our software; it applies also to
 | 
			
		||||
any other work released this way by its authors.  You can apply it to
 | 
			
		||||
your programs, too.
 | 
			
		||||
 | 
			
		||||
  When we speak of free software, we are referring to freedom, not
 | 
			
		||||
price.  Our General Public Licenses are designed to make sure that you
 | 
			
		||||
have the freedom to distribute copies of free software (and charge for
 | 
			
		||||
them if you wish), that you receive source code or can get it if you
 | 
			
		||||
want it, that you can change the software or use pieces of it in new
 | 
			
		||||
free programs, and that you know you can do these things.
 | 
			
		||||
 | 
			
		||||
  To protect your rights, we need to prevent others from denying you
 | 
			
		||||
these rights or asking you to surrender the rights.  Therefore, you have
 | 
			
		||||
certain responsibilities if you distribute copies of the software, or if
 | 
			
		||||
you modify it: responsibilities to respect the freedom of others.
 | 
			
		||||
 | 
			
		||||
  For example, if you distribute copies of such a program, whether
 | 
			
		||||
gratis or for a fee, you must pass on to the recipients the same
 | 
			
		||||
freedoms that you received.  You must make sure that they, too, receive
 | 
			
		||||
or can get the source code.  And you must show them these terms so they
 | 
			
		||||
know their rights.
 | 
			
		||||
 | 
			
		||||
  Developers that use the GNU GPL protect your rights with two steps:
 | 
			
		||||
(1) assert copyright on the software, and (2) offer you this License
 | 
			
		||||
giving you legal permission to copy, distribute and/or modify it.
 | 
			
		||||
 | 
			
		||||
  For the developers' and authors' protection, the GPL clearly explains
 | 
			
		||||
that there is no warranty for this free software.  For both users' and
 | 
			
		||||
authors' sake, the GPL requires that modified versions be marked as
 | 
			
		||||
changed, so that their problems will not be attributed erroneously to
 | 
			
		||||
authors of previous versions.
 | 
			
		||||
 | 
			
		||||
  Some devices are designed to deny users access to install or run
 | 
			
		||||
modified versions of the software inside them, although the manufacturer
 | 
			
		||||
can do so.  This is fundamentally incompatible with the aim of
 | 
			
		||||
protecting users' freedom to change the software.  The systematic
 | 
			
		||||
pattern of such abuse occurs in the area of products for individuals to
 | 
			
		||||
use, which is precisely where it is most unacceptable.  Therefore, we
 | 
			
		||||
have designed this version of the GPL to prohibit the practice for those
 | 
			
		||||
products.  If such problems arise substantially in other domains, we
 | 
			
		||||
stand ready to extend this provision to those domains in future versions
 | 
			
		||||
of the GPL, as needed to protect the freedom of users.
 | 
			
		||||
 | 
			
		||||
  Finally, every program is threatened constantly by software patents.
 | 
			
		||||
States should not allow patents to restrict development and use of
 | 
			
		||||
software on general-purpose computers, but in those that do, we wish to
 | 
			
		||||
avoid the special danger that patents applied to a free program could
 | 
			
		||||
make it effectively proprietary.  To prevent this, the GPL assures that
 | 
			
		||||
patents cannot be used to render the program non-free.
 | 
			
		||||
 | 
			
		||||
  The precise terms and conditions for copying, distribution and
 | 
			
		||||
modification follow.
 | 
			
		||||
 | 
			
		||||
                       TERMS AND CONDITIONS
 | 
			
		||||
 | 
			
		||||
  0. Definitions.
 | 
			
		||||
 | 
			
		||||
  "This License" refers to version 3 of the GNU General Public License.
 | 
			
		||||
 | 
			
		||||
  "Copyright" also means copyright-like laws that apply to other kinds of
 | 
			
		||||
works, such as semiconductor masks.
 | 
			
		||||
 | 
			
		||||
  "The Program" refers to any copyrightable work licensed under this
 | 
			
		||||
License.  Each licensee is addressed as "you".  "Licensees" and
 | 
			
		||||
"recipients" may be individuals or organizations.
 | 
			
		||||
 | 
			
		||||
  To "modify" a work means to copy from or adapt all or part of the work
 | 
			
		||||
in a fashion requiring copyright permission, other than the making of an
 | 
			
		||||
exact copy.  The resulting work is called a "modified version" of the
 | 
			
		||||
earlier work or a work "based on" the earlier work.
 | 
			
		||||
 | 
			
		||||
  A "covered work" means either the unmodified Program or a work based
 | 
			
		||||
on the Program.
 | 
			
		||||
 | 
			
		||||
  To "propagate" a work means to do anything with it that, without
 | 
			
		||||
permission, would make you directly or secondarily liable for
 | 
			
		||||
infringement under applicable copyright law, except executing it on a
 | 
			
		||||
computer or modifying a private copy.  Propagation includes copying,
 | 
			
		||||
distribution (with or without modification), making available to the
 | 
			
		||||
public, and in some countries other activities as well.
 | 
			
		||||
 | 
			
		||||
  To "convey" a work means any kind of propagation that enables other
 | 
			
		||||
parties to make or receive copies.  Mere interaction with a user through
 | 
			
		||||
a computer network, with no transfer of a copy, is not conveying.
 | 
			
		||||
 | 
			
		||||
  An interactive user interface displays "Appropriate Legal Notices"
 | 
			
		||||
to the extent that it includes a convenient and prominently visible
 | 
			
		||||
feature that (1) displays an appropriate copyright notice, and (2)
 | 
			
		||||
tells the user that there is no warranty for the work (except to the
 | 
			
		||||
extent that warranties are provided), that licensees may convey the
 | 
			
		||||
work under this License, and how to view a copy of this License.  If
 | 
			
		||||
the interface presents a list of user commands or options, such as a
 | 
			
		||||
menu, a prominent item in the list meets this criterion.
 | 
			
		||||
 | 
			
		||||
  1. Source Code.
 | 
			
		||||
 | 
			
		||||
  The "source code" for a work means the preferred form of the work
 | 
			
		||||
for making modifications to it.  "Object code" means any non-source
 | 
			
		||||
form of a work.
 | 
			
		||||
 | 
			
		||||
  A "Standard Interface" means an interface that either is an official
 | 
			
		||||
standard defined by a recognized standards body, or, in the case of
 | 
			
		||||
interfaces specified for a particular programming language, one that
 | 
			
		||||
is widely used among developers working in that language.
 | 
			
		||||
 | 
			
		||||
  The "System Libraries" of an executable work include anything, other
 | 
			
		||||
than the work as a whole, that (a) is included in the normal form of
 | 
			
		||||
packaging a Major Component, but which is not part of that Major
 | 
			
		||||
Component, and (b) serves only to enable use of the work with that
 | 
			
		||||
Major Component, or to implement a Standard Interface for which an
 | 
			
		||||
implementation is available to the public in source code form.  A
 | 
			
		||||
"Major Component", in this context, means a major essential component
 | 
			
		||||
(kernel, window system, and so on) of the specific operating system
 | 
			
		||||
(if any) on which the executable work runs, or a compiler used to
 | 
			
		||||
produce the work, or an object code interpreter used to run it.
 | 
			
		||||
 | 
			
		||||
  The "Corresponding Source" for a work in object code form means all
 | 
			
		||||
the source code needed to generate, install, and (for an executable
 | 
			
		||||
work) run the object code and to modify the work, including scripts to
 | 
			
		||||
control those activities.  However, it does not include the work's
 | 
			
		||||
System Libraries, or general-purpose tools or generally available free
 | 
			
		||||
programs which are used unmodified in performing those activities but
 | 
			
		||||
which are not part of the work.  For example, Corresponding Source
 | 
			
		||||
includes interface definition files associated with source files for
 | 
			
		||||
the work, and the source code for shared libraries and dynamically
 | 
			
		||||
linked subprograms that the work is specifically designed to require,
 | 
			
		||||
such as by intimate data communication or control flow between those
 | 
			
		||||
subprograms and other parts of the work.
 | 
			
		||||
 | 
			
		||||
  The Corresponding Source need not include anything that users
 | 
			
		||||
can regenerate automatically from other parts of the Corresponding
 | 
			
		||||
Source.
 | 
			
		||||
 | 
			
		||||
  The Corresponding Source for a work in source code form is that
 | 
			
		||||
same work.
 | 
			
		||||
 | 
			
		||||
  2. Basic Permissions.
 | 
			
		||||
 | 
			
		||||
  All rights granted under this License are granted for the term of
 | 
			
		||||
copyright on the Program, and are irrevocable provided the stated
 | 
			
		||||
conditions are met.  This License explicitly affirms your unlimited
 | 
			
		||||
permission to run the unmodified Program.  The output from running a
 | 
			
		||||
covered work is covered by this License only if the output, given its
 | 
			
		||||
content, constitutes a covered work.  This License acknowledges your
 | 
			
		||||
rights of fair use or other equivalent, as provided by copyright law.
 | 
			
		||||
 | 
			
		||||
  You may make, run and propagate covered works that you do not
 | 
			
		||||
convey, without conditions so long as your license otherwise remains
 | 
			
		||||
in force.  You may convey covered works to others for the sole purpose
 | 
			
		||||
of having them make modifications exclusively for you, or provide you
 | 
			
		||||
with facilities for running those works, provided that you comply with
 | 
			
		||||
the terms of this License in conveying all material for which you do
 | 
			
		||||
not control copyright.  Those thus making or running the covered works
 | 
			
		||||
for you must do so exclusively on your behalf, under your direction
 | 
			
		||||
and control, on terms that prohibit them from making any copies of
 | 
			
		||||
your copyrighted material outside their relationship with you.
 | 
			
		||||
 | 
			
		||||
  Conveying under any other circumstances is permitted solely under
 | 
			
		||||
the conditions stated below.  Sublicensing is not allowed; section 10
 | 
			
		||||
makes it unnecessary.
 | 
			
		||||
 | 
			
		||||
  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
 | 
			
		||||
 | 
			
		||||
  No covered work shall be deemed part of an effective technological
 | 
			
		||||
measure under any applicable law fulfilling obligations under article
 | 
			
		||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
 | 
			
		||||
similar laws prohibiting or restricting circumvention of such
 | 
			
		||||
measures.
 | 
			
		||||
 | 
			
		||||
  When you convey a covered work, you waive any legal power to forbid
 | 
			
		||||
circumvention of technological measures to the extent such circumvention
 | 
			
		||||
is effected by exercising rights under this License with respect to
 | 
			
		||||
the covered work, and you disclaim any intention to limit operation or
 | 
			
		||||
modification of the work as a means of enforcing, against the work's
 | 
			
		||||
users, your or third parties' legal rights to forbid circumvention of
 | 
			
		||||
technological measures.
 | 
			
		||||
 | 
			
		||||
  4. Conveying Verbatim Copies.
 | 
			
		||||
 | 
			
		||||
  You may convey verbatim copies of the Program's source code as you
 | 
			
		||||
receive it, in any medium, provided that you conspicuously and
 | 
			
		||||
appropriately publish on each copy an appropriate copyright notice;
 | 
			
		||||
keep intact all notices stating that this License and any
 | 
			
		||||
non-permissive terms added in accord with section 7 apply to the code;
 | 
			
		||||
keep intact all notices of the absence of any warranty; and give all
 | 
			
		||||
recipients a copy of this License along with the Program.
 | 
			
		||||
 | 
			
		||||
  You may charge any price or no price for each copy that you convey,
 | 
			
		||||
and you may offer support or warranty protection for a fee.
 | 
			
		||||
 | 
			
		||||
  5. Conveying Modified Source Versions.
 | 
			
		||||
 | 
			
		||||
  You may convey a work based on the Program, or the modifications to
 | 
			
		||||
produce it from the Program, in the form of source code under the
 | 
			
		||||
terms of section 4, provided that you also meet all of these conditions:
 | 
			
		||||
 | 
			
		||||
    a) The work must carry prominent notices stating that you modified
 | 
			
		||||
    it, and giving a relevant date.
 | 
			
		||||
 | 
			
		||||
    b) The work must carry prominent notices stating that it is
 | 
			
		||||
    released under this License and any conditions added under section
 | 
			
		||||
    7.  This requirement modifies the requirement in section 4 to
 | 
			
		||||
    "keep intact all notices".
 | 
			
		||||
 | 
			
		||||
    c) You must license the entire work, as a whole, under this
 | 
			
		||||
    License to anyone who comes into possession of a copy.  This
 | 
			
		||||
    License will therefore apply, along with any applicable section 7
 | 
			
		||||
    additional terms, to the whole of the work, and all its parts,
 | 
			
		||||
    regardless of how they are packaged.  This License gives no
 | 
			
		||||
    permission to license the work in any other way, but it does not
 | 
			
		||||
    invalidate such permission if you have separately received it.
 | 
			
		||||
 | 
			
		||||
    d) If the work has interactive user interfaces, each must display
 | 
			
		||||
    Appropriate Legal Notices; however, if the Program has interactive
 | 
			
		||||
    interfaces that do not display Appropriate Legal Notices, your
 | 
			
		||||
    work need not make them do so.
 | 
			
		||||
 | 
			
		||||
  A compilation of a covered work with other separate and independent
 | 
			
		||||
works, which are not by their nature extensions of the covered work,
 | 
			
		||||
and which are not combined with it such as to form a larger program,
 | 
			
		||||
in or on a volume of a storage or distribution medium, is called an
 | 
			
		||||
"aggregate" if the compilation and its resulting copyright are not
 | 
			
		||||
used to limit the access or legal rights of the compilation's users
 | 
			
		||||
beyond what the individual works permit.  Inclusion of a covered work
 | 
			
		||||
in an aggregate does not cause this License to apply to the other
 | 
			
		||||
parts of the aggregate.
 | 
			
		||||
 | 
			
		||||
  6. Conveying Non-Source Forms.
 | 
			
		||||
 | 
			
		||||
  You may convey a covered work in object code form under the terms
 | 
			
		||||
of sections 4 and 5, provided that you also convey the
 | 
			
		||||
machine-readable Corresponding Source under the terms of this License,
 | 
			
		||||
in one of these ways:
 | 
			
		||||
 | 
			
		||||
    a) Convey the object code in, or embodied in, a physical product
 | 
			
		||||
    (including a physical distribution medium), accompanied by the
 | 
			
		||||
    Corresponding Source fixed on a durable physical medium
 | 
			
		||||
    customarily used for software interchange.
 | 
			
		||||
 | 
			
		||||
    b) Convey the object code in, or embodied in, a physical product
 | 
			
		||||
    (including a physical distribution medium), accompanied by a
 | 
			
		||||
    written offer, valid for at least three years and valid for as
 | 
			
		||||
    long as you offer spare parts or customer support for that product
 | 
			
		||||
    model, to give anyone who possesses the object code either (1) a
 | 
			
		||||
    copy of the Corresponding Source for all the software in the
 | 
			
		||||
    product that is covered by this License, on a durable physical
 | 
			
		||||
    medium customarily used for software interchange, for a price no
 | 
			
		||||
    more than your reasonable cost of physically performing this
 | 
			
		||||
    conveying of source, or (2) access to copy the
 | 
			
		||||
    Corresponding Source from a network server at no charge.
 | 
			
		||||
 | 
			
		||||
    c) Convey individual copies of the object code with a copy of the
 | 
			
		||||
    written offer to provide the Corresponding Source.  This
 | 
			
		||||
    alternative is allowed only occasionally and noncommercially, and
 | 
			
		||||
    only if you received the object code with such an offer, in accord
 | 
			
		||||
    with subsection 6b.
 | 
			
		||||
 | 
			
		||||
    d) Convey the object code by offering access from a designated
 | 
			
		||||
    place (gratis or for a charge), and offer equivalent access to the
 | 
			
		||||
    Corresponding Source in the same way through the same place at no
 | 
			
		||||
    further charge.  You need not require recipients to copy the
 | 
			
		||||
    Corresponding Source along with the object code.  If the place to
 | 
			
		||||
    copy the object code is a network server, the Corresponding Source
 | 
			
		||||
    may be on a different server (operated by you or a third party)
 | 
			
		||||
    that supports equivalent copying facilities, provided you maintain
 | 
			
		||||
    clear directions next to the object code saying where to find the
 | 
			
		||||
    Corresponding Source.  Regardless of what server hosts the
 | 
			
		||||
    Corresponding Source, you remain obligated to ensure that it is
 | 
			
		||||
    available for as long as needed to satisfy these requirements.
 | 
			
		||||
 | 
			
		||||
    e) Convey the object code using peer-to-peer transmission, provided
 | 
			
		||||
    you inform other peers where the object code and Corresponding
 | 
			
		||||
    Source of the work are being offered to the general public at no
 | 
			
		||||
    charge under subsection 6d.
 | 
			
		||||
 | 
			
		||||
  A separable portion of the object code, whose source code is excluded
 | 
			
		||||
from the Corresponding Source as a System Library, need not be
 | 
			
		||||
included in conveying the object code work.
 | 
			
		||||
 | 
			
		||||
  A "User Product" is either (1) a "consumer product", which means any
 | 
			
		||||
tangible personal property which is normally used for personal, family,
 | 
			
		||||
or household purposes, or (2) anything designed or sold for incorporation
 | 
			
		||||
into a dwelling.  In determining whether a product is a consumer product,
 | 
			
		||||
doubtful cases shall be resolved in favor of coverage.  For a particular
 | 
			
		||||
product received by a particular user, "normally used" refers to a
 | 
			
		||||
typical or common use of that class of product, regardless of the status
 | 
			
		||||
of the particular user or of the way in which the particular user
 | 
			
		||||
actually uses, or expects or is expected to use, the product.  A product
 | 
			
		||||
is a consumer product regardless of whether the product has substantial
 | 
			
		||||
commercial, industrial or non-consumer uses, unless such uses represent
 | 
			
		||||
the only significant mode of use of the product.
 | 
			
		||||
 | 
			
		||||
  "Installation Information" for a User Product means any methods,
 | 
			
		||||
procedures, authorization keys, or other information required to install
 | 
			
		||||
and execute modified versions of a covered work in that User Product from
 | 
			
		||||
a modified version of its Corresponding Source.  The information must
 | 
			
		||||
suffice to ensure that the continued functioning of the modified object
 | 
			
		||||
code is in no case prevented or interfered with solely because
 | 
			
		||||
modification has been made.
 | 
			
		||||
 | 
			
		||||
  If you convey an object code work under this section in, or with, or
 | 
			
		||||
specifically for use in, a User Product, and the conveying occurs as
 | 
			
		||||
part of a transaction in which the right of possession and use of the
 | 
			
		||||
User Product is transferred to the recipient in perpetuity or for a
 | 
			
		||||
fixed term (regardless of how the transaction is characterized), the
 | 
			
		||||
Corresponding Source conveyed under this section must be accompanied
 | 
			
		||||
by the Installation Information.  But this requirement does not apply
 | 
			
		||||
if neither you nor any third party retains the ability to install
 | 
			
		||||
modified object code on the User Product (for example, the work has
 | 
			
		||||
been installed in ROM).
 | 
			
		||||
 | 
			
		||||
  The requirement to provide Installation Information does not include a
 | 
			
		||||
requirement to continue to provide support service, warranty, or updates
 | 
			
		||||
for a work that has been modified or installed by the recipient, or for
 | 
			
		||||
the User Product in which it has been modified or installed.  Access to a
 | 
			
		||||
network may be denied when the modification itself materially and
 | 
			
		||||
adversely affects the operation of the network or violates the rules and
 | 
			
		||||
protocols for communication across the network.
 | 
			
		||||
 | 
			
		||||
  Corresponding Source conveyed, and Installation Information provided,
 | 
			
		||||
in accord with this section must be in a format that is publicly
 | 
			
		||||
documented (and with an implementation available to the public in
 | 
			
		||||
source code form), and must require no special password or key for
 | 
			
		||||
unpacking, reading or copying.
 | 
			
		||||
 | 
			
		||||
  7. Additional Terms.
 | 
			
		||||
 | 
			
		||||
  "Additional permissions" are terms that supplement the terms of this
 | 
			
		||||
License by making exceptions from one or more of its conditions.
 | 
			
		||||
Additional permissions that are applicable to the entire Program shall
 | 
			
		||||
be treated as though they were included in this License, to the extent
 | 
			
		||||
that they are valid under applicable law.  If additional permissions
 | 
			
		||||
apply only to part of the Program, that part may be used separately
 | 
			
		||||
under those permissions, but the entire Program remains governed by
 | 
			
		||||
this License without regard to the additional permissions.
 | 
			
		||||
 | 
			
		||||
  When you convey a copy of a covered work, you may at your option
 | 
			
		||||
remove any additional permissions from that copy, or from any part of
 | 
			
		||||
it.  (Additional permissions may be written to require their own
 | 
			
		||||
removal in certain cases when you modify the work.)  You may place
 | 
			
		||||
additional permissions on material, added by you to a covered work,
 | 
			
		||||
for which you have or can give appropriate copyright permission.
 | 
			
		||||
 | 
			
		||||
  Notwithstanding any other provision of this License, for material you
 | 
			
		||||
add to a covered work, you may (if authorized by the copyright holders of
 | 
			
		||||
that material) supplement the terms of this License with terms:
 | 
			
		||||
 | 
			
		||||
    a) Disclaiming warranty or limiting liability differently from the
 | 
			
		||||
    terms of sections 15 and 16 of this License; or
 | 
			
		||||
 | 
			
		||||
    b) Requiring preservation of specified reasonable legal notices or
 | 
			
		||||
    author attributions in that material or in the Appropriate Legal
 | 
			
		||||
    Notices displayed by works containing it; or
 | 
			
		||||
 | 
			
		||||
    c) Prohibiting misrepresentation of the origin of that material, or
 | 
			
		||||
    requiring that modified versions of such material be marked in
 | 
			
		||||
    reasonable ways as different from the original version; or
 | 
			
		||||
 | 
			
		||||
    d) Limiting the use for publicity purposes of names of licensors or
 | 
			
		||||
    authors of the material; or
 | 
			
		||||
 | 
			
		||||
    e) Declining to grant rights under trademark law for use of some
 | 
			
		||||
    trade names, trademarks, or service marks; or
 | 
			
		||||
 | 
			
		||||
    f) Requiring indemnification of licensors and authors of that
 | 
			
		||||
    material by anyone who conveys the material (or modified versions of
 | 
			
		||||
    it) with contractual assumptions of liability to the recipient, for
 | 
			
		||||
    any liability that these contractual assumptions directly impose on
 | 
			
		||||
    those licensors and authors.
 | 
			
		||||
 | 
			
		||||
  All other non-permissive additional terms are considered "further
 | 
			
		||||
restrictions" within the meaning of section 10.  If the Program as you
 | 
			
		||||
received it, or any part of it, contains a notice stating that it is
 | 
			
		||||
governed by this License along with a term that is a further
 | 
			
		||||
restriction, you may remove that term.  If a license document contains
 | 
			
		||||
a further restriction but permits relicensing or conveying under this
 | 
			
		||||
License, you may add to a covered work material governed by the terms
 | 
			
		||||
of that license document, provided that the further restriction does
 | 
			
		||||
not survive such relicensing or conveying.
 | 
			
		||||
 | 
			
		||||
  If you add terms to a covered work in accord with this section, you
 | 
			
		||||
must place, in the relevant source files, a statement of the
 | 
			
		||||
additional terms that apply to those files, or a notice indicating
 | 
			
		||||
where to find the applicable terms.
 | 
			
		||||
 | 
			
		||||
  Additional terms, permissive or non-permissive, may be stated in the
 | 
			
		||||
form of a separately written license, or stated as exceptions;
 | 
			
		||||
the above requirements apply either way.
 | 
			
		||||
 | 
			
		||||
  8. Termination.
 | 
			
		||||
 | 
			
		||||
  You may not propagate or modify a covered work except as expressly
 | 
			
		||||
provided under this License.  Any attempt otherwise to propagate or
 | 
			
		||||
modify it is void, and will automatically terminate your rights under
 | 
			
		||||
this License (including any patent licenses granted under the third
 | 
			
		||||
paragraph of section 11).
 | 
			
		||||
 | 
			
		||||
  However, if you cease all violation of this License, then your
 | 
			
		||||
license from a particular copyright holder is reinstated (a)
 | 
			
		||||
provisionally, unless and until the copyright holder explicitly and
 | 
			
		||||
finally terminates your license, and (b) permanently, if the copyright
 | 
			
		||||
holder fails to notify you of the violation by some reasonable means
 | 
			
		||||
prior to 60 days after the cessation.
 | 
			
		||||
 | 
			
		||||
  Moreover, your license from a particular copyright holder is
 | 
			
		||||
reinstated permanently if the copyright holder notifies you of the
 | 
			
		||||
violation by some reasonable means, this is the first time you have
 | 
			
		||||
received notice of violation of this License (for any work) from that
 | 
			
		||||
copyright holder, and you cure the violation prior to 30 days after
 | 
			
		||||
your receipt of the notice.
 | 
			
		||||
 | 
			
		||||
  Termination of your rights under this section does not terminate the
 | 
			
		||||
licenses of parties who have received copies or rights from you under
 | 
			
		||||
this License.  If your rights have been terminated and not permanently
 | 
			
		||||
reinstated, you do not qualify to receive new licenses for the same
 | 
			
		||||
material under section 10.
 | 
			
		||||
 | 
			
		||||
  9. Acceptance Not Required for Having Copies.
 | 
			
		||||
 | 
			
		||||
  You are not required to accept this License in order to receive or
 | 
			
		||||
run a copy of the Program.  Ancillary propagation of a covered work
 | 
			
		||||
occurring solely as a consequence of using peer-to-peer transmission
 | 
			
		||||
to receive a copy likewise does not require acceptance.  However,
 | 
			
		||||
nothing other than this License grants you permission to propagate or
 | 
			
		||||
modify any covered work.  These actions infringe copyright if you do
 | 
			
		||||
not accept this License.  Therefore, by modifying or propagating a
 | 
			
		||||
covered work, you indicate your acceptance of this License to do so.
 | 
			
		||||
 | 
			
		||||
  10. Automatic Licensing of Downstream Recipients.
 | 
			
		||||
 | 
			
		||||
  Each time you convey a covered work, the recipient automatically
 | 
			
		||||
receives a license from the original licensors, to run, modify and
 | 
			
		||||
propagate that work, subject to this License.  You are not responsible
 | 
			
		||||
for enforcing compliance by third parties with this License.
 | 
			
		||||
 | 
			
		||||
  An "entity transaction" is a transaction transferring control of an
 | 
			
		||||
organization, or substantially all assets of one, or subdividing an
 | 
			
		||||
organization, or merging organizations.  If propagation of a covered
 | 
			
		||||
work results from an entity transaction, each party to that
 | 
			
		||||
transaction who receives a copy of the work also receives whatever
 | 
			
		||||
licenses to the work the party's predecessor in interest had or could
 | 
			
		||||
give under the previous paragraph, plus a right to possession of the
 | 
			
		||||
Corresponding Source of the work from the predecessor in interest, if
 | 
			
		||||
the predecessor has it or can get it with reasonable efforts.
 | 
			
		||||
 | 
			
		||||
  You may not impose any further restrictions on the exercise of the
 | 
			
		||||
rights granted or affirmed under this License.  For example, you may
 | 
			
		||||
not impose a license fee, royalty, or other charge for exercise of
 | 
			
		||||
rights granted under this License, and you may not initiate litigation
 | 
			
		||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
 | 
			
		||||
any patent claim is infringed by making, using, selling, offering for
 | 
			
		||||
sale, or importing the Program or any portion of it.
 | 
			
		||||
 | 
			
		||||
  11. Patents.
 | 
			
		||||
 | 
			
		||||
  A "contributor" is a copyright holder who authorizes use under this
 | 
			
		||||
License of the Program or a work on which the Program is based.  The
 | 
			
		||||
work thus licensed is called the contributor's "contributor version".
 | 
			
		||||
 | 
			
		||||
  A contributor's "essential patent claims" are all patent claims
 | 
			
		||||
owned or controlled by the contributor, whether already acquired or
 | 
			
		||||
hereafter acquired, that would be infringed by some manner, permitted
 | 
			
		||||
by this License, of making, using, or selling its contributor version,
 | 
			
		||||
but do not include claims that would be infringed only as a
 | 
			
		||||
consequence of further modification of the contributor version.  For
 | 
			
		||||
purposes of this definition, "control" includes the right to grant
 | 
			
		||||
patent sublicenses in a manner consistent with the requirements of
 | 
			
		||||
this License.
 | 
			
		||||
 | 
			
		||||
  Each contributor grants you a non-exclusive, worldwide, royalty-free
 | 
			
		||||
patent license under the contributor's essential patent claims, to
 | 
			
		||||
make, use, sell, offer for sale, import and otherwise run, modify and
 | 
			
		||||
propagate the contents of its contributor version.
 | 
			
		||||
 | 
			
		||||
  In the following three paragraphs, a "patent license" is any express
 | 
			
		||||
agreement or commitment, however denominated, not to enforce a patent
 | 
			
		||||
(such as an express permission to practice a patent or covenant not to
 | 
			
		||||
sue for patent infringement).  To "grant" such a patent license to a
 | 
			
		||||
party means to make such an agreement or commitment not to enforce a
 | 
			
		||||
patent against the party.
 | 
			
		||||
 | 
			
		||||
  If you convey a covered work, knowingly relying on a patent license,
 | 
			
		||||
and the Corresponding Source of the work is not available for anyone
 | 
			
		||||
to copy, free of charge and under the terms of this License, through a
 | 
			
		||||
publicly available network server or other readily accessible means,
 | 
			
		||||
then you must either (1) cause the Corresponding Source to be so
 | 
			
		||||
available, or (2) arrange to deprive yourself of the benefit of the
 | 
			
		||||
patent license for this particular work, or (3) arrange, in a manner
 | 
			
		||||
consistent with the requirements of this License, to extend the patent
 | 
			
		||||
license to downstream recipients.  "Knowingly relying" means you have
 | 
			
		||||
actual knowledge that, but for the patent license, your conveying the
 | 
			
		||||
covered work in a country, or your recipient's use of the covered work
 | 
			
		||||
in a country, would infringe one or more identifiable patents in that
 | 
			
		||||
country that you have reason to believe are valid.
 | 
			
		||||
 | 
			
		||||
  If, pursuant to or in connection with a single transaction or
 | 
			
		||||
arrangement, you convey, or propagate by procuring conveyance of, a
 | 
			
		||||
covered work, and grant a patent license to some of the parties
 | 
			
		||||
receiving the covered work authorizing them to use, propagate, modify
 | 
			
		||||
or convey a specific copy of the covered work, then the patent license
 | 
			
		||||
you grant is automatically extended to all recipients of the covered
 | 
			
		||||
work and works based on it.
 | 
			
		||||
 | 
			
		||||
  A patent license is "discriminatory" if it does not include within
 | 
			
		||||
the scope of its coverage, prohibits the exercise of, or is
 | 
			
		||||
conditioned on the non-exercise of one or more of the rights that are
 | 
			
		||||
specifically granted under this License.  You may not convey a covered
 | 
			
		||||
work if you are a party to an arrangement with a third party that is
 | 
			
		||||
in the business of distributing software, under which you make payment
 | 
			
		||||
to the third party based on the extent of your activity of conveying
 | 
			
		||||
the work, and under which the third party grants, to any of the
 | 
			
		||||
parties who would receive the covered work from you, a discriminatory
 | 
			
		||||
patent license (a) in connection with copies of the covered work
 | 
			
		||||
conveyed by you (or copies made from those copies), or (b) primarily
 | 
			
		||||
for and in connection with specific products or compilations that
 | 
			
		||||
contain the covered work, unless you entered into that arrangement,
 | 
			
		||||
or that patent license was granted, prior to 28 March 2007.
 | 
			
		||||
 | 
			
		||||
  Nothing in this License shall be construed as excluding or limiting
 | 
			
		||||
any implied license or other defenses to infringement that may
 | 
			
		||||
otherwise be available to you under applicable patent law.
 | 
			
		||||
 | 
			
		||||
  12. No Surrender of Others' Freedom.
 | 
			
		||||
 | 
			
		||||
  If conditions are imposed on you (whether by court order, agreement or
 | 
			
		||||
otherwise) that contradict the conditions of this License, they do not
 | 
			
		||||
excuse you from the conditions of this License.  If you cannot convey a
 | 
			
		||||
covered work so as to satisfy simultaneously your obligations under this
 | 
			
		||||
License and any other pertinent obligations, then as a consequence you may
 | 
			
		||||
not convey it at all.  For example, if you agree to terms that obligate you
 | 
			
		||||
to collect a royalty for further conveying from those to whom you convey
 | 
			
		||||
the Program, the only way you could satisfy both those terms and this
 | 
			
		||||
License would be to refrain entirely from conveying the Program.
 | 
			
		||||
 | 
			
		||||
  13. Use with the GNU Affero General Public License.
 | 
			
		||||
 | 
			
		||||
  Notwithstanding any other provision of this License, you have
 | 
			
		||||
permission to link or combine any covered work with a work licensed
 | 
			
		||||
under version 3 of the GNU Affero General Public License into a single
 | 
			
		||||
combined work, and to convey the resulting work.  The terms of this
 | 
			
		||||
License will continue to apply to the part which is the covered work,
 | 
			
		||||
but the special requirements of the GNU Affero General Public License,
 | 
			
		||||
section 13, concerning interaction through a network will apply to the
 | 
			
		||||
combination as such.
 | 
			
		||||
 | 
			
		||||
  14. Revised Versions of this License.
 | 
			
		||||
 | 
			
		||||
  The Free Software Foundation may publish revised and/or new versions of
 | 
			
		||||
the GNU General Public License from time to time.  Such new versions will
 | 
			
		||||
be similar in spirit to the present version, but may differ in detail to
 | 
			
		||||
address new problems or concerns.
 | 
			
		||||
 | 
			
		||||
  Each version is given a distinguishing version number.  If the
 | 
			
		||||
Program specifies that a certain numbered version of the GNU General
 | 
			
		||||
Public License "or any later version" applies to it, you have the
 | 
			
		||||
option of following the terms and conditions either of that numbered
 | 
			
		||||
version or of any later version published by the Free Software
 | 
			
		||||
Foundation.  If the Program does not specify a version number of the
 | 
			
		||||
GNU General Public License, you may choose any version ever published
 | 
			
		||||
by the Free Software Foundation.
 | 
			
		||||
 | 
			
		||||
  If the Program specifies that a proxy can decide which future
 | 
			
		||||
versions of the GNU General Public License can be used, that proxy's
 | 
			
		||||
public statement of acceptance of a version permanently authorizes you
 | 
			
		||||
to choose that version for the Program.
 | 
			
		||||
 | 
			
		||||
  Later license versions may give you additional or different
 | 
			
		||||
permissions.  However, no additional obligations are imposed on any
 | 
			
		||||
author or copyright holder as a result of your choosing to follow a
 | 
			
		||||
later version.
 | 
			
		||||
 | 
			
		||||
  15. Disclaimer of Warranty.
 | 
			
		||||
 | 
			
		||||
  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
 | 
			
		||||
APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
 | 
			
		||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
 | 
			
		||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
 | 
			
		||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
 | 
			
		||||
PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
 | 
			
		||||
IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
 | 
			
		||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
 | 
			
		||||
 | 
			
		||||
  16. Limitation of Liability.
 | 
			
		||||
 | 
			
		||||
  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
 | 
			
		||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
 | 
			
		||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
 | 
			
		||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
 | 
			
		||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
 | 
			
		||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
 | 
			
		||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
 | 
			
		||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
 | 
			
		||||
SUCH DAMAGES.
 | 
			
		||||
 | 
			
		||||
  17. Interpretation of Sections 15 and 16.
 | 
			
		||||
 | 
			
		||||
  If the disclaimer of warranty and limitation of liability provided
 | 
			
		||||
above cannot be given local legal effect according to their terms,
 | 
			
		||||
reviewing courts shall apply local law that most closely approximates
 | 
			
		||||
an absolute waiver of all civil liability in connection with the
 | 
			
		||||
Program, unless a warranty or assumption of liability accompanies a
 | 
			
		||||
copy of the Program in return for a fee.
 | 
			
		||||
 | 
			
		||||
                     END OF TERMS AND CONDITIONS
 | 
			
		||||
 | 
			
		||||
            How to Apply These Terms to Your New Programs
 | 
			
		||||
 | 
			
		||||
  If you develop a new program, and you want it to be of the greatest
 | 
			
		||||
possible use to the public, the best way to achieve this is to make it
 | 
			
		||||
free software which everyone can redistribute and change under these terms.
 | 
			
		||||
 | 
			
		||||
  To do so, attach the following notices to the program.  It is safest
 | 
			
		||||
to attach them to the start of each source file to most effectively
 | 
			
		||||
state the exclusion of warranty; and each file should have at least
 | 
			
		||||
the "copyright" line and a pointer to where the full notice is found.
 | 
			
		||||
 | 
			
		||||
    <one line to give the program's name and a brief idea of what it does.>
 | 
			
		||||
    Copyright (C) <year>  <name of author>
 | 
			
		||||
 | 
			
		||||
    This program is free software: you can redistribute it and/or modify
 | 
			
		||||
    it under the terms of the GNU General Public License as published by
 | 
			
		||||
    the Free Software Foundation, either version 3 of the License, or
 | 
			
		||||
    (at your option) any later version.
 | 
			
		||||
 | 
			
		||||
    This program is distributed in the hope that it will be useful,
 | 
			
		||||
    but WITHOUT ANY WARRANTY; without even the implied warranty of
 | 
			
		||||
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 | 
			
		||||
    GNU General Public License for more details.
 | 
			
		||||
 | 
			
		||||
    You should have received a copy of the GNU General Public License
 | 
			
		||||
    along with this program.  If not, see <https://www.gnu.org/licenses/>.
 | 
			
		||||
 | 
			
		||||
Also add information on how to contact you by electronic and paper mail.
 | 
			
		||||
 | 
			
		||||
  If the program does terminal interaction, make it output a short
 | 
			
		||||
notice like this when it starts in an interactive mode:
 | 
			
		||||
 | 
			
		||||
    <program>  Copyright (C) <year>  <name of author>
 | 
			
		||||
    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
 | 
			
		||||
    This is free software, and you are welcome to redistribute it
 | 
			
		||||
    under certain conditions; type `show c' for details.
 | 
			
		||||
 | 
			
		||||
The hypothetical commands `show w' and `show c' should show the appropriate
 | 
			
		||||
parts of the General Public License.  Of course, your program's commands
 | 
			
		||||
might be different; for a GUI interface, you would use an "about box".
 | 
			
		||||
 | 
			
		||||
  You should also get your employer (if you work as a programmer) or school,
 | 
			
		||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
 | 
			
		||||
For more information on this, and how to apply and follow the GNU GPL, see
 | 
			
		||||
<https://www.gnu.org/licenses/>.
 | 
			
		||||
 | 
			
		||||
  The GNU General Public License does not permit incorporating your program
 | 
			
		||||
into proprietary programs.  If your program is a subroutine library, you
 | 
			
		||||
may consider it more useful to permit linking proprietary applications with
 | 
			
		||||
the library.  If this is what you want to do, use the GNU Lesser General
 | 
			
		||||
Public License instead of this License.  But first, please read
 | 
			
		||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
 | 
			
		||||
							
								
								
									
										0
									
								
								apps/cic-ussd/cic_ussd/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										39
									
								
								apps/cic-ussd/cic_ussd/accounts.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,39 @@
 | 
			
		||||
# standard imports
 | 
			
		||||
import logging
 | 
			
		||||
from collections import deque
 | 
			
		||||
 | 
			
		||||
# third-party imports
 | 
			
		||||
from cic_eth.api import Api
 | 
			
		||||
 | 
			
		||||
# local imports
 | 
			
		||||
from cic_ussd.transactions import from_wei
 | 
			
		||||
 | 
			
		||||
logg = logging.getLogger()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BalanceManager:
 | 
			
		||||
 | 
			
		||||
    def __init__(self, address: str, chain_str: str, token_symbol: str):
 | 
			
		||||
        """
 | 
			
		||||
        :param address: Ethereum address of account whose balance is being queried
 | 
			
		||||
        :type address: str, 0x-hex
 | 
			
		||||
        :param chain_str: The chain name and network id.
 | 
			
		||||
        :type chain_str: str
 | 
			
		||||
        :param token_symbol: ERC20 token symbol of whose balance is being queried
 | 
			
		||||
        :type token_symbol: str
 | 
			
		||||
        """
 | 
			
		||||
        self.address = address
 | 
			
		||||
        self.chain_str = chain_str
 | 
			
		||||
        self.token_symbol = token_symbol
 | 
			
		||||
 | 
			
		||||
    def get_operational_balance(self) -> float:
 | 
			
		||||
        """This question queries cic-eth for an account's balance
 | 
			
		||||
        :return: The current balance of the account as reflected on the blockchain.
 | 
			
		||||
        :rtype: int
 | 
			
		||||
        """
 | 
			
		||||
        cic_eth_api = Api(chain_str=self.chain_str, callback_task=None)
 | 
			
		||||
        balance_request_task = cic_eth_api.balance(address=self.address, token_symbol=self.token_symbol)
 | 
			
		||||
        balance_request_task_results = balance_request_task.collect()
 | 
			
		||||
        balance_result = deque(balance_request_task_results, maxlen=1).pop()
 | 
			
		||||
        balance = from_wei(value=balance_result[-1])
 | 
			
		||||
        return balance
 | 
			
		||||
							
								
								
									
										36
									
								
								apps/cic-ussd/cic_ussd/db/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,36 @@
 | 
			
		||||
# standard imports
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
# third party imports
 | 
			
		||||
from confini import Config
 | 
			
		||||
 | 
			
		||||
logg = logging.getLogger()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def dsn_from_config(config):
 | 
			
		||||
    """
 | 
			
		||||
    This function builds a data source name mapping to a database from values defined in the config object.
 | 
			
		||||
    :param config: A config object.
 | 
			
		||||
    :type config: Config
 | 
			
		||||
    :return: A database URI.
 | 
			
		||||
    :rtype: str
 | 
			
		||||
    """
 | 
			
		||||
    scheme = config.get('DATABASE_ENGINE')
 | 
			
		||||
    if config.get('DATABASE_DRIVER') is not None:
 | 
			
		||||
        scheme += '+{}'.format(config.get('DATABASE_DRIVER'))
 | 
			
		||||
 | 
			
		||||
    dsn = ''
 | 
			
		||||
    if config.get('DATABASE_ENGINE') == 'sqlite':
 | 
			
		||||
        dsn = f'{scheme}:///{config.get("DATABASE_NAME")}'
 | 
			
		||||
 | 
			
		||||
    else:
 | 
			
		||||
        dsn = '{}://{}:{}@{}:{}/{}'.format(
 | 
			
		||||
            scheme,
 | 
			
		||||
            config.get('DATABASE_USER'),
 | 
			
		||||
            config.get('DATABASE_PASSWORD'),
 | 
			
		||||
            config.get('DATABASE_HOST'),
 | 
			
		||||
            config.get('DATABASE_PORT'),
 | 
			
		||||
            config.get('DATABASE_NAME'),
 | 
			
		||||
        )
 | 
			
		||||
    logg.debug('parsed dsn from config: {}'.format(dsn))
 | 
			
		||||
    return dsn
 | 
			
		||||
							
								
								
									
										1
									
								
								apps/cic-ussd/cic_ussd/db/migrations/default/README
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1 @@
 | 
			
		||||
Generic single-database configuration.
 | 
			
		||||
							
								
								
									
										74
									
								
								apps/cic-ussd/cic_ussd/db/migrations/default/alembic.ini
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,74 @@
 | 
			
		||||
# A generic, single database configuration.
 | 
			
		||||
 | 
			
		||||
[alembic]
 | 
			
		||||
# path to migration scripts
 | 
			
		||||
script_location = .
 | 
			
		||||
 | 
			
		||||
# template used to generate migration files
 | 
			
		||||
# file_template = %%(rev)s_%%(slug)s
 | 
			
		||||
 | 
			
		||||
# timezone to use when rendering the date
 | 
			
		||||
# within the migration file as well as the filename.
 | 
			
		||||
# string value is passed to dateutil.tz.gettz()
 | 
			
		||||
# leave blank for localtime
 | 
			
		||||
# timezone =
 | 
			
		||||
 | 
			
		||||
# max length of characters to apply to the
 | 
			
		||||
# "slug" field
 | 
			
		||||
# truncate_slug_length = 40
 | 
			
		||||
 | 
			
		||||
# set to 'true' to run the environment during
 | 
			
		||||
# the 'revision' command, regardless of autogenerate
 | 
			
		||||
# revision_environment = false
 | 
			
		||||
 | 
			
		||||
# set to 'true' to allow .pyc and .pyo files without
 | 
			
		||||
# a source .py file to be detected as revisions in the
 | 
			
		||||
# versions/ directory
 | 
			
		||||
# sourceless = false
 | 
			
		||||
 | 
			
		||||
# version location specification; this defaults
 | 
			
		||||
# to ./versions.  When using multiple version
 | 
			
		||||
# directories, initial revisions must be specified with --version-path
 | 
			
		||||
# version_locations = %(here)s/bar %(here)s/bat ./versions
 | 
			
		||||
 | 
			
		||||
# the output encoding used when revision files
 | 
			
		||||
# are written from script.py.mako
 | 
			
		||||
# output_encoding = utf-8
 | 
			
		||||
 | 
			
		||||
sqlalchemy.url = driver://user:pass@localhost/dbname
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# Logging configuration
 | 
			
		||||
[loggers]
 | 
			
		||||
keys = root,sqlalchemy,alembic
 | 
			
		||||
 | 
			
		||||
[handlers]
 | 
			
		||||
keys = console
 | 
			
		||||
 | 
			
		||||
[formatters]
 | 
			
		||||
keys = generic
 | 
			
		||||
 | 
			
		||||
[logger_root]
 | 
			
		||||
level = WARN
 | 
			
		||||
handlers = console
 | 
			
		||||
qualname =
 | 
			
		||||
 | 
			
		||||
[logger_sqlalchemy]
 | 
			
		||||
level = WARN
 | 
			
		||||
handlers =
 | 
			
		||||
qualname = sqlalchemy.engine
 | 
			
		||||
 | 
			
		||||
[logger_alembic]
 | 
			
		||||
level = INFO
 | 
			
		||||
handlers =
 | 
			
		||||
qualname = alembic
 | 
			
		||||
 | 
			
		||||
[handler_console]
 | 
			
		||||
class = StreamHandler
 | 
			
		||||
args = (sys.stderr,)
 | 
			
		||||
level = NOTSET
 | 
			
		||||
formatter = generic
 | 
			
		||||
 | 
			
		||||
[formatter_generic]
 | 
			
		||||
format = %(levelname)-5.5s [%(name)s] %(message)s
 | 
			
		||||
datefmt = %H:%M:%S
 | 
			
		||||
							
								
								
									
										80
									
								
								apps/cic-ussd/cic_ussd/db/migrations/default/env.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,80 @@
 | 
			
		||||
from logging.config import fileConfig
 | 
			
		||||
 | 
			
		||||
from sqlalchemy import engine_from_config
 | 
			
		||||
from sqlalchemy import pool
 | 
			
		||||
 | 
			
		||||
from alembic import context
 | 
			
		||||
 | 
			
		||||
# this is the Alembic Config object, which provides
 | 
			
		||||
# access to the values within the .ini file in use.
 | 
			
		||||
config = context.config
 | 
			
		||||
 | 
			
		||||
# Interpret the config file for Python logging.
 | 
			
		||||
# This line sets up loggers basically.
 | 
			
		||||
fileConfig(config.config_file_name, disable_existing_loggers=True)
 | 
			
		||||
 | 
			
		||||
# add your model's MetaData object here
 | 
			
		||||
# for 'autogenerate' support
 | 
			
		||||
# from myapp import mymodel
 | 
			
		||||
# target_metadata = mymodel.Base.metadata
 | 
			
		||||
target_metadata = None
 | 
			
		||||
 | 
			
		||||
# other values from the config, defined by the needs of env.py,
 | 
			
		||||
# can be acquired:
 | 
			
		||||
# my_important_option = config.get_main_option("my_important_option")
 | 
			
		||||
# ... etc.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def run_migrations_offline():
 | 
			
		||||
    """Run migrations in 'offline' mode.
 | 
			
		||||
 | 
			
		||||
    This configures the context with just a URL
 | 
			
		||||
    and not an Engine, though an Engine is acceptable
 | 
			
		||||
    here as well.  By skipping the Engine creation
 | 
			
		||||
    we don't even need a DBAPI to be available.
 | 
			
		||||
 | 
			
		||||
    Calls to context.execute() here emit the given string to the
 | 
			
		||||
    script output.
 | 
			
		||||
 | 
			
		||||
    """
 | 
			
		||||
    url = config.get_main_option("sqlalchemy.url")
 | 
			
		||||
    context.configure(
 | 
			
		||||
        url=url,
 | 
			
		||||
        target_metadata=target_metadata,
 | 
			
		||||
        literal_binds=True,
 | 
			
		||||
        dialect_opts={"paramstyle": "named"},
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    with context.begin_transaction():
 | 
			
		||||
        context.run_migrations()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def run_migrations_online():
 | 
			
		||||
    """Run migrations in 'online' mode.
 | 
			
		||||
 | 
			
		||||
    In this scenario we need to create an Engine
 | 
			
		||||
    and associate a connection with the context.
 | 
			
		||||
 | 
			
		||||
    """
 | 
			
		||||
    connectable = context.config.attributes.get("connection", None)
 | 
			
		||||
 | 
			
		||||
    if connectable is None:
 | 
			
		||||
        connectable = engine_from_config(
 | 
			
		||||
            context.config.get_section(context.config.config_ini_section),
 | 
			
		||||
            prefix="sqlalchemy.",
 | 
			
		||||
            poolclass=pool.NullPool,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    with connectable.connect() as connection:
 | 
			
		||||
        context.configure(
 | 
			
		||||
            connection=connection, target_metadata=target_metadata
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        with context.begin_transaction():
 | 
			
		||||
            context.run_migrations()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
if context.is_offline_mode():
 | 
			
		||||
    run_migrations_offline()
 | 
			
		||||
else:
 | 
			
		||||
    run_migrations_online()
 | 
			
		||||
							
								
								
									
										24
									
								
								apps/cic-ussd/cic_ussd/db/migrations/default/script.py.mako
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,24 @@
 | 
			
		||||
"""${message}
 | 
			
		||||
 | 
			
		||||
Revision ID: ${up_revision}
 | 
			
		||||
Revises: ${down_revision | comma,n}
 | 
			
		||||
Create Date: ${create_date}
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from alembic import op
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
${imports if imports else ""}
 | 
			
		||||
 | 
			
		||||
# revision identifiers, used by Alembic.
 | 
			
		||||
revision = ${repr(up_revision)}
 | 
			
		||||
down_revision = ${repr(down_revision)}
 | 
			
		||||
branch_labels = ${repr(branch_labels)}
 | 
			
		||||
depends_on = ${repr(depends_on)}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def upgrade():
 | 
			
		||||
    ${upgrades if upgrades else "pass"}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def downgrade():
 | 
			
		||||
    ${downgrades if downgrades else "pass"}
 | 
			
		||||
@ -0,0 +1,38 @@
 | 
			
		||||
"""Create ussd session table
 | 
			
		||||
 | 
			
		||||
Revision ID: 2a329190a9af
 | 
			
		||||
Revises: b5ab9371c0b8
 | 
			
		||||
Create Date: 2020-10-06 00:06:54.354168
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from alembic import op
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
from sqlalchemy.dialects import postgresql
 | 
			
		||||
 | 
			
		||||
# revision identifiers, used by Alembic.
 | 
			
		||||
revision = '2a329190a9af'
 | 
			
		||||
down_revision = 'f289e8510444'
 | 
			
		||||
branch_labels = None
 | 
			
		||||
depends_on = None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def upgrade():
 | 
			
		||||
    op.create_table('ussd_session',
 | 
			
		||||
                    sa.Column('id', sa.Integer(), nullable=False),
 | 
			
		||||
                    sa.Column('created', sa.DateTime(), nullable=True),
 | 
			
		||||
                    sa.Column('updated', sa.DateTime(), nullable=True),
 | 
			
		||||
                    sa.Column('external_session_id', sa.String(), nullable=False),
 | 
			
		||||
                    sa.Column('service_code', sa.String(), nullable=False),
 | 
			
		||||
                    sa.Column('msisdn', sa.String(), nullable=False),
 | 
			
		||||
                    sa.Column('user_input', sa.String(), nullable=True),
 | 
			
		||||
                    sa.Column('state', sa.String(), nullable=False),
 | 
			
		||||
                    sa.Column('session_data', postgresql.JSON(astext_type=sa.Text()), nullable=True),
 | 
			
		||||
                    sa.Column('version', sa.Integer(), nullable=False),
 | 
			
		||||
                    sa.PrimaryKeyConstraint('id')
 | 
			
		||||
                    )
 | 
			
		||||
    op.create_index(op.f('ix_ussd_session_external_session_id'), 'ussd_session', ['external_session_id'], unique=True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def downgrade():
 | 
			
		||||
    op.drop_index(op.f('ix_ussd_session_external_session_id'), table_name='ussd_session')
 | 
			
		||||
    op.drop_table('ussd_session')
 | 
			
		||||
@ -0,0 +1,30 @@
 | 
			
		||||
"""Create task tracker table
 | 
			
		||||
 | 
			
		||||
Revision ID: a571d0aee6f8
 | 
			
		||||
Revises: 2a329190a9af
 | 
			
		||||
Create Date: 2021-01-04 18:28:00.462228
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from alembic import op
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# revision identifiers, used by Alembic.
 | 
			
		||||
revision = 'a571d0aee6f8'
 | 
			
		||||
down_revision = '2a329190a9af'
 | 
			
		||||
branch_labels = None
 | 
			
		||||
depends_on = None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def upgrade():
 | 
			
		||||
    op.create_table('task_tracker',
 | 
			
		||||
                    sa.Column('id', sa.Integer(), nullable=False),
 | 
			
		||||
                    sa.Column('created', sa.DateTime(), nullable=True),
 | 
			
		||||
                    sa.Column('updated', sa.DateTime(), nullable=True),
 | 
			
		||||
                    sa.Column('task_uuid', sa.String(), nullable=False),
 | 
			
		||||
                    sa.PrimaryKeyConstraint('id')
 | 
			
		||||
                    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def downgrade():
 | 
			
		||||
    op.drop_table('task_tracker')
 | 
			
		||||
@ -0,0 +1,39 @@
 | 
			
		||||
"""Create user table
 | 
			
		||||
 | 
			
		||||
Revision ID: f289e8510444
 | 
			
		||||
Revises: 
 | 
			
		||||
Create Date: 2020-07-14 21:37:13.014200
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from alembic import op
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# revision identifiers, used by Alembic.
 | 
			
		||||
revision = 'f289e8510444'
 | 
			
		||||
down_revision = None
 | 
			
		||||
branch_labels = None
 | 
			
		||||
depends_on = None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def upgrade():
 | 
			
		||||
    op.create_table('user',
 | 
			
		||||
                    sa.Column('id', sa.Integer(), nullable=False),
 | 
			
		||||
                    sa.Column('blockchain_address', sa.String(), nullable=False),
 | 
			
		||||
                    sa.Column('phone_number', sa.String(), nullable=False),
 | 
			
		||||
                    sa.Column('preferred_language', sa.String(), nullable=True),
 | 
			
		||||
                    sa.Column('password_hash', sa.String(), nullable=True),
 | 
			
		||||
                    sa.Column('failed_pin_attempts', sa.Integer(), nullable=False),
 | 
			
		||||
                    sa.Column('account_status', sa.Integer(), nullable=False),
 | 
			
		||||
                    sa.Column('created', sa.DateTime(), nullable=False),
 | 
			
		||||
                    sa.Column('updated', sa.DateTime(), nullable=False),
 | 
			
		||||
                    sa.PrimaryKeyConstraint('id')
 | 
			
		||||
                    )
 | 
			
		||||
    op.create_index(op.f('ix_user_phone_number'), 'user', ['phone_number'], unique=True)
 | 
			
		||||
    op.create_index(op.f('ix_user_blockchain_address'), 'user', ['blockchain_address'], unique=True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def downgrade():
 | 
			
		||||
    op.drop_index(op.f('ix_user_blockchain_address'), table_name='user')
 | 
			
		||||
    op.drop_index(op.f('ix_user_phone_number'), table_name='user')
 | 
			
		||||
    op.drop_table('user')
 | 
			
		||||
							
								
								
									
										47
									
								
								apps/cic-ussd/cic_ussd/db/models/base.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,47 @@
 | 
			
		||||
# standard imports
 | 
			
		||||
import datetime
 | 
			
		||||
 | 
			
		||||
# third-party imports
 | 
			
		||||
from sqlalchemy import Column, Integer, DateTime
 | 
			
		||||
from sqlalchemy.ext.declarative import declarative_base
 | 
			
		||||
from sqlalchemy import create_engine
 | 
			
		||||
from sqlalchemy.orm import sessionmaker
 | 
			
		||||
 | 
			
		||||
Model = declarative_base(name='Model')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SessionBase(Model):
 | 
			
		||||
    __abstract__ = True
 | 
			
		||||
 | 
			
		||||
    id = Column(Integer, primary_key=True)
 | 
			
		||||
    created = Column(DateTime, default=datetime.datetime.utcnow)
 | 
			
		||||
    updated = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
 | 
			
		||||
 | 
			
		||||
    engine = None
 | 
			
		||||
    session = None
 | 
			
		||||
    query = None
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def create_session():
 | 
			
		||||
        session = sessionmaker(bind=SessionBase.engine)
 | 
			
		||||
        return session()
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def _set_engine(engine):
 | 
			
		||||
        SessionBase.engine = engine
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def build():
 | 
			
		||||
        Model.metadata.create_all(bind=SessionBase.engine)
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    # https://docs.sqlalchemy.org/en/13/core/pooling.html#pool-disconnects
 | 
			
		||||
    def connect(data_source_name):
 | 
			
		||||
        engine = create_engine(data_source_name, pool_pre_ping=True)
 | 
			
		||||
        SessionBase._set_engine(engine)
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def disconnect():
 | 
			
		||||
        SessionBase.engine.dispose()
 | 
			
		||||
        SessionBase.engine = None
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										19
									
								
								apps/cic-ussd/cic_ussd/db/models/task_tracker.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,19 @@
 | 
			
		||||
# standard imports
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
# third-party imports
 | 
			
		||||
from sqlalchemy import Column, String
 | 
			
		||||
 | 
			
		||||
# local imports
 | 
			
		||||
from cic_ussd.db.models.base import SessionBase
 | 
			
		||||
 | 
			
		||||
logg = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TaskTracker(SessionBase):
 | 
			
		||||
    __tablename__ = 'task_tracker'
 | 
			
		||||
 | 
			
		||||
    def __init__(self, task_uuid):
 | 
			
		||||
        self.task_uuid = task_uuid
 | 
			
		||||
 | 
			
		||||
    task_uuid = Column(String, nullable=False)
 | 
			
		||||
							
								
								
									
										90
									
								
								apps/cic-ussd/cic_ussd/db/models/user.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,90 @@
 | 
			
		||||
# standard imports
 | 
			
		||||
from enum import IntEnum
 | 
			
		||||
 | 
			
		||||
# third party imports
 | 
			
		||||
from sqlalchemy import Column, Integer, String
 | 
			
		||||
 | 
			
		||||
# local imports
 | 
			
		||||
from cic_ussd.db.models.base import SessionBase
 | 
			
		||||
from cic_ussd.encoder import check_password_hash, create_password_hash
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AccountStatus(IntEnum):
 | 
			
		||||
    PENDING = 1
 | 
			
		||||
    ACTIVE = 2
 | 
			
		||||
    LOCKED = 3
 | 
			
		||||
    RESET = 4
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class User(SessionBase):
 | 
			
		||||
    """
 | 
			
		||||
    This class defines a user record along with functions responsible for hashing the user's corresponding password and
 | 
			
		||||
     subsequently verifying a password's validity given an input to compare against the persisted hash.
 | 
			
		||||
    """
 | 
			
		||||
    __tablename__ = 'user'
 | 
			
		||||
 | 
			
		||||
    blockchain_address = Column(String)
 | 
			
		||||
    phone_number = Column(String)
 | 
			
		||||
    password_hash = Column(String)
 | 
			
		||||
    failed_pin_attempts = Column(Integer)
 | 
			
		||||
    account_status = Column(Integer)
 | 
			
		||||
    preferred_language = Column(String)
 | 
			
		||||
 | 
			
		||||
    def __init__(self, blockchain_address, phone_number):
 | 
			
		||||
        self.blockchain_address = blockchain_address
 | 
			
		||||
        self.phone_number = phone_number
 | 
			
		||||
        self.password_hash = None
 | 
			
		||||
        self.failed_pin_attempts = 0
 | 
			
		||||
        self.account_status = AccountStatus.PENDING.value
 | 
			
		||||
 | 
			
		||||
    def __repr__(self):
 | 
			
		||||
        return f'<User: {self.blockchain_address}>'
 | 
			
		||||
 | 
			
		||||
    def create_password(self, password):
 | 
			
		||||
        """This method takes a password value and hashes the value before assigning it to the corresponding
 | 
			
		||||
        `hashed_password` attribute in the user record.
 | 
			
		||||
        :param password: A password value
 | 
			
		||||
        :type password: str
 | 
			
		||||
        """
 | 
			
		||||
        self.password_hash = create_password_hash(password)
 | 
			
		||||
 | 
			
		||||
    def verify_password(self, password):
 | 
			
		||||
        """This method takes a password value and compares it to the user's corresponding `hashed_password` value to
 | 
			
		||||
        establish password validity.
 | 
			
		||||
        :param password: A password value
 | 
			
		||||
        :type password: str
 | 
			
		||||
        :return: Pin validity
 | 
			
		||||
        :rtype: boolean
 | 
			
		||||
        """
 | 
			
		||||
        return check_password_hash(password, self.password_hash)
 | 
			
		||||
 | 
			
		||||
    def reset_account_pin(self):
 | 
			
		||||
        """This method is used to unlock a user's account."""
 | 
			
		||||
        self.failed_pin_attempts = 0
 | 
			
		||||
        self.account_status = AccountStatus.RESET.value
 | 
			
		||||
 | 
			
		||||
    def get_account_status(self):
 | 
			
		||||
        """This method checks whether the account is past the allowed number of failed pin attempts.
 | 
			
		||||
        If so, it changes the accounts status to Locked.
 | 
			
		||||
        :return: The account status for a user object
 | 
			
		||||
        :rtype: str
 | 
			
		||||
        """
 | 
			
		||||
        if self.failed_pin_attempts > 2:
 | 
			
		||||
            self.account_status = AccountStatus.LOCKED.value
 | 
			
		||||
        return AccountStatus(self.account_status).name
 | 
			
		||||
 | 
			
		||||
    def activate_account(self):
 | 
			
		||||
        """This method is used to reset failed pin attempts and change account status to Active."""
 | 
			
		||||
        self.failed_pin_attempts = 0
 | 
			
		||||
        self.account_status = AccountStatus.ACTIVE.value
 | 
			
		||||
 | 
			
		||||
    def has_valid_pin(self):
 | 
			
		||||
        """This method checks whether the user's account status and if a pin hash is present which implies
 | 
			
		||||
        pin validity.
 | 
			
		||||
        :return: The presence of a valid pin and status of the account being active.
 | 
			
		||||
        :rtype: bool
 | 
			
		||||
        """
 | 
			
		||||
        valid_pin = None
 | 
			
		||||
        if self.get_account_status() == 'ACTIVE' and self.password_hash is not None:
 | 
			
		||||
            valid_pin = True
 | 
			
		||||
        return valid_pin
 | 
			
		||||
							
								
								
									
										71
									
								
								apps/cic-ussd/cic_ussd/db/models/ussd_session.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,71 @@
 | 
			
		||||
# standard imports
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
# third-party imports
 | 
			
		||||
from sqlalchemy import Column, String, Integer
 | 
			
		||||
from sqlalchemy.dialects.postgresql import JSON
 | 
			
		||||
from sqlalchemy.orm.attributes import flag_modified
 | 
			
		||||
 | 
			
		||||
# local imports
 | 
			
		||||
from cic_ussd.db.models.base import SessionBase
 | 
			
		||||
from cic_ussd.error import VersionTooLowError
 | 
			
		||||
 | 
			
		||||
logg = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UssdSession(SessionBase):
 | 
			
		||||
    __tablename__ = 'ussd_session'
 | 
			
		||||
 | 
			
		||||
    external_session_id = Column(String, nullable=False, index=True, unique=True)
 | 
			
		||||
    service_code = Column(String, nullable=False)
 | 
			
		||||
    msisdn = Column(String, nullable=False)
 | 
			
		||||
    user_input = Column(String)
 | 
			
		||||
    state = Column(String, nullable=False)
 | 
			
		||||
    session_data = Column(JSON)
 | 
			
		||||
    version = Column(Integer, nullable=False)
 | 
			
		||||
 | 
			
		||||
    def set_data(self, key, session, value):
 | 
			
		||||
        if self.session_data is None:
 | 
			
		||||
            self.session_data = {}
 | 
			
		||||
        self.session_data[key] = value
 | 
			
		||||
 | 
			
		||||
        # https://stackoverflow.com/questions/42559434/updates-to-json-field-dont-persist-to-db
 | 
			
		||||
        flag_modified(self, "session_data")
 | 
			
		||||
        session.add(self)
 | 
			
		||||
 | 
			
		||||
    def get_data(self, key):
 | 
			
		||||
        if self.session_data is not None:
 | 
			
		||||
            return self.session_data.get(key)
 | 
			
		||||
        else:
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
    def check_version(self, new_version):
 | 
			
		||||
        if new_version <= self.version:
 | 
			
		||||
            raise VersionTooLowError('New session version number is not greater than last saved version!')
 | 
			
		||||
 | 
			
		||||
    def update(self, user_input, state, version, session):
 | 
			
		||||
        self.check_version(version)
 | 
			
		||||
        self.user_input = user_input
 | 
			
		||||
        self.state = state
 | 
			
		||||
        self.version = version
 | 
			
		||||
        session.add(self)
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def have_session_for_phone(phone):
 | 
			
		||||
        r = UssdSession.session.query(UssdSession).filter_by(msisdn=phone).first()
 | 
			
		||||
        return r is not None
 | 
			
		||||
 | 
			
		||||
    def to_json(self):
 | 
			
		||||
        """ This function serializes the in db ussd session object to a JSON object
 | 
			
		||||
        :return: A JSON object of a ussd session in db
 | 
			
		||||
        :rtype: dict
 | 
			
		||||
        """
 | 
			
		||||
        return {
 | 
			
		||||
            "external_session_id": self.external_session_id,
 | 
			
		||||
            "service_code": self.service_code,
 | 
			
		||||
            "msisdn": self.msisdn,
 | 
			
		||||
            "user_input": self.user_input,
 | 
			
		||||
            "state": self.state,
 | 
			
		||||
            "session_data": self.session_data,
 | 
			
		||||
            "version": self.version
 | 
			
		||||
        }
 | 
			
		||||
							
								
								
									
										214
									
								
								apps/cic-ussd/cic_ussd/db/ussd_menu.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,214 @@
 | 
			
		||||
{
 | 
			
		||||
    "ussd_menu": {
 | 
			
		||||
        "1": {
 | 
			
		||||
            "description": "The self signup process has been initiated and the account is being created",
 | 
			
		||||
            "display_key": "ussd.kenya.account_creation_prompt",
 | 
			
		||||
            "name": "account_creation_prompt",
 | 
			
		||||
            "parent": null
 | 
			
		||||
        },
 | 
			
		||||
        "2": {
 | 
			
		||||
            "description": "Start menu. This is the entry point for users to select their preferred language",
 | 
			
		||||
            "display_key": "ussd.kenya.initial_language_selection",
 | 
			
		||||
            "name": "initial_language_selection",
 | 
			
		||||
            "parent": null
 | 
			
		||||
        },
 | 
			
		||||
        "3": {
 | 
			
		||||
            "description": "PIN setup entry menu",
 | 
			
		||||
            "display_key": "ussd.kenya.initial_pin_entry",
 | 
			
		||||
            "name": "initial_pin_entry",
 | 
			
		||||
            "parent": "initial_language_selection"
 | 
			
		||||
        },
 | 
			
		||||
        "4": {
 | 
			
		||||
            "description": "Confirm new PIN menu",
 | 
			
		||||
            "display_key": "ussd.kenya.initial_pin_confirmation",
 | 
			
		||||
            "name": "initial_pin_confirmation",
 | 
			
		||||
            "parent": "initial_pin_entry"
 | 
			
		||||
        },
 | 
			
		||||
        "5": {
 | 
			
		||||
            "description": "Start menu. This is the entry point for activated users",
 | 
			
		||||
            "display_key": "ussd.kenya.start",
 | 
			
		||||
            "name": "start",
 | 
			
		||||
            "parent": null
 | 
			
		||||
        },
 | 
			
		||||
        "6": {
 | 
			
		||||
            "description": "Send Token recipient entry",
 | 
			
		||||
            "display_key": "ussd.kenya.enter_transaction_recipient",
 | 
			
		||||
            "name": "enter_transaction_recipient",
 | 
			
		||||
            "parent": "start"
 | 
			
		||||
        },
 | 
			
		||||
        "7": {
 | 
			
		||||
            "description": "Send Token amount prompt menu",
 | 
			
		||||
            "display_key": "ussd.kenya.enter_transaction_amount",
 | 
			
		||||
            "name": "enter_transaction_amount",
 | 
			
		||||
            "parent": "start"
 | 
			
		||||
        },
 | 
			
		||||
        "8": {
 | 
			
		||||
            "description": "PIN entry for authorization to send token",
 | 
			
		||||
            "display_key": "ussd.kenya.transaction_pin_authorization",
 | 
			
		||||
            "name": "transaction_pin_authorization",
 | 
			
		||||
            "parent": "start"
 | 
			
		||||
        },
 | 
			
		||||
        "9": {
 | 
			
		||||
            "description": "Terminal of a menu flow where an SMS is expected after.",
 | 
			
		||||
            "display_key": "ussd.kenya.complete",
 | 
			
		||||
            "name": "complete",
 | 
			
		||||
            "parent": null
 | 
			
		||||
        },
 | 
			
		||||
        "10": {
 | 
			
		||||
            "description": "Help menu",
 | 
			
		||||
            "display_key": "ussd.kenya.help",
 | 
			
		||||
            "name": "help",
 | 
			
		||||
            "parent": "start"
 | 
			
		||||
        },
 | 
			
		||||
        "11": {
 | 
			
		||||
            "description": "Manage account menu",
 | 
			
		||||
            "display_key": "ussd.kenya.profile_management",
 | 
			
		||||
            "name": "profile_management",
 | 
			
		||||
            "parent": "start"
 | 
			
		||||
        },
 | 
			
		||||
        "12": {
 | 
			
		||||
            "description": "Manage business directory info",
 | 
			
		||||
            "display_key": "ussd.kenya.select_preferred_language",
 | 
			
		||||
            "name": "select_preferred_language",
 | 
			
		||||
            "parent": "account_management"
 | 
			
		||||
        },
 | 
			
		||||
        "13": {
 | 
			
		||||
            "description": "About business directory info",
 | 
			
		||||
            "display_key": "ussd.kenya.mini_statement_pin_authorization",
 | 
			
		||||
            "name": "mini_statement_pin_authorization",
 | 
			
		||||
            "parent": "account_management"
 | 
			
		||||
        },
 | 
			
		||||
        "14": {
 | 
			
		||||
            "description": "Change business directory info",
 | 
			
		||||
            "display_key": "ussd.kenya.enter_current_pin",
 | 
			
		||||
            "name": "enter_current_pin",
 | 
			
		||||
            "parent": "account_management"
 | 
			
		||||
        },
 | 
			
		||||
        "15": {
 | 
			
		||||
            "description": "New PIN entry menu",
 | 
			
		||||
            "display_key": "ussd.kenya.enter_new_pin",
 | 
			
		||||
            "name": "enter_new_pin",
 | 
			
		||||
            "parent": "account_management"
 | 
			
		||||
        },
 | 
			
		||||
        "16": {
 | 
			
		||||
            "description": "First name entry menu",
 | 
			
		||||
            "display_key": "ussd.kenya.enter_first_name",
 | 
			
		||||
            "name": "enter_first_name",
 | 
			
		||||
            "parent": "profile_management"
 | 
			
		||||
        },
 | 
			
		||||
        "17": {
 | 
			
		||||
            "description": "Last name entry menu",
 | 
			
		||||
            "display_key": "ussd.kenya.enter_last_name",
 | 
			
		||||
            "name": "enter_last_name",
 | 
			
		||||
            "parent": "profile_management"
 | 
			
		||||
        },
 | 
			
		||||
        "18": {
 | 
			
		||||
            "description": "Gender entry menu",
 | 
			
		||||
            "display_key": "ussd.kenya.enter_gender",
 | 
			
		||||
            "name": "enter_gender",
 | 
			
		||||
            "parent": "profile_management"
 | 
			
		||||
        },
 | 
			
		||||
        "19": {
 | 
			
		||||
            "description": "Location entry menu",
 | 
			
		||||
            "display_key": "ussd.kenya.enter_location",
 | 
			
		||||
            "name": "enter_location",
 | 
			
		||||
            "parent": "profile_management"
 | 
			
		||||
        },
 | 
			
		||||
        "20": {
 | 
			
		||||
            "description": "Business profile entry menu",
 | 
			
		||||
            "display_key": "ussd.kenya.enter_business_profile",
 | 
			
		||||
            "name": "enter_business_profile",
 | 
			
		||||
            "parent": "profile_management"
 | 
			
		||||
        },
 | 
			
		||||
        "21": {
 | 
			
		||||
            "description": "Menu to display a user's entire profile",
 | 
			
		||||
            "display_key": "ussd.kenya.display_user_profile_data",
 | 
			
		||||
            "name": "display_user_profile_data",
 | 
			
		||||
            "parent": "profile_management"
 | 
			
		||||
        },
 | 
			
		||||
        "22": {
 | 
			
		||||
            "description": "Pin authorization to change name",
 | 
			
		||||
            "display_key": "ussd.kenya.name_management_pin_authorization",
 | 
			
		||||
            "name": "name_management_pin_authorization",
 | 
			
		||||
            "parent": "profile_management"
 | 
			
		||||
        },
 | 
			
		||||
        "23": {
 | 
			
		||||
            "description": "Pin authorization to change gender",
 | 
			
		||||
            "display_key": "ussd.kenya.gender_management_pin_authorization",
 | 
			
		||||
            "name": "gender_management_pin_authorization",
 | 
			
		||||
            "parent": "profile_management"
 | 
			
		||||
        },
 | 
			
		||||
        "24": {
 | 
			
		||||
            "description": "Pin authorization to change location",
 | 
			
		||||
            "display_key": "ussd.kenya.location_management_pin_authorization",
 | 
			
		||||
            "name": "location_management_pin_authorization",
 | 
			
		||||
            "parent": "profile_management"
 | 
			
		||||
        },
 | 
			
		||||
        "26": {
 | 
			
		||||
            "description": "Pin authorization to display user's profile",
 | 
			
		||||
            "display_key": "ussd.kenya.view_profile_pin_authorization",
 | 
			
		||||
            "name": "view_profile_pin_authorization",
 | 
			
		||||
            "parent": "profile_management"
 | 
			
		||||
        },
 | 
			
		||||
        "27": {
 | 
			
		||||
            "description": "Exit menu",
 | 
			
		||||
            "display_key": "ussd.kenya.exit",
 | 
			
		||||
            "name": "exit",
 | 
			
		||||
            "parent": null
 | 
			
		||||
        },
 | 
			
		||||
        "28": {
 | 
			
		||||
            "description": "Invalid menu option",
 | 
			
		||||
            "display_key": "ussd.kenya.exit_invalid_menu_option",
 | 
			
		||||
            "name": "exit_invalid_menu_option",
 | 
			
		||||
            "parent": null
 | 
			
		||||
        },
 | 
			
		||||
        "29": {
 | 
			
		||||
            "description": "PIN policy violation",
 | 
			
		||||
            "display_key": "ussd.kenya.exit_invalid_pin",
 | 
			
		||||
            "name": "exit_invalid_pin",
 | 
			
		||||
            "parent": null
 | 
			
		||||
        },
 | 
			
		||||
        "30": {
 | 
			
		||||
            "description": "PIN mismatch. New PIN and the new PIN confirmation do not match",
 | 
			
		||||
            "display_key": "ussd.kenya.exit_pin_mismatch",
 | 
			
		||||
            "name": "exit_pin_mismatch",
 | 
			
		||||
            "parent": null
 | 
			
		||||
        },
 | 
			
		||||
        "31": {
 | 
			
		||||
            "description": "Ussd PIN Blocked Menu",
 | 
			
		||||
            "display_key": "ussd.kenya.exit_pin_blocked",
 | 
			
		||||
            "name": "exit_pin_blocked",
 | 
			
		||||
            "parent": null
 | 
			
		||||
        },
 | 
			
		||||
        "32": {
 | 
			
		||||
            "description": "Key params missing in request",
 | 
			
		||||
            "display_key": "ussd.kenya.exit_invalid_request",
 | 
			
		||||
            "name": "exit_invalid_request",
 | 
			
		||||
            "parent": null
 | 
			
		||||
        },
 | 
			
		||||
        "33": {
 | 
			
		||||
            "description": "The user did not select a choice",
 | 
			
		||||
            "display_key": "ussd.kenya.exit_invalid_input",
 | 
			
		||||
            "name": "exit_invalid_input",
 | 
			
		||||
            "parent": null
 | 
			
		||||
        },
 | 
			
		||||
        "34": {
 | 
			
		||||
            "description": "Exit following a successful transaction.",
 | 
			
		||||
            "display_key": "ussd.kenya.exit_successful_transaction",
 | 
			
		||||
            "name": "exit_successful_transaction",
 | 
			
		||||
            "parent": null
 | 
			
		||||
        },
 | 
			
		||||
        "35": {
 | 
			
		||||
            "description": "Manage account menu",
 | 
			
		||||
            "display_key": "ussd.kenya.account_management",
 | 
			
		||||
            "name": "account_management",
 | 
			
		||||
            "parent": "start"
 | 
			
		||||
        },
 | 
			
		||||
        "36": {
 | 
			
		||||
            "description": "Exit following insufficient balance to perform a transaction.",
 | 
			
		||||
            "display_key": "ussd.kenya.exit_insufficient_balance",
 | 
			
		||||
            "name": "exit_insufficient_balance",
 | 
			
		||||
            "parent": null
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										69
									
								
								apps/cic-ussd/cic_ussd/encoder.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,69 @@
 | 
			
		||||
# third party imports
 | 
			
		||||
import bcrypt
 | 
			
		||||
from cryptography.fernet import Fernet
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PasswordEncoder(Fernet):
 | 
			
		||||
    """This class is responsible for defining the encryption function for password encoding in the application and the
 | 
			
		||||
    provision of a class method that can be used to define the static key attribute at the application's entry point.
 | 
			
		||||
 | 
			
		||||
    :cvar key: a URL-safe base64-encoded 32-byte
 | 
			
		||||
    :type key: bytes
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    key = None
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def set_key(cls, key: bytes):
 | 
			
		||||
        """This method sets the value of the static key attribute to make it accessible to all subsequent instances of
 | 
			
		||||
        the class once defined.
 | 
			
		||||
        :param key: key: a URL-safe base64-encoded 32-byte
 | 
			
		||||
        :type key: bytes
 | 
			
		||||
        """
 | 
			
		||||
        cls.key = key
 | 
			
		||||
 | 
			
		||||
    def encrypt(self, ciphertext: bytes):
 | 
			
		||||
        """This overloads the encrypt function of the Fernet class
 | 
			
		||||
        :param ciphertext: The data to be encrypted.
 | 
			
		||||
        :type ciphertext: bytes
 | 
			
		||||
        :return: A fernet token (A set of bytes representing the hashed value succeeding the encryption)
 | 
			
		||||
        :rtype: bytes
 | 
			
		||||
        """
 | 
			
		||||
        return super(PasswordEncoder, self).encrypt(ciphertext)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def create_password_hash(password):
 | 
			
		||||
    """This method encrypts a password value using a pre-set pepper and an appended salt. Documentation is brief since
 | 
			
		||||
    symmetric encryption using a unique key (pepper) and salted passwords before hashing is well documented.
 | 
			
		||||
    N/B: Fernet encryption requires the unique key to be a URL-safe base64-encoded 32-byte key.
 | 
			
		||||
    https://cryptography.io/en/latest/fernet/
 | 
			
		||||
    :param password: A password value
 | 
			
		||||
    :type password: str
 | 
			
		||||
 | 
			
		||||
    :raises ValueError: if a key whose length length is less than 32 bytes.
 | 
			
		||||
    :raises binascii.Error: if base64 key is invalid or corrupted.
 | 
			
		||||
 | 
			
		||||
    :return: A fernet token (A set of bytes representing the hashed value succeeding the encryption)
 | 
			
		||||
    :rtype: str
 | 
			
		||||
    """
 | 
			
		||||
    fernet = PasswordEncoder(PasswordEncoder.key)
 | 
			
		||||
    return fernet.encrypt(bcrypt.hashpw(password.encode(), bcrypt.gensalt())).decode()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def check_password_hash(password, hashed_password):
 | 
			
		||||
    """This method ascertains a password's validity by hashing the provided password value using the original pepper and
 | 
			
		||||
    compares the resultant fernet signature to the one persisted in the db for a given user.
 | 
			
		||||
    :param password: A password value
 | 
			
		||||
    :type password: str
 | 
			
		||||
    :param hashed_password: A hash for a user's password value
 | 
			
		||||
    :type hashed_password: str
 | 
			
		||||
 | 
			
		||||
    :raises ValueError: if a key whose length length is less than 32 bytes.
 | 
			
		||||
    :raises binascii.Error: if base64 key is invalid or corrupted.
 | 
			
		||||
 | 
			
		||||
    :return: Password validity
 | 
			
		||||
    :rtype: boolean
 | 
			
		||||
    """
 | 
			
		||||
    fernet = PasswordEncoder(PasswordEncoder.key)
 | 
			
		||||
    hashed_password = fernet.decrypt(hashed_password.encode())
 | 
			
		||||
    return bcrypt.checkpw(password.encode(), hashed_password)
 | 
			
		||||
							
								
								
									
										19
									
								
								apps/cic-ussd/cic_ussd/error.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,19 @@
 | 
			
		||||
class VersionTooLowError(Exception):
 | 
			
		||||
    """Raised when the session version doesn't match latest version."""
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SessionNotFoundError(Exception):
 | 
			
		||||
    """Raised when queried session is not found in memory."""
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class InvalidFileFormatError(OSError):
 | 
			
		||||
    """Raised when the file format is invalid."""
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ActionDataNotFoundError(OSError):
 | 
			
		||||
    """Raised when action data matching a specific task uuid is not found in the redis cache"""
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										0
									
								
								apps/cic-ussd/cic_ussd/files/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										50
									
								
								apps/cic-ussd/cic_ussd/files/local_files.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,50 @@
 | 
			
		||||
# standard imports
 | 
			
		||||
import json
 | 
			
		||||
import logging
 | 
			
		||||
import os
 | 
			
		||||
 | 
			
		||||
# third party imports
 | 
			
		||||
from tinydb import TinyDB
 | 
			
		||||
 | 
			
		||||
logg = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def create_local_file_data_stores(file_location: str, table_name: str):
 | 
			
		||||
    """
 | 
			
		||||
    This methods creates a file where data can be stored in memory.
 | 
			
		||||
    :param file_location: Path to file to create tiny db in-memory data store.
 | 
			
		||||
    :type file_location: str
 | 
			
		||||
    :param table_name: The name of the tiny db table structure to store the data.
 | 
			
		||||
    :type table_name: str
 | 
			
		||||
    :return: A tinyDB table
 | 
			
		||||
    """
 | 
			
		||||
    store = TinyDB(file_location, sort_keys=True, indent=4, separators=(',', ': '))
 | 
			
		||||
    return store.table(table_name, cache_size=30)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def json_file_parser(filepath: str) -> list:
 | 
			
		||||
    """This function takes an entry name for a group of transitions or states, it then reads the
 | 
			
		||||
    successive file and returns a list of the corresponding elements representing a set of transitions or states.
 | 
			
		||||
 | 
			
		||||
    :param filepath: A path to the JSON file containing data.
 | 
			
		||||
    :type filepath: str
 | 
			
		||||
    :return: A list of objects to add to the state machine's transitions.
 | 
			
		||||
    :rtype: list
 | 
			
		||||
    """
 | 
			
		||||
    data = []
 | 
			
		||||
    for json_data_file_path in os.listdir(filepath):
 | 
			
		||||
        # get path of data files
 | 
			
		||||
        data_file_path = os.path.join(filepath, json_data_file_path)
 | 
			
		||||
 | 
			
		||||
        # open data file
 | 
			
		||||
        data_file = open(data_file_path)
 | 
			
		||||
 | 
			
		||||
        # load json data
 | 
			
		||||
        json_data = json.load(data_file)
 | 
			
		||||
        logg.debug(f'Loading data from: {json_data_file_path}')
 | 
			
		||||
 | 
			
		||||
        # get all data in one list
 | 
			
		||||
        data += json_data
 | 
			
		||||
        data_file.close()
 | 
			
		||||
 | 
			
		||||
    return data
 | 
			
		||||
							
								
								
									
										0
									
								
								apps/cic-ussd/cic_ussd/menu/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										104
									
								
								apps/cic-ussd/cic_ussd/menu/ussd_menu.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,104 @@
 | 
			
		||||
# standard imports
 | 
			
		||||
import logging
 | 
			
		||||
from typing import Optional
 | 
			
		||||
 | 
			
		||||
# third party imports
 | 
			
		||||
from tinydb import Query
 | 
			
		||||
from tinydb.table import Document, Table
 | 
			
		||||
 | 
			
		||||
# define logger.
 | 
			
		||||
logg = logging.getLogger()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UssdMenu:
 | 
			
		||||
    """
 | 
			
		||||
    This class defines the USSD menu object that is called whenever a user makes transitions in the menu.
 | 
			
		||||
    :cvar ussd_menu_db: The tinydb database object.
 | 
			
		||||
    :type ussd_menu_db: Table
 | 
			
		||||
    """
 | 
			
		||||
    ussd_menu_db = None
 | 
			
		||||
    Menu = Query()
 | 
			
		||||
 | 
			
		||||
    def __init__(self,
 | 
			
		||||
                 name: str,
 | 
			
		||||
                 description: str,
 | 
			
		||||
                 parent: Optional[str],
 | 
			
		||||
                 country: Optional[str] = 'Kenya',
 | 
			
		||||
                 gateway: Optional[str] = 'USSD'):
 | 
			
		||||
        """
 | 
			
		||||
        This function is called whenever a USSD menu object is created and saves the instance to a JSON DB.
 | 
			
		||||
        :param name: The name of the menu and is used as it's unique identifier.
 | 
			
		||||
        :type name: str.
 | 
			
		||||
        :param description: A brief explanation of what the menu does.
 | 
			
		||||
        :type description: str.
 | 
			
		||||
        :param parent: The menu from which the current menu is called. Transitions move from parent to child menus.
 | 
			
		||||
        :type parent: str.
 | 
			
		||||
        :param country: The country from which the menu is created for and being used. Defaults to Kenya.
 | 
			
		||||
        :type country: str
 | 
			
		||||
        :param gateway: The gateway through which the menu is used. Defaults to USSD.
 | 
			
		||||
        :type gateway: str.
 | 
			
		||||
        :raises ValueError: If menu already exists.
 | 
			
		||||
        """
 | 
			
		||||
        self.name = name
 | 
			
		||||
        self.description = description
 | 
			
		||||
        self.parent = parent
 | 
			
		||||
        self.display_key = f'{gateway.lower()}.{country.lower()}.{name}'
 | 
			
		||||
 | 
			
		||||
        menu = self.ussd_menu_db.get(UssdMenu.Menu.name == name)
 | 
			
		||||
        if menu:
 | 
			
		||||
            raise ValueError('Menu already exists!')
 | 
			
		||||
        self.ussd_menu_db.insert({
 | 
			
		||||
            'name': self.name,
 | 
			
		||||
            'description': self.description,
 | 
			
		||||
            'parent': self.parent,
 | 
			
		||||
            'display_key': self.display_key
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def find_by_name(name: str) -> Document:
 | 
			
		||||
        """
 | 
			
		||||
        This function attempts to fetch a menu from the JSON DB using the unique name.
 | 
			
		||||
        :param name: The name of the menu that is being searched for.
 | 
			
		||||
        :type name: str.
 | 
			
		||||
        :return: The function returns the queried menu in JSON format if found,
 | 
			
		||||
        else it returns the menu item for invalid requests.
 | 
			
		||||
        :rtype: Document.
 | 
			
		||||
        """
 | 
			
		||||
        menu = UssdMenu.ussd_menu_db.get(UssdMenu.Menu.name == name)
 | 
			
		||||
        if not menu:
 | 
			
		||||
            logg.error("No USSD Menu with name {}".format(name))
 | 
			
		||||
            return UssdMenu.ussd_menu_db.get(UssdMenu.Menu.name == 'exit_invalid_request')
 | 
			
		||||
        else:
 | 
			
		||||
            return menu
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def set_description(name: str, description: str):
 | 
			
		||||
        """
 | 
			
		||||
        This function updates the description for a specific menu in the JSON DB.
 | 
			
		||||
        :param name: The name of the menu whose description should be updated.
 | 
			
		||||
        :type name: str.
 | 
			
		||||
        :param description: The new menu description. On success it should overwrite the menu's previous description.
 | 
			
		||||
        :type description: str.
 | 
			
		||||
        """
 | 
			
		||||
        menu = UssdMenu.find_by_name(name=name)
 | 
			
		||||
        UssdMenu.ussd_menu_db.update({'description': description}, UssdMenu.Menu.name == menu['name'])
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def parent_menu(menu_name: str) -> Document:
 | 
			
		||||
        """
 | 
			
		||||
        This function fetches the parent menu of the menu instance it has been called on.
 | 
			
		||||
        :param menu_name: The name of the menu whose parent is to be returned.
 | 
			
		||||
        :type menu_name: str
 | 
			
		||||
        :return: This function returns the menu's parent menu in JSON format.
 | 
			
		||||
        :rtype: Document.
 | 
			
		||||
        """
 | 
			
		||||
        ussd_menu = UssdMenu.find_by_name(name=menu_name)
 | 
			
		||||
        return UssdMenu.find_by_name(ussd_menu.get('parent'))
 | 
			
		||||
 | 
			
		||||
    def __repr__(self) -> str:
 | 
			
		||||
        """
 | 
			
		||||
        This method return the object representation of the menu.
 | 
			
		||||
        :return: This function returns a string containing the object representation of the menu.
 | 
			
		||||
        :rtype: str.
 | 
			
		||||
        """
 | 
			
		||||
        return f"<UssdMenu {self.name} - {self.description}>"
 | 
			
		||||
							
								
								
									
										29
									
								
								apps/cic-ussd/cic_ussd/notifications.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,29 @@
 | 
			
		||||
# standard imports
 | 
			
		||||
from typing import Union
 | 
			
		||||
 | 
			
		||||
# third-party imports
 | 
			
		||||
from cic_notify.api import Api
 | 
			
		||||
 | 
			
		||||
# local imports
 | 
			
		||||
from cic_ussd.translation import translation_for
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Notifier:
 | 
			
		||||
 | 
			
		||||
    queue: Union[str, bool, None] = False
 | 
			
		||||
 | 
			
		||||
    def send_sms_notification(self, key: str, phone_number: str, preferred_language: str, **kwargs):
 | 
			
		||||
        """This function creates a task to send a message to a user.
 | 
			
		||||
        :param key: The key mapping to a specific message entry in translation files.
 | 
			
		||||
        :type key: str
 | 
			
		||||
        :param phone_number: The recipient's phone number.
 | 
			
		||||
        :type phone_number: str
 | 
			
		||||
        :param preferred_language: A notification recipient's preferred language.
 | 
			
		||||
        :type preferred_language: str
 | 
			
		||||
        """
 | 
			
		||||
        if self.queue is False:
 | 
			
		||||
            notify_api = Api()
 | 
			
		||||
        else:
 | 
			
		||||
            notify_api = Api(queue=self.queue)
 | 
			
		||||
        message = translation_for(key=key, preferred_language=preferred_language, **kwargs)
 | 
			
		||||
        notify_api.sms(recipient=phone_number, message=message)
 | 
			
		||||
							
								
								
									
										489
									
								
								apps/cic-ussd/cic_ussd/operations.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,489 @@
 | 
			
		||||
# standard imports
 | 
			
		||||
import json
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
# third party imports
 | 
			
		||||
import celery
 | 
			
		||||
import i18n
 | 
			
		||||
import phonenumbers
 | 
			
		||||
from cic_eth.api.api_task import Api
 | 
			
		||||
from tinydb.table import Document
 | 
			
		||||
from typing import Optional
 | 
			
		||||
 | 
			
		||||
# local imports
 | 
			
		||||
from cic_ussd.db.models.user import User
 | 
			
		||||
from cic_ussd.db.models.ussd_session import UssdSession
 | 
			
		||||
from cic_ussd.db.models.task_tracker import TaskTracker
 | 
			
		||||
from cic_ussd.menu.ussd_menu import UssdMenu
 | 
			
		||||
from cic_ussd.processor import custom_display_text, process_request
 | 
			
		||||
from cic_ussd.redis import InMemoryStore
 | 
			
		||||
from cic_ussd.session.ussd_session import UssdSession as InMemoryUssdSession
 | 
			
		||||
from cic_ussd.validator import check_known_user, validate_response_type
 | 
			
		||||
 | 
			
		||||
logg = logging.getLogger()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def add_tasks_to_tracker(task_uuid):
 | 
			
		||||
    """
 | 
			
		||||
    This function takes tasks spawned over api interfaces and records their creation time for tracking.
 | 
			
		||||
    :param task_uuid: The uuid for an initiated task.
 | 
			
		||||
    :type task_uuid: str
 | 
			
		||||
    """
 | 
			
		||||
    task_record = TaskTracker(task_uuid=task_uuid)
 | 
			
		||||
    TaskTracker.session.add(task_record)
 | 
			
		||||
    TaskTracker.session.commit()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def define_response_with_content(headers: list, response: str) -> tuple:
 | 
			
		||||
    """This function encodes responses to byte form in order to make feasible for uwsgi response formats. It then
 | 
			
		||||
    computes the length of the response and appends the content length to the headers.
 | 
			
		||||
    :param headers: A list of tuples defining headers for responses.
 | 
			
		||||
    :type headers: list
 | 
			
		||||
    :param response: The response to send for an incoming http request
 | 
			
		||||
    :type response: str
 | 
			
		||||
    :return: A tuple containing the response bytes and a list of tuples defining headers
 | 
			
		||||
    :rtype: tuple
 | 
			
		||||
    """
 | 
			
		||||
    response_bytes = response.encode('utf-8')
 | 
			
		||||
    content_length = len(response_bytes)
 | 
			
		||||
    content_length_header = ('Content-Length', str(content_length))
 | 
			
		||||
    # check for content length defaulted to zero in error headers
 | 
			
		||||
    for position, header in enumerate(headers):
 | 
			
		||||
        if header[0] == 'Content-Length':
 | 
			
		||||
            headers[position] = content_length_header
 | 
			
		||||
        else:
 | 
			
		||||
            headers.append(content_length_header)
 | 
			
		||||
    return response_bytes, headers
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def create_ussd_session(
 | 
			
		||||
        external_session_id: str,
 | 
			
		||||
        phone: str,
 | 
			
		||||
        service_code: str,
 | 
			
		||||
        user_input: str,
 | 
			
		||||
        current_menu: str) -> InMemoryUssdSession:
 | 
			
		||||
    """
 | 
			
		||||
    Creates a new ussd session
 | 
			
		||||
    :param external_session_id: Session id value provided by AT
 | 
			
		||||
    :type external_session_id: str
 | 
			
		||||
    :param phone: A valid phone number
 | 
			
		||||
    :type phone: str
 | 
			
		||||
    :param service_code: service code passed over request
 | 
			
		||||
    :type service_code AT service code
 | 
			
		||||
    :param user_input: Input from the request
 | 
			
		||||
    :type user_input: str
 | 
			
		||||
    :param current_menu: Menu name that is currently being displayed on the ussd session
 | 
			
		||||
    :type current_menu: str
 | 
			
		||||
    :return: ussd session object
 | 
			
		||||
    :rtype: Session
 | 
			
		||||
    """
 | 
			
		||||
    session = InMemoryUssdSession(
 | 
			
		||||
        external_session_id=external_session_id,
 | 
			
		||||
        msisdn=phone,
 | 
			
		||||
        user_input=user_input,
 | 
			
		||||
        state=current_menu,
 | 
			
		||||
        service_code=service_code
 | 
			
		||||
    )
 | 
			
		||||
    return session
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def create_or_update_session(
 | 
			
		||||
        external_session_id: str,
 | 
			
		||||
        phone: str,
 | 
			
		||||
        service_code: str,
 | 
			
		||||
        user_input: str,
 | 
			
		||||
        current_menu: str,
 | 
			
		||||
        session_data: Optional[dict] = None) -> InMemoryUssdSession:
 | 
			
		||||
    """
 | 
			
		||||
    Handles the creation or updating of session as necessary.
 | 
			
		||||
    :param external_session_id: Session id value provided by AT
 | 
			
		||||
    :type external_session_id: str
 | 
			
		||||
    :param phone: A valid phone number
 | 
			
		||||
    :type phone: str
 | 
			
		||||
    :param service_code: service code passed over request
 | 
			
		||||
    :type service_code: AT service code
 | 
			
		||||
    :param user_input: input from the request
 | 
			
		||||
    :type user_input: str
 | 
			
		||||
    :param current_menu: Menu name that is currently being displayed on the ussd session
 | 
			
		||||
    :type current_menu: str
 | 
			
		||||
    :param session_data: Any additional data that was persisted during the user's interaction with the system.
 | 
			
		||||
    :type session_data: dict.
 | 
			
		||||
    :return: ussd session object
 | 
			
		||||
    :rtype: InMemoryUssdSession
 | 
			
		||||
    """
 | 
			
		||||
    existing_ussd_session = UssdSession.session.query(UssdSession).filter_by(
 | 
			
		||||
        external_session_id=external_session_id).first()
 | 
			
		||||
 | 
			
		||||
    if existing_ussd_session:
 | 
			
		||||
        ussd_session = update_ussd_session(
 | 
			
		||||
            ussd_session=existing_ussd_session,
 | 
			
		||||
            current_menu=current_menu,
 | 
			
		||||
            user_input=user_input,
 | 
			
		||||
            session_data=session_data
 | 
			
		||||
        )
 | 
			
		||||
    else:
 | 
			
		||||
        ussd_session = create_ussd_session(
 | 
			
		||||
            external_session_id=external_session_id,
 | 
			
		||||
            phone=phone,
 | 
			
		||||
            service_code=service_code,
 | 
			
		||||
            user_input=user_input,
 | 
			
		||||
            current_menu=current_menu)
 | 
			
		||||
    return ussd_session
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_account_status(phone_number) -> str:
 | 
			
		||||
    """Get the status of a user's account.
 | 
			
		||||
    :param phone_number: The phone number to be checked.
 | 
			
		||||
    :type phone_number: str
 | 
			
		||||
    :return: The user account status.
 | 
			
		||||
    :rtype: str
 | 
			
		||||
    """
 | 
			
		||||
    user = User.session.query(User).filter_by(phone_number=phone_number).first()
 | 
			
		||||
    status = user.get_account_status()
 | 
			
		||||
    User.session.add(user)
 | 
			
		||||
    User.session.commit()
 | 
			
		||||
 | 
			
		||||
    return status
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_latest_input(user_input: str) -> str:
 | 
			
		||||
    """This function gets the last value entered by the user from the collective user input which follows the pattern of
 | 
			
		||||
    asterix (*) separated entries.
 | 
			
		||||
    :param user_input: The data entered by a user.
 | 
			
		||||
    :type user_input: str
 | 
			
		||||
    :return: The last element in the user input value.
 | 
			
		||||
    :rtype: str
 | 
			
		||||
    """
 | 
			
		||||
    return user_input.split('*')[-1]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def initiate_account_creation_request(chain_str: str,
 | 
			
		||||
                                      external_session_id: str,
 | 
			
		||||
                                      phone_number: str,
 | 
			
		||||
                                      service_code: str,
 | 
			
		||||
                                      user_input: str) -> str:
 | 
			
		||||
    """This function issues a task to create a blockchain account on cic-eth. It then creates a record of the ussd
 | 
			
		||||
    session corresponding to the creation of the account and returns a response denoting that the user's account is
 | 
			
		||||
    being created.
 | 
			
		||||
    :param chain_str: The chain name and network id.
 | 
			
		||||
    :type chain_str: str
 | 
			
		||||
    :param external_session_id: A unique ID from africastalking.
 | 
			
		||||
    :type external_session_id: str
 | 
			
		||||
    :param phone_number: The phone number for the account to be created.
 | 
			
		||||
    :type phone_number: str
 | 
			
		||||
    :param service_code: The service code dialed.
 | 
			
		||||
    :type service_code: str
 | 
			
		||||
    :param user_input: The input entered by the user.
 | 
			
		||||
    :type user_input: str
 | 
			
		||||
    :return: A response denoting that the account is being created.
 | 
			
		||||
    :rtype: str
 | 
			
		||||
    """
 | 
			
		||||
    # attempt to create a user
 | 
			
		||||
    cic_eth_api = Api(callback_task='cic_ussd.tasks.callback_handler.process_account_creation_callback',
 | 
			
		||||
                      callback_queue='cic-ussd',
 | 
			
		||||
                      callback_param='',
 | 
			
		||||
                      chain_str=chain_str)
 | 
			
		||||
    creation_task_id = cic_eth_api.create_account().id
 | 
			
		||||
 | 
			
		||||
    # record task initiation time
 | 
			
		||||
    add_tasks_to_tracker(task_uuid=creation_task_id)
 | 
			
		||||
 | 
			
		||||
    # cache account creation data
 | 
			
		||||
    cache_account_creation_task_id(phone_number=phone_number, task_id=creation_task_id)
 | 
			
		||||
 | 
			
		||||
    # find menu to notify user account is being created
 | 
			
		||||
    current_menu = UssdMenu.find_by_name(name='account_creation_prompt')
 | 
			
		||||
 | 
			
		||||
    # create a ussd session session
 | 
			
		||||
    create_or_update_session(
 | 
			
		||||
        external_session_id=external_session_id,
 | 
			
		||||
        phone=phone_number,
 | 
			
		||||
        service_code=service_code,
 | 
			
		||||
        current_menu=current_menu.get('name'),
 | 
			
		||||
        user_input=user_input)
 | 
			
		||||
 | 
			
		||||
    # define response to relay to user
 | 
			
		||||
    response = define_multilingual_responses(
 | 
			
		||||
        key='ussd.kenya.account_creation_prompt', locales=['en', 'sw'], prefix='END')
 | 
			
		||||
    return response
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def define_multilingual_responses(key: str, locales: list, prefix: str, **kwargs):
 | 
			
		||||
    """This function returns responses in multiple languages in the interest of enabling responses in more than one
 | 
			
		||||
    language.
 | 
			
		||||
    :param key: The key to access some text value from the translation files.
 | 
			
		||||
    :type key: str
 | 
			
		||||
    :param locales: A list of the locales to translate the text value to.
 | 
			
		||||
    :type locales: list
 | 
			
		||||
    :param prefix: The prefix for the text value either: (CON|END)
 | 
			
		||||
    :type prefix: str
 | 
			
		||||
    :param kwargs: Other arguments to be passed to the translator
 | 
			
		||||
    :type kwargs: kwargs
 | 
			
		||||
    :return: A string of the text value in multiple languages.
 | 
			
		||||
    :rtype: str
 | 
			
		||||
    """
 | 
			
		||||
    prefix = prefix.upper()
 | 
			
		||||
    response = f'{prefix} '
 | 
			
		||||
    for locale in locales:
 | 
			
		||||
        response += i18n.t(key=key, locale=locale, **kwargs)
 | 
			
		||||
        response += '\n'
 | 
			
		||||
    return response
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def persist_session_to_db_task(external_session_id: str, queue: str):
 | 
			
		||||
    """
 | 
			
		||||
    This function creates a signature matching the persist session to db task and runs the task asynchronously.
 | 
			
		||||
    :param external_session_id: Session id value provided by AT
 | 
			
		||||
    :type external_session_id: str
 | 
			
		||||
    :param queue: Celery queue on which task should run
 | 
			
		||||
    :type queue: str
 | 
			
		||||
    """
 | 
			
		||||
    s_persist_session_to_db = celery.signature(
 | 
			
		||||
        'cic_ussd.tasks.ussd.persist_session_to_db',
 | 
			
		||||
        [external_session_id]
 | 
			
		||||
    )
 | 
			
		||||
    s_persist_session_to_db.apply_async(queue=queue)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def cache_account_creation_task_id(phone_number: str, task_id: str):
 | 
			
		||||
    """This function stores the task id that is returned from a task spawned to create a blockchain account in the redis
 | 
			
		||||
    cache.
 | 
			
		||||
    :param phone_number: The phone number for the user whose account is being created.
 | 
			
		||||
    :type phone_number: str
 | 
			
		||||
    :param task_id: A celery task id
 | 
			
		||||
    :type task_id: str
 | 
			
		||||
    """
 | 
			
		||||
    redis_cache = InMemoryStore.cache
 | 
			
		||||
    account_creation_request_data = {
 | 
			
		||||
        'phone_number': phone_number,
 | 
			
		||||
        'sms_notification_sent': False,
 | 
			
		||||
        'status': 'PENDING',
 | 
			
		||||
        'task_id': task_id,
 | 
			
		||||
    }
 | 
			
		||||
    redis_cache.set(task_id, json.dumps(account_creation_request_data))
 | 
			
		||||
    redis_cache.persist(name=task_id)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def process_current_menu(ussd_session: Optional[dict], user: User, user_input: str) -> Document:
 | 
			
		||||
    """This function checks user input and returns a corresponding ussd menu
 | 
			
		||||
    :param ussd_session: An in db ussd session object.
 | 
			
		||||
    :type ussd_session: UssdSession
 | 
			
		||||
    :param user: A user object.
 | 
			
		||||
    :type user: User
 | 
			
		||||
    :param user_input: The user's input.
 | 
			
		||||
    :type user_input: str
 | 
			
		||||
    :return: An in memory ussd menu object.
 | 
			
		||||
    :rtype: Document
 | 
			
		||||
    """
 | 
			
		||||
    # handle invalid inputs
 | 
			
		||||
    if ussd_session and user_input == "":
 | 
			
		||||
        current_menu = UssdMenu.find_by_name(name='exit_invalid_input')
 | 
			
		||||
    else:
 | 
			
		||||
        # get current state
 | 
			
		||||
        latest_input = get_latest_input(user_input=user_input)
 | 
			
		||||
        current_menu = process_request(ussd_session=ussd_session, user_input=latest_input, user=user)
 | 
			
		||||
    return current_menu
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def process_menu_interaction_requests(chain_str: str,
 | 
			
		||||
                                      external_session_id: str,
 | 
			
		||||
                                      phone_number: str,
 | 
			
		||||
                                      queue: str,
 | 
			
		||||
                                      service_code: str,
 | 
			
		||||
                                      user_input: str) -> str:
 | 
			
		||||
    """This function handles requests intended for interaction with ussd menu, it checks whether a user matching the
 | 
			
		||||
    provided phone number exists and in the absence of which it creates an account for the user.
 | 
			
		||||
    In the event that a user exists it processes the request and returns an appropriate response.
 | 
			
		||||
    :param chain_str: The chain name and network id.
 | 
			
		||||
    :type chain_str: str
 | 
			
		||||
    :param external_session_id: Unique session id from AfricasTalking
 | 
			
		||||
    :type external_session_id: str
 | 
			
		||||
    :param phone_number: Phone number of the user making the request.
 | 
			
		||||
    :type phone_number: str
 | 
			
		||||
    :param queue: The celery queue on which to run tasks
 | 
			
		||||
    :type queue: str
 | 
			
		||||
    :param service_code: The service dialed by the user making the request.
 | 
			
		||||
    :type service_code: str
 | 
			
		||||
    :param user_input: The inputs entered by the user.
 | 
			
		||||
    :type user_input: str
 | 
			
		||||
    :return: A response based on the request received.
 | 
			
		||||
    :rtype: str
 | 
			
		||||
    """
 | 
			
		||||
    # check whether the user exists
 | 
			
		||||
    if not check_known_user(phone=phone_number):
 | 
			
		||||
        response = initiate_account_creation_request(chain_str=chain_str,
 | 
			
		||||
                                                     external_session_id=external_session_id,
 | 
			
		||||
                                                     phone_number=phone_number,
 | 
			
		||||
                                                     service_code=service_code,
 | 
			
		||||
                                                     user_input=user_input)
 | 
			
		||||
 | 
			
		||||
    else:
 | 
			
		||||
        # get user
 | 
			
		||||
        user = User.session.query(User).filter_by(phone_number=phone_number).first()
 | 
			
		||||
 | 
			
		||||
        # find any existing ussd session
 | 
			
		||||
        existing_ussd_session = UssdSession.session.query(UssdSession).filter_by(
 | 
			
		||||
            external_session_id=external_session_id).first()
 | 
			
		||||
 | 
			
		||||
        # validate user inputs
 | 
			
		||||
        if existing_ussd_session:
 | 
			
		||||
            current_menu = process_current_menu(
 | 
			
		||||
                ussd_session=existing_ussd_session.to_json(),
 | 
			
		||||
                user=user,
 | 
			
		||||
                user_input=user_input
 | 
			
		||||
            )
 | 
			
		||||
        else:
 | 
			
		||||
            current_menu = process_current_menu(
 | 
			
		||||
                ussd_session=None,
 | 
			
		||||
                user=user,
 | 
			
		||||
                user_input=user_input
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        # create or update the ussd session as appropriate
 | 
			
		||||
        ussd_session = create_or_update_session(
 | 
			
		||||
            external_session_id=external_session_id,
 | 
			
		||||
            phone=phone_number,
 | 
			
		||||
            service_code=service_code,
 | 
			
		||||
            user_input=user_input,
 | 
			
		||||
            current_menu=current_menu.get('name')
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # define appropriate response
 | 
			
		||||
        response = custom_display_text(
 | 
			
		||||
            display_key=current_menu.get('display_key'),
 | 
			
		||||
            menu_name=current_menu.get('name'),
 | 
			
		||||
            ussd_session=ussd_session.to_json(),
 | 
			
		||||
            user=user
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    # check that the response from the processor is valid
 | 
			
		||||
    if not validate_response_type(processor_response=response):
 | 
			
		||||
        raise Exception(f'Invalid response: {response}')
 | 
			
		||||
 | 
			
		||||
    # persist session to db
 | 
			
		||||
    persist_session_to_db_task(external_session_id=external_session_id, queue=queue)
 | 
			
		||||
 | 
			
		||||
    return response
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def reset_pin(phone_number: str) -> str:
 | 
			
		||||
    """Reset account status from Locked to Pending.
 | 
			
		||||
    :param phone_number: The phone number belonging to the account to be unlocked.
 | 
			
		||||
    :type phone_number: str
 | 
			
		||||
    :return: The status of the pin reset.
 | 
			
		||||
    :rtype: str
 | 
			
		||||
    """
 | 
			
		||||
    user = User.session.query(User).filter_by(phone_number=phone_number).first()
 | 
			
		||||
    user.reset_account_pin()
 | 
			
		||||
    User.session.add(user)
 | 
			
		||||
    User.session.commit()
 | 
			
		||||
 | 
			
		||||
    response = f'Pin reset for user {phone_number} is successful!'
 | 
			
		||||
    return response
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def update_ussd_session(
 | 
			
		||||
        ussd_session: InMemoryUssdSession,
 | 
			
		||||
        user_input: str,
 | 
			
		||||
        current_menu: str,
 | 
			
		||||
        session_data: Optional[dict] = None) -> InMemoryUssdSession:
 | 
			
		||||
    """
 | 
			
		||||
    Updates a ussd session
 | 
			
		||||
    :param ussd_session: Session id value provided by AT
 | 
			
		||||
    :type ussd_session: InMemoryUssdSession
 | 
			
		||||
    :param user_input: Input from the request
 | 
			
		||||
    :type user_input: str
 | 
			
		||||
    :param current_menu: Menu name that is currently being displayed on the ussd session
 | 
			
		||||
    :type current_menu: str
 | 
			
		||||
    :param session_data: Any additional data that was persisted during the user's interaction with the system.
 | 
			
		||||
    :type session_data: dict.
 | 
			
		||||
    :return: ussd session object
 | 
			
		||||
    :rtype: InMemoryUssdSession
 | 
			
		||||
    """
 | 
			
		||||
    if session_data is None:
 | 
			
		||||
        session_data = ussd_session.session_data
 | 
			
		||||
 | 
			
		||||
    session = InMemoryUssdSession(
 | 
			
		||||
        external_session_id=ussd_session.external_session_id,
 | 
			
		||||
        msisdn=ussd_session.msisdn,
 | 
			
		||||
        user_input=user_input,
 | 
			
		||||
        state=current_menu,
 | 
			
		||||
        service_code=ussd_session.service_code,
 | 
			
		||||
        session_data=session_data
 | 
			
		||||
    )
 | 
			
		||||
    return session
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def save_to_in_memory_ussd_session_data(queue: str, session_data: dict, ussd_session: dict):
 | 
			
		||||
    """This function is used to save information to the session data attribute of a ussd session object in the redis
 | 
			
		||||
    cache.
 | 
			
		||||
    :param queue: The queue on which the celery task should run.
 | 
			
		||||
    :type queue: str
 | 
			
		||||
    :param session_data: A dictionary containing data for a specific ussd session in redis that needs to be saved
 | 
			
		||||
    temporarily.
 | 
			
		||||
    :type session_data: dict
 | 
			
		||||
    :param ussd_session: A ussd session passed to the state machine.
 | 
			
		||||
    :type ussd_session: UssdSession
 | 
			
		||||
    """
 | 
			
		||||
    # define redis cache entry point
 | 
			
		||||
    cache = InMemoryStore.cache
 | 
			
		||||
 | 
			
		||||
    # get external session id
 | 
			
		||||
    external_session_id = ussd_session.get('external_session_id')
 | 
			
		||||
 | 
			
		||||
    # check for existing session data
 | 
			
		||||
    existing_session_data = ussd_session.get('session_data')
 | 
			
		||||
 | 
			
		||||
    # merge old session data with new inputs to session data
 | 
			
		||||
    if existing_session_data:
 | 
			
		||||
        session_data = {**existing_session_data, **session_data}
 | 
			
		||||
 | 
			
		||||
    # get corresponding session record
 | 
			
		||||
    in_redis_ussd_session = cache.get(external_session_id)
 | 
			
		||||
    in_redis_ussd_session = json.loads(in_redis_ussd_session)
 | 
			
		||||
 | 
			
		||||
    # create new in memory ussd session with current ussd session data
 | 
			
		||||
    create_or_update_session(
 | 
			
		||||
        external_session_id=external_session_id,
 | 
			
		||||
        phone=in_redis_ussd_session.get('msisdn'),
 | 
			
		||||
        service_code=in_redis_ussd_session.get('service_code'),
 | 
			
		||||
        user_input=in_redis_ussd_session.get('user_input'),
 | 
			
		||||
        current_menu=in_redis_ussd_session.get('state'),
 | 
			
		||||
        session_data=session_data
 | 
			
		||||
    )
 | 
			
		||||
    persist_session_to_db_task(external_session_id=external_session_id, queue=queue)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def process_phone_number(phone_number: str, region: str):
 | 
			
		||||
    """This function parses any phone number for the provided region
 | 
			
		||||
    :param phone_number: A string with a phone number.
 | 
			
		||||
    :type phone_number: str
 | 
			
		||||
    :param region: Caller defined region
 | 
			
		||||
    :type region: str
 | 
			
		||||
    :return: The parsed phone number value based on the defined region
 | 
			
		||||
    :rtype: str
 | 
			
		||||
    """
 | 
			
		||||
    if not isinstance(phone_number, str):
 | 
			
		||||
        try:
 | 
			
		||||
            phone_number = str(int(phone_number))
 | 
			
		||||
 | 
			
		||||
        except ValueError:
 | 
			
		||||
            pass
 | 
			
		||||
 | 
			
		||||
    phone_number_object = phonenumbers.parse(phone_number, region)
 | 
			
		||||
    parsed_phone_number = phonenumbers.format_number(phone_number_object, phonenumbers.PhoneNumberFormat.E164)
 | 
			
		||||
 | 
			
		||||
    return parsed_phone_number
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_user_by_phone_number(phone_number: str) -> Optional[User]:
 | 
			
		||||
    """This function queries the database for a user based on the provided phone number.
 | 
			
		||||
    :param phone_number: A valid phone number.
 | 
			
		||||
    :type phone_number: str
 | 
			
		||||
    :return: A user object matching a given phone number
 | 
			
		||||
    :rtype: User|None
 | 
			
		||||
    """
 | 
			
		||||
    # consider adding region to user's metadata
 | 
			
		||||
    phone_number = process_phone_number(phone_number=phone_number, region='KE')
 | 
			
		||||
    user = User.session.query(User).filter_by(phone_number=phone_number).first()
 | 
			
		||||
    return user
 | 
			
		||||
							
								
								
									
										245
									
								
								apps/cic-ussd/cic_ussd/processor.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,245 @@
 | 
			
		||||
# standard imports
 | 
			
		||||
import logging
 | 
			
		||||
from typing import Optional
 | 
			
		||||
 | 
			
		||||
# third party imports
 | 
			
		||||
from tinydb.table import Document
 | 
			
		||||
 | 
			
		||||
# local imports
 | 
			
		||||
from cic_ussd.accounts import BalanceManager
 | 
			
		||||
from cic_ussd.db.models.user import AccountStatus, User
 | 
			
		||||
from cic_ussd.db.models.ussd_session import UssdSession
 | 
			
		||||
from cic_ussd.menu.ussd_menu import UssdMenu
 | 
			
		||||
from cic_ussd.state_machine import UssdStateMachine
 | 
			
		||||
from cic_ussd.transactions import to_wei, from_wei
 | 
			
		||||
from cic_ussd.translation import translation_for
 | 
			
		||||
 | 
			
		||||
logg = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def process_pin_authorization(display_key: str, user: User, **kwargs) -> str:
 | 
			
		||||
    """
 | 
			
		||||
    This method provides translation for all ussd menu entries that follow the pin authorization pattern.
 | 
			
		||||
    :param display_key: The path in the translation files defining an appropriate ussd response
 | 
			
		||||
    :type display_key: str
 | 
			
		||||
    :param user: The user in a running USSD session.
 | 
			
		||||
    :type user: User
 | 
			
		||||
    :param kwargs: Any additional information required by the text values in the internationalization files.
 | 
			
		||||
    :type kwargs
 | 
			
		||||
    :return: A string value corresponding the ussd menu's text value.
 | 
			
		||||
    :rtype: str
 | 
			
		||||
    """
 | 
			
		||||
    remaining_attempts = 3
 | 
			
		||||
    if user.failed_pin_attempts > 0:
 | 
			
		||||
        return translation_for(
 | 
			
		||||
            key=f'{display_key}.retry',
 | 
			
		||||
            preferred_language=user.preferred_language,
 | 
			
		||||
            remaining_attempts=(remaining_attempts - user.failed_pin_attempts)
 | 
			
		||||
        )
 | 
			
		||||
    else:
 | 
			
		||||
        return translation_for(
 | 
			
		||||
            key=f'{display_key}.first',
 | 
			
		||||
            preferred_language=user.preferred_language,
 | 
			
		||||
            **kwargs
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def process_exit_insufficient_balance(display_key: str, user: User, ussd_session: dict):
 | 
			
		||||
    """This function processes the exit menu letting users their account balance is insufficient to perform a specific
 | 
			
		||||
    transaction.
 | 
			
		||||
    :param display_key: The path in the translation files defining an appropriate ussd response
 | 
			
		||||
    :type display_key: str
 | 
			
		||||
    :param user: The user requesting access to the ussd menu.
 | 
			
		||||
    :type user: User
 | 
			
		||||
    :param ussd_session: A JSON serialized in-memory ussd session object
 | 
			
		||||
    :type ussd_session: dict
 | 
			
		||||
    :return: Corresponding translation text response
 | 
			
		||||
    :rtype: str
 | 
			
		||||
    """
 | 
			
		||||
    # get account balance
 | 
			
		||||
    balance_manager = BalanceManager(address=user.blockchain_address,
 | 
			
		||||
                                     chain_str=UssdStateMachine.chain_str,
 | 
			
		||||
                                     token_symbol='SRF')
 | 
			
		||||
    balance = balance_manager.get_operational_balance()
 | 
			
		||||
 | 
			
		||||
    # compile response data
 | 
			
		||||
    user_input = ussd_session.get('user_input').split('*')[-1]
 | 
			
		||||
    transaction_amount = to_wei(value=int(user_input))
 | 
			
		||||
    token_symbol = 'SRF'
 | 
			
		||||
    recipient_phone_number = ussd_session.get('session_data').get('recipient_phone_number')
 | 
			
		||||
    tx_recipient_information = recipient_phone_number
 | 
			
		||||
 | 
			
		||||
    return translation_for(
 | 
			
		||||
        key=display_key,
 | 
			
		||||
        preferred_language=user.preferred_language,
 | 
			
		||||
        amount=from_wei(transaction_amount),
 | 
			
		||||
        token_symbol=token_symbol,
 | 
			
		||||
        recipient_information=tx_recipient_information,
 | 
			
		||||
        token_balance=balance
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def process_exit_successful_transaction(display_key: str, user: User, ussd_session: dict):
 | 
			
		||||
    """This function processes the exit menu after a successful initiation for a transfer of tokens.
 | 
			
		||||
    :param display_key: The path in the translation files defining an appropriate ussd response
 | 
			
		||||
    :type display_key: str
 | 
			
		||||
    :param user: The user requesting access to the ussd menu.
 | 
			
		||||
    :type user: User
 | 
			
		||||
    :param ussd_session: A JSON serialized in-memory ussd session object
 | 
			
		||||
    :type ussd_session: dict
 | 
			
		||||
    :return: Corresponding translation text response
 | 
			
		||||
    :rtype: str
 | 
			
		||||
    """
 | 
			
		||||
    transaction_amount = to_wei(int(ussd_session.get('session_data').get('transaction_amount')))
 | 
			
		||||
    token_symbol = 'SRF'
 | 
			
		||||
    recipient_phone_number = ussd_session.get('session_data').get('recipient_phone_number')
 | 
			
		||||
    sender_phone_number = user.phone_number
 | 
			
		||||
    tx_recipient_information = recipient_phone_number
 | 
			
		||||
    tx_sender_information = sender_phone_number
 | 
			
		||||
 | 
			
		||||
    return translation_for(
 | 
			
		||||
        key=display_key,
 | 
			
		||||
        preferred_language=user.preferred_language,
 | 
			
		||||
        transaction_amount=from_wei(transaction_amount),
 | 
			
		||||
        token_symbol=token_symbol,
 | 
			
		||||
        recipient_information=tx_recipient_information,
 | 
			
		||||
        sender_information=tx_sender_information
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def process_transaction_pin_authorization(user: User, display_key: str, ussd_session: dict):
 | 
			
		||||
    """This function processes pin authorization where making a transaction is concerned. It constructs a
 | 
			
		||||
    pre-transaction response menu that shows the details of the transaction.
 | 
			
		||||
    :param user: The user requesting access to the ussd menu.
 | 
			
		||||
    :type user: User
 | 
			
		||||
    :param display_key: The path in the translation files defining an appropriate ussd response
 | 
			
		||||
    :type display_key: str
 | 
			
		||||
    :param ussd_session: The USSD session determining what user data needs to be extracted and added to the menu's
 | 
			
		||||
    text values.
 | 
			
		||||
    :type ussd_session: UssdSession
 | 
			
		||||
    :return: Corresponding translation text response
 | 
			
		||||
    :rtype: str
 | 
			
		||||
    """
 | 
			
		||||
    # compile response data
 | 
			
		||||
    recipient_phone_number = ussd_session.get('session_data').get('recipient_phone_number')
 | 
			
		||||
    tx_recipient_information = recipient_phone_number
 | 
			
		||||
    tx_sender_information = user.phone_number
 | 
			
		||||
    logg.debug('Requires integration with cic-meta to get user name.')
 | 
			
		||||
    token_symbol = 'SRF'
 | 
			
		||||
    user_input = ussd_session.get('user_input').split('*')[-1]
 | 
			
		||||
    transaction_amount = to_wei(value=int(user_input))
 | 
			
		||||
    logg.debug('Requires integration to determine user tokens.')
 | 
			
		||||
    return process_pin_authorization(
 | 
			
		||||
        user=user,
 | 
			
		||||
        display_key=display_key,
 | 
			
		||||
        recipient_information=tx_recipient_information,
 | 
			
		||||
        transaction_amount=from_wei(transaction_amount),
 | 
			
		||||
        token_symbol=token_symbol,
 | 
			
		||||
        sender_information=tx_sender_information
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def process_start_menu(display_key: str, user: User):
 | 
			
		||||
    """This function gets data on an account's balance and token in order to append it to the start of the start menu's
 | 
			
		||||
    title. It passes said arguments to the translation function and returns the appropriate corresponding text from the
 | 
			
		||||
    translation files.
 | 
			
		||||
    :param user: The user requesting access to the ussd menu.
 | 
			
		||||
    :type user: User
 | 
			
		||||
    :param display_key: The path in the translation files defining an appropriate ussd response
 | 
			
		||||
    :type display_key: str
 | 
			
		||||
    :return: Corresponding translation text response
 | 
			
		||||
    :rtype: str
 | 
			
		||||
    """
 | 
			
		||||
    balance_manager = BalanceManager(address=user.blockchain_address,
 | 
			
		||||
                                     chain_str=UssdStateMachine.chain_str,
 | 
			
		||||
                                     token_symbol='SRF')
 | 
			
		||||
    balance = balance_manager.get_operational_balance()
 | 
			
		||||
    token_symbol = 'SRF'
 | 
			
		||||
    logg.debug("Requires integration to determine user's balance and token.")
 | 
			
		||||
    return translation_for(
 | 
			
		||||
        key=display_key,
 | 
			
		||||
        preferred_language=user.preferred_language,
 | 
			
		||||
        account_balance=balance,
 | 
			
		||||
        account_token_name=token_symbol
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def process_request(user_input: str, user: User, ussd_session: Optional[dict] = None) -> Document:
 | 
			
		||||
    """This function assesses a request based on the user from the request comes, the session_id and the user's
 | 
			
		||||
    input. It determines whether the request translates to a return to an existing session by checking whether the
 | 
			
		||||
    provided  session id exists in the database or whether the creation of a new ussd session object is warranted.
 | 
			
		||||
    It then returns the appropriate ussd menu text values.
 | 
			
		||||
    :param user: The user requesting access to the ussd menu.
 | 
			
		||||
    :type user: User
 | 
			
		||||
    :param user_input: The value a user enters in the ussd menu.
 | 
			
		||||
    :type user_input: str
 | 
			
		||||
    :param ussd_session: A JSON serialized in-memory ussd session object
 | 
			
		||||
    :type ussd_session: dict
 | 
			
		||||
    :return: A ussd menu's corresponding text value.
 | 
			
		||||
    :rtype: Document
 | 
			
		||||
    """
 | 
			
		||||
    if ussd_session:
 | 
			
		||||
        if user_input == "0":
 | 
			
		||||
            return UssdMenu.parent_menu(menu_name=ussd_session.get('state'))
 | 
			
		||||
        else:
 | 
			
		||||
            successive_state = next_state(ussd_session=ussd_session, user=user, user_input=user_input)
 | 
			
		||||
            return UssdMenu.find_by_name(name=successive_state)
 | 
			
		||||
    else:
 | 
			
		||||
        if user.has_valid_pin():
 | 
			
		||||
            return UssdMenu.find_by_name(name='start')
 | 
			
		||||
        else:
 | 
			
		||||
            if user.failed_pin_attempts >= 3 and user.get_account_status() == AccountStatus.LOCKED.name:
 | 
			
		||||
                return UssdMenu.find_by_name(name='exit_pin_blocked')
 | 
			
		||||
            elif user.preferred_language is None:
 | 
			
		||||
                return UssdMenu.find_by_name(name='initial_language_selection')
 | 
			
		||||
            else:
 | 
			
		||||
                return UssdMenu.find_by_name(name='initial_pin_entry')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def next_state(ussd_session: dict, user: User, user_input: str) -> str:
 | 
			
		||||
    """This function navigates the state machine based on the ussd session object and user inputs it receives.
 | 
			
		||||
    It checks the user input and provides the successive state in the state machine. It then updates the session's
 | 
			
		||||
    state attribute with the new state.
 | 
			
		||||
    :param ussd_session: A JSON serialized in-memory ussd session object
 | 
			
		||||
    :type ussd_session: dict
 | 
			
		||||
    :param user: The user requesting access to the ussd menu.
 | 
			
		||||
    :type user: User
 | 
			
		||||
    :param user_input: The value a user enters in the ussd menu.
 | 
			
		||||
    :type user_input: str
 | 
			
		||||
    :return: A string value corresponding the successive give a specific state in the state machine.
 | 
			
		||||
    """
 | 
			
		||||
    state_machine = UssdStateMachine(ussd_session=ussd_session)
 | 
			
		||||
    state_machine.scan_data((user_input, ussd_session, user))
 | 
			
		||||
    new_state = state_machine.state
 | 
			
		||||
 | 
			
		||||
    return new_state
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def custom_display_text(
 | 
			
		||||
        display_key: str,
 | 
			
		||||
        menu_name: str,
 | 
			
		||||
        ussd_session: dict,
 | 
			
		||||
        user: User) -> str:
 | 
			
		||||
    """This function extracts the appropriate session data based on the current menu name. It then inserts them as
 | 
			
		||||
    keywords in the i18n function.
 | 
			
		||||
    :param display_key: The path in the translation files defining an appropriate ussd response
 | 
			
		||||
    :type display_key: str
 | 
			
		||||
    :param menu_name: The name by which a specific menu can be identified.
 | 
			
		||||
    :type menu_name: str
 | 
			
		||||
    :param user: The user in a running USSD session.
 | 
			
		||||
    :type user: User
 | 
			
		||||
    :param ussd_session: A JSON serialized in-memory ussd session object
 | 
			
		||||
    :type ussd_session: dict
 | 
			
		||||
    :return: A string value corresponding the ussd menu's text value.
 | 
			
		||||
    :rtype: str
 | 
			
		||||
    """
 | 
			
		||||
    if menu_name == 'transaction_pin_authorization':
 | 
			
		||||
        return process_transaction_pin_authorization(display_key=display_key, user=user, ussd_session=ussd_session)
 | 
			
		||||
    elif menu_name == 'exit_insufficient_balance':
 | 
			
		||||
        return process_exit_insufficient_balance(display_key=display_key, user=user, ussd_session=ussd_session)
 | 
			
		||||
    elif menu_name == 'exit_successful_transaction':
 | 
			
		||||
        return process_exit_successful_transaction(display_key=display_key, user=user, ussd_session=ussd_session)
 | 
			
		||||
    elif menu_name == 'start':
 | 
			
		||||
        return process_start_menu(display_key=display_key, user=user)
 | 
			
		||||
    else:
 | 
			
		||||
        return translation_for(key=display_key, preferred_language=user.preferred_language)
 | 
			
		||||
							
								
								
									
										6
									
								
								apps/cic-ussd/cic_ussd/redis.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,6 @@
 | 
			
		||||
# third-party imports
 | 
			
		||||
from redis import Redis
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class InMemoryStore:
 | 
			
		||||
    cache: Redis = None
 | 
			
		||||
							
								
								
									
										134
									
								
								apps/cic-ussd/cic_ussd/requests.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,134 @@
 | 
			
		||||
# standard imports
 | 
			
		||||
from typing import Optional, Tuple, Union
 | 
			
		||||
import json
 | 
			
		||||
import logging
 | 
			
		||||
import re
 | 
			
		||||
from typing import Optional, Union
 | 
			
		||||
from urllib.parse import urlparse, parse_qs
 | 
			
		||||
 | 
			
		||||
# third-party imports
 | 
			
		||||
from sqlalchemy import desc
 | 
			
		||||
 | 
			
		||||
# local imports
 | 
			
		||||
from cic_ussd.db.models.user import AccountStatus, User
 | 
			
		||||
from cic_ussd.operations import get_account_status, reset_pin
 | 
			
		||||
from cic_ussd.validator import check_known_user
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
logg = logging.getLogger(__file__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_query_parameters(env: dict, query_name: Optional[str] = None) -> Union[dict, str]:
 | 
			
		||||
    """Gets value of the request query parameters.
 | 
			
		||||
    :param env: Object containing server and request information.
 | 
			
		||||
    :type env: dict
 | 
			
		||||
    :param query_name: The specific query parameter to fetch.
 | 
			
		||||
    :type query_name: str
 | 
			
		||||
    :return: Query parameters from the request.
 | 
			
		||||
    :rtype: dict | str
 | 
			
		||||
    """
 | 
			
		||||
    parsed_url = urlparse(env.get('REQUEST_URI'))
 | 
			
		||||
    params = parse_qs(parsed_url.query)
 | 
			
		||||
    if query_name:
 | 
			
		||||
        param = params.get(query_name)[0]
 | 
			
		||||
        return param
 | 
			
		||||
    return params
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_request_endpoint(env: dict) -> str:
 | 
			
		||||
    """Gets value of the request url path.
 | 
			
		||||
    :param env: Object containing server and request information
 | 
			
		||||
    :type env: dict
 | 
			
		||||
    :return: Endpoint that has been touched by the call
 | 
			
		||||
    :rtype: str
 | 
			
		||||
    """
 | 
			
		||||
    return env.get('PATH_INFO')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_request_method(env: dict) -> str:
 | 
			
		||||
    """Gets value of the request method.
 | 
			
		||||
    :param env: Object containing server and request information.
 | 
			
		||||
    :type env: dict
 | 
			
		||||
    :return: Request method.
 | 
			
		||||
    :rtype: str
 | 
			
		||||
    """
 | 
			
		||||
    return env.get('REQUEST_METHOD').upper()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_account_creation_callback_request_data(env: dict) -> tuple:
 | 
			
		||||
    """This function retrieves data from a callback
 | 
			
		||||
    :param env: Object containing server and request information.
 | 
			
		||||
    :type env: dict
 | 
			
		||||
    :return: A tuple containing the status, result and task_id for a celery task spawned to create a blockchain
 | 
			
		||||
    account.
 | 
			
		||||
    :rtype: tuple
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    callback_data = env.get('wsgi.input')
 | 
			
		||||
    status = callback_data.get('status')
 | 
			
		||||
    task_id = callback_data.get('root_id')
 | 
			
		||||
    result = callback_data.get('result')
 | 
			
		||||
 | 
			
		||||
    return status, task_id, result
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def process_pin_reset_requests(env: dict, phone_number: str):
 | 
			
		||||
    """This function processes requests that are responsible for the pin reset functionality. It processes GET and PUT
 | 
			
		||||
    requests responsible for returning an account's status and
 | 
			
		||||
    :param env: A dictionary of values representing data sent on the api.
 | 
			
		||||
    :type env: dict
 | 
			
		||||
    :param phone_number: The phone of the user whose pin is being reset.
 | 
			
		||||
    :type phone_number: str
 | 
			
		||||
    :return: A response denoting the result of the request to reset the user's pin.
 | 
			
		||||
    :rtype: str
 | 
			
		||||
    """
 | 
			
		||||
    if not check_known_user(phone=phone_number):
 | 
			
		||||
        return f'No user matching {phone_number} was found.', '404 Not Found'
 | 
			
		||||
 | 
			
		||||
    if get_request_method(env) == 'PUT':
 | 
			
		||||
        return reset_pin(phone_number=phone_number), '200 OK'
 | 
			
		||||
 | 
			
		||||
    if get_request_method(env) == 'GET':
 | 
			
		||||
        status = get_account_status(phone_number=phone_number)
 | 
			
		||||
        response = {
 | 
			
		||||
            'status': f'{status}'
 | 
			
		||||
        }
 | 
			
		||||
        response = json.dumps(response)
 | 
			
		||||
        return response, '200 OK'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def process_locked_accounts_requests(env: dict) -> tuple:
 | 
			
		||||
    """This function authenticates staff requests and returns a serialized JSON formatted list of blockchain addresses
 | 
			
		||||
    of accounts for which the PIN has been locked due to too many failed attempts.
 | 
			
		||||
    :param env: A dictionary of values representing data sent on the api.
 | 
			
		||||
    :type env: dict
 | 
			
		||||
    :return: A tuple containing a serialized list of blockchain addresses for locked accounts and corresponding message
 | 
			
		||||
    for the response.
 | 
			
		||||
    :rtype: tuple
 | 
			
		||||
    """
 | 
			
		||||
    logg.debug('Authentication requires integration with cic-auth')
 | 
			
		||||
    response = ''
 | 
			
		||||
 | 
			
		||||
    if get_request_method(env) == 'GET':
 | 
			
		||||
        offset = 0
 | 
			
		||||
        limit = 100
 | 
			
		||||
 | 
			
		||||
        locked_accounts_path = r'/accounts/locked/(\d+)?/?(\d+)?'
 | 
			
		||||
        r = re.match(locked_accounts_path, env.get('PATH_INFO'))
 | 
			
		||||
 | 
			
		||||
        if r:
 | 
			
		||||
            if r.lastindex > 1:
 | 
			
		||||
                offset = r[1]
 | 
			
		||||
                limit = r[2]
 | 
			
		||||
            else:
 | 
			
		||||
                limit = r[1]
 | 
			
		||||
 | 
			
		||||
        locked_accounts = User.session.query(User.blockchain_address).filter(
 | 
			
		||||
            User.account_status == AccountStatus.LOCKED.value,
 | 
			
		||||
            User.failed_pin_attempts >= 3).order_by(desc(User.updated)).offset(offset).limit(limit).all()
 | 
			
		||||
 | 
			
		||||
        # convert lists to scalar blockchain addresses
 | 
			
		||||
        locked_accounts = [blockchain_address for (blockchain_address, ) in locked_accounts]
 | 
			
		||||
        response = json.dumps(locked_accounts)
 | 
			
		||||
        return response, '200 OK'
 | 
			
		||||
    return response, '405 Play by the rules'
 | 
			
		||||
							
								
								
									
										0
									
								
								apps/cic-ussd/cic_ussd/runnable/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										182
									
								
								apps/cic-ussd/cic_ussd/runnable/server.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,182 @@
 | 
			
		||||
"""Functions defining WSGI interaction with external http requests
 | 
			
		||||
Defines an application function essential for the uWSGI python loader to run th python application code.
 | 
			
		||||
"""
 | 
			
		||||
# standard imports
 | 
			
		||||
import argparse
 | 
			
		||||
import celery
 | 
			
		||||
import i18n
 | 
			
		||||
import json
 | 
			
		||||
import logging
 | 
			
		||||
import os
 | 
			
		||||
import redis
 | 
			
		||||
 | 
			
		||||
# third-party imports
 | 
			
		||||
from confini import Config
 | 
			
		||||
from urllib.parse import quote_plus
 | 
			
		||||
 | 
			
		||||
# local imports
 | 
			
		||||
from cic_ussd.db import dsn_from_config
 | 
			
		||||
from cic_ussd.db.models.base import SessionBase
 | 
			
		||||
from cic_ussd.encoder import PasswordEncoder
 | 
			
		||||
from cic_ussd.files.local_files import create_local_file_data_stores, json_file_parser
 | 
			
		||||
from cic_ussd.menu.ussd_menu import UssdMenu
 | 
			
		||||
from cic_ussd.operations import (define_response_with_content,
 | 
			
		||||
                                 process_menu_interaction_requests,
 | 
			
		||||
                                 define_multilingual_responses)
 | 
			
		||||
from cic_ussd.redis import InMemoryStore
 | 
			
		||||
from cic_ussd.requests import (get_request_endpoint,
 | 
			
		||||
                               get_request_method,
 | 
			
		||||
                               get_query_parameters,
 | 
			
		||||
                               process_locked_accounts_requests,
 | 
			
		||||
                               process_pin_reset_requests)
 | 
			
		||||
from cic_ussd.session.ussd_session import UssdSession as InMemoryUssdSession
 | 
			
		||||
from cic_ussd.state_machine import UssdStateMachine
 | 
			
		||||
from cic_ussd.validator import check_ip, check_request_content_length, check_service_code, validate_phone_number
 | 
			
		||||
 | 
			
		||||
logging.basicConfig(level=logging.WARNING)
 | 
			
		||||
logg = logging.getLogger()
 | 
			
		||||
 | 
			
		||||
config_directory = '/usr/local/etc/cic-ussd/'
 | 
			
		||||
 | 
			
		||||
# define arguments
 | 
			
		||||
arg_parser = argparse.ArgumentParser()
 | 
			
		||||
arg_parser.add_argument('-c', type=str, default=config_directory, help='config directory.')
 | 
			
		||||
arg_parser.add_argument('-q', type=str, default='cic-ussd', help='queue name for worker tasks')
 | 
			
		||||
arg_parser.add_argument('-v', action='store_true', help='be verbose')
 | 
			
		||||
arg_parser.add_argument('-vv', action='store_true', help='be more verbose')
 | 
			
		||||
arg_parser.add_argument('--env-prefix',
 | 
			
		||||
                        default=os.environ.get('CONFINI_ENV_PREFIX'),
 | 
			
		||||
                        dest='env_prefix',
 | 
			
		||||
                        type=str,
 | 
			
		||||
                        help='environment prefix for variables to overwrite configuration')
 | 
			
		||||
args = arg_parser.parse_args()
 | 
			
		||||
 | 
			
		||||
# parse config
 | 
			
		||||
config = Config(config_dir=args.c, env_prefix=args.env_prefix)
 | 
			
		||||
config.process()
 | 
			
		||||
config.censor('PASSWORD', 'DATABASE')
 | 
			
		||||
 | 
			
		||||
# define log levels
 | 
			
		||||
if args.vv:
 | 
			
		||||
    logging.getLogger().setLevel(logging.DEBUG)
 | 
			
		||||
elif args.v:
 | 
			
		||||
    logging.getLogger().setLevel(logging.INFO)
 | 
			
		||||
 | 
			
		||||
# log config vars
 | 
			
		||||
logg.debug(config)
 | 
			
		||||
 | 
			
		||||
# initialize elements
 | 
			
		||||
# set up translations
 | 
			
		||||
i18n.load_path.append(config.get('APP_LOCALE_PATH'))
 | 
			
		||||
i18n.set('fallback', config.get('APP_LOCALE_FALLBACK'))
 | 
			
		||||
 | 
			
		||||
# set Fernet key
 | 
			
		||||
PasswordEncoder.set_key(config.get('APP_PASSWORD_PEPPER'))
 | 
			
		||||
 | 
			
		||||
# create in-memory databases
 | 
			
		||||
ussd_menu_db = create_local_file_data_stores(file_location=config.get('USSD_MENU_FILE'),
 | 
			
		||||
                                             table_name='ussd_menu')
 | 
			
		||||
UssdMenu.ussd_menu_db = ussd_menu_db
 | 
			
		||||
 | 
			
		||||
# set up db
 | 
			
		||||
data_source_name = dsn_from_config(config)
 | 
			
		||||
SessionBase.connect(data_source_name=data_source_name)
 | 
			
		||||
# create session for the life time of http request
 | 
			
		||||
SessionBase.session = SessionBase.create_session()
 | 
			
		||||
 | 
			
		||||
# define universal redis cache access
 | 
			
		||||
InMemoryStore.cache = redis.StrictRedis(host=config.get('REDIS_HOSTNAME'),
 | 
			
		||||
                                        port=config.get('REDIS_PORT'),
 | 
			
		||||
                                        password=config.get('REDIS_PASSWORD'),
 | 
			
		||||
                                        db=config.get('REDIS_DATABASE'),
 | 
			
		||||
                                        decode_responses=True)
 | 
			
		||||
InMemoryUssdSession.redis_cache = InMemoryStore.cache
 | 
			
		||||
 | 
			
		||||
# initialize celery app
 | 
			
		||||
celery.Celery(backend=config.get('CELERY_RESULT_URL'), broker=config.get('CELERY_BROKER_URL'))
 | 
			
		||||
 | 
			
		||||
# load states and transitions data
 | 
			
		||||
states = json_file_parser(filepath=config.get('STATEMACHINE_STATES'))
 | 
			
		||||
transitions = json_file_parser(filepath=config.get('STATEMACHINE_TRANSITIONS'))
 | 
			
		||||
 | 
			
		||||
UssdStateMachine.chain_str = config.get('CIC_CHAIN_SPEC')
 | 
			
		||||
UssdStateMachine.states = states
 | 
			
		||||
UssdStateMachine.transitions = transitions
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def application(env, start_response):
 | 
			
		||||
    """Loads python code for application to be accessible over web server
 | 
			
		||||
    :param env: Object containing server and request information
 | 
			
		||||
    :type env: dict
 | 
			
		||||
    :param start_response: Callable to define responses.
 | 
			
		||||
    :type start_response: any
 | 
			
		||||
    """
 | 
			
		||||
    # define headers
 | 
			
		||||
    errors_headers = [('Content-Type', 'text/plain'), ('Content-Length', '0')]
 | 
			
		||||
    headers = [('Content-Type', 'text/plain')]
 | 
			
		||||
 | 
			
		||||
    if get_request_method(env=env) == 'POST' and get_request_endpoint(env=env) == '/':
 | 
			
		||||
 | 
			
		||||
        # get post data
 | 
			
		||||
        post_data = json.load(env.get('wsgi.input'))
 | 
			
		||||
 | 
			
		||||
        service_code = post_data.get('serviceCode')
 | 
			
		||||
        phone_number = post_data.get('phoneNumber')
 | 
			
		||||
        external_session_id = post_data.get('sessionId')
 | 
			
		||||
        user_input = post_data.get('text')
 | 
			
		||||
 | 
			
		||||
        # validate ip address
 | 
			
		||||
        if not check_ip(config=config, env=env):
 | 
			
		||||
            start_response('403 Sneaky, sneaky', errors_headers)
 | 
			
		||||
            return []
 | 
			
		||||
 | 
			
		||||
        # validate content length
 | 
			
		||||
        if not check_request_content_length(config=config, env=env):
 | 
			
		||||
            start_response('400 Size matters', errors_headers)
 | 
			
		||||
            return []
 | 
			
		||||
 | 
			
		||||
        # validate service code
 | 
			
		||||
        if not check_service_code(code=service_code, config=config):
 | 
			
		||||
            response = define_multilingual_responses(
 | 
			
		||||
                key='ussd.kenya.invalid_service_code',
 | 
			
		||||
                locales=['en', 'sw'],
 | 
			
		||||
                prefix='END',
 | 
			
		||||
                valid_service_code=config.get('APP_SERVICE_CODE'))
 | 
			
		||||
            response_bytes, headers = define_response_with_content(headers=errors_headers, response=response)
 | 
			
		||||
            start_response('400 Invalid service code', headers)
 | 
			
		||||
            return [response_bytes]
 | 
			
		||||
 | 
			
		||||
        # validate phone number
 | 
			
		||||
        if not validate_phone_number(phone_number):
 | 
			
		||||
            start_response('400 Invalid phone number format', errors_headers)
 | 
			
		||||
            return []
 | 
			
		||||
 | 
			
		||||
        # handle menu interaction requests
 | 
			
		||||
        response = process_menu_interaction_requests(chain_str=config.get('CIC_CHAIN_SPEC'),
 | 
			
		||||
                                                     external_session_id=external_session_id,
 | 
			
		||||
                                                     phone_number=phone_number,
 | 
			
		||||
                                                     queue=args.q,
 | 
			
		||||
                                                     service_code=service_code,
 | 
			
		||||
                                                     user_input=user_input)
 | 
			
		||||
 | 
			
		||||
        response_bytes, headers = define_response_with_content(headers=headers, response=response)
 | 
			
		||||
        start_response('200 OK,', headers)
 | 
			
		||||
        SessionBase.session.close()
 | 
			
		||||
        return [response_bytes]
 | 
			
		||||
 | 
			
		||||
    # handle pin requests
 | 
			
		||||
    if get_request_endpoint(env) == '/pin':
 | 
			
		||||
        phone_number = get_query_parameters(env=env, query_name='phoneNumber')
 | 
			
		||||
        phone_number = quote_plus(phone_number)
 | 
			
		||||
        response, message = process_pin_reset_requests(env=env, phone_number=phone_number)
 | 
			
		||||
        response_bytes, headers = define_response_with_content(headers=errors_headers, response=response)
 | 
			
		||||
        SessionBase.session.close()
 | 
			
		||||
        start_response(message, headers)
 | 
			
		||||
        return [response_bytes]
 | 
			
		||||
 | 
			
		||||
    # handle requests for locked accounts
 | 
			
		||||
    response, message = process_locked_accounts_requests(env=env)
 | 
			
		||||
    response_bytes, headers = define_response_with_content(headers=headers, response=response)
 | 
			
		||||
    start_response(message, headers)
 | 
			
		||||
    SessionBase.session.close()
 | 
			
		||||
    return [response_bytes]
 | 
			
		||||
							
								
								
									
										113
									
								
								apps/cic-ussd/cic_ussd/runnable/tasker.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,113 @@
 | 
			
		||||
# standard imports
 | 
			
		||||
import argparse
 | 
			
		||||
import logging
 | 
			
		||||
import os
 | 
			
		||||
import tempfile
 | 
			
		||||
 | 
			
		||||
# third party imports
 | 
			
		||||
import celery
 | 
			
		||||
import redis
 | 
			
		||||
from confini import Config
 | 
			
		||||
 | 
			
		||||
# local imports
 | 
			
		||||
from cic_ussd.db import dsn_from_config
 | 
			
		||||
from cic_ussd.db.models.base import SessionBase
 | 
			
		||||
from cic_ussd.redis import InMemoryStore
 | 
			
		||||
from cic_ussd.session.ussd_session import UssdSession as InMemoryUssdSession
 | 
			
		||||
 | 
			
		||||
logging.basicConfig(level=logging.WARNING)
 | 
			
		||||
logg = logging.getLogger()
 | 
			
		||||
 | 
			
		||||
config_directory = '/usr/local/etc/cic-ussd/'
 | 
			
		||||
 | 
			
		||||
# define arguments
 | 
			
		||||
arg_parser = argparse.ArgumentParser()
 | 
			
		||||
arg_parser.add_argument('-c', type=str, default=config_directory, help='config directory.')
 | 
			
		||||
arg_parser.add_argument('-q', type=str, default='cic-ussd', help='queue name for worker tasks')
 | 
			
		||||
arg_parser.add_argument('-v', action='store_true', help='be verbose')
 | 
			
		||||
arg_parser.add_argument('-vv', action='store_true', help='be more verbose')
 | 
			
		||||
arg_parser.add_argument('--env-prefix', default=os.environ.get('CONFINI_ENV_PREFIX'), dest='env_prefix', type=str, help='environment prefix for variables to overwrite configuration')
 | 
			
		||||
args = arg_parser.parse_args()
 | 
			
		||||
 | 
			
		||||
# parse config
 | 
			
		||||
config = Config(config_dir=args.c, env_prefix=args.env_prefix)
 | 
			
		||||
config.process()
 | 
			
		||||
config.censor('PASSWORD', 'DATABASE')
 | 
			
		||||
 | 
			
		||||
# define log levels
 | 
			
		||||
if args.vv:
 | 
			
		||||
    logging.getLogger().setLevel(logging.DEBUG)
 | 
			
		||||
elif args.v:
 | 
			
		||||
    logging.getLogger().setLevel(logging.INFO)
 | 
			
		||||
 | 
			
		||||
logg.debug(config)
 | 
			
		||||
 | 
			
		||||
# connect to database
 | 
			
		||||
data_source_name = dsn_from_config(config)
 | 
			
		||||
SessionBase.connect(data_source_name=data_source_name)
 | 
			
		||||
 | 
			
		||||
# verify database connection with minimal sanity query
 | 
			
		||||
session = SessionBase.create_session()
 | 
			
		||||
session.execute('SELECT version_num FROM alembic_version')
 | 
			
		||||
session.close()
 | 
			
		||||
 | 
			
		||||
# define universal redis cache access
 | 
			
		||||
InMemoryStore.cache = redis.StrictRedis(host=config.get('REDIS_HOSTNAME'),
 | 
			
		||||
                                        port=config.get('REDIS_PORT'),
 | 
			
		||||
                                        password=config.get('REDIS_PASSWORD'),
 | 
			
		||||
                                        db=config.get('REDIS_DATABASE'),
 | 
			
		||||
                                        decode_responses=True)
 | 
			
		||||
InMemoryUssdSession.redis_cache = InMemoryStore.cache
 | 
			
		||||
 | 
			
		||||
# set up celery
 | 
			
		||||
current_app = celery.Celery(__name__)
 | 
			
		||||
 | 
			
		||||
# define celery configs
 | 
			
		||||
broker = config.get('CELERY_BROKER_URL')
 | 
			
		||||
if broker[:4] == 'file':
 | 
			
		||||
    broker_queue = tempfile.mkdtemp()
 | 
			
		||||
    broker_processed = tempfile.mkdtemp()
 | 
			
		||||
    current_app.conf.update({
 | 
			
		||||
        'broker_url': broker,
 | 
			
		||||
        'broker_transport_options': {
 | 
			
		||||
            'data_folder_in': broker_queue,
 | 
			
		||||
            'data_folder_out': broker_queue,
 | 
			
		||||
            'data_folder_processed': broker_processed
 | 
			
		||||
        },
 | 
			
		||||
    })
 | 
			
		||||
    logg.warning(
 | 
			
		||||
        f'celery broker dirs queue i/o {broker_queue} processed {broker_processed}, will NOT be deleted on shutdown')
 | 
			
		||||
else:
 | 
			
		||||
    current_app.conf.update({
 | 
			
		||||
        'broker_url': broker
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
result = config.get('CELERY_RESULT_URL')
 | 
			
		||||
if result[:4] == 'file':
 | 
			
		||||
    result_queue = tempfile.mkdtemp()
 | 
			
		||||
    current_app.conf.update({
 | 
			
		||||
        'result_backend': 'file://{}'.format(result_queue),
 | 
			
		||||
        })
 | 
			
		||||
    logg.warning('celery backend store dir {} created, will NOT be deleted on shutdown'.format(result_queue))
 | 
			
		||||
else:
 | 
			
		||||
    current_app.conf.update({
 | 
			
		||||
        'result_backend': result,
 | 
			
		||||
        })
 | 
			
		||||
import cic_ussd.tasks
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def main():
 | 
			
		||||
    argv = ['worker']
 | 
			
		||||
    if args.vv:
 | 
			
		||||
        argv.append('--loglevel=DEBUG')
 | 
			
		||||
    elif args.v:
 | 
			
		||||
        argv.append('--loglevel=INFO')
 | 
			
		||||
    argv.append('-Q')
 | 
			
		||||
    argv.append(args.q)
 | 
			
		||||
 | 
			
		||||
    current_app.worker_main(argv)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
if __name__ == '__main__':
 | 
			
		||||
    main()
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										0
									
								
								apps/cic-ussd/cic_ussd/session/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										107
									
								
								apps/cic-ussd/cic_ussd/session/ussd_session.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,107 @@
 | 
			
		||||
# standard imports
 | 
			
		||||
import logging
 | 
			
		||||
from typing import Optional
 | 
			
		||||
import json
 | 
			
		||||
 | 
			
		||||
# third party imports
 | 
			
		||||
from redis import Redis
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
logg = logging.getLogger()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UssdSession:
 | 
			
		||||
    """
 | 
			
		||||
    This class defines the USSD session object that is called whenever a user interacts with the system.
 | 
			
		||||
    :cvar redis_cache: The in-memory redis cache.
 | 
			
		||||
    :type redis_cache: Redis
 | 
			
		||||
    """
 | 
			
		||||
    redis_cache: Redis = None
 | 
			
		||||
 | 
			
		||||
    def __init__(self,
 | 
			
		||||
                 external_session_id: str,
 | 
			
		||||
                 service_code: str,
 | 
			
		||||
                 msisdn: str,
 | 
			
		||||
                 user_input: str,
 | 
			
		||||
                 state: str,
 | 
			
		||||
                 session_data: Optional[dict] = None):
 | 
			
		||||
        """
 | 
			
		||||
        This function is called whenever a USSD session object is created and saves the instance to a JSON DB.
 | 
			
		||||
        :param external_session_id: The Africa's Talking session ID.
 | 
			
		||||
        :type external_session_id: str.
 | 
			
		||||
        :param service_code: The USSD service code from which the user used to gain access to the system.
 | 
			
		||||
        :type service_code: str.
 | 
			
		||||
        :param msisdn: The user's phone number.
 | 
			
		||||
        :type msisdn: str.
 | 
			
		||||
        :param user_input: The data or choice the user has made while interacting with the system.
 | 
			
		||||
        :type user_input: str.
 | 
			
		||||
        :param state: The name of the USSD menu that the user was interacting with.
 | 
			
		||||
        :type state: str.
 | 
			
		||||
        :param session_data: Any additional data that was persisted during the user's interaction with the system.
 | 
			
		||||
        :type session_data: dict.
 | 
			
		||||
        """
 | 
			
		||||
        self.external_session_id = external_session_id
 | 
			
		||||
        self.service_code = service_code
 | 
			
		||||
        self.msisdn = msisdn
 | 
			
		||||
        self.user_input = user_input
 | 
			
		||||
        self.state = state
 | 
			
		||||
        self.session_data = session_data
 | 
			
		||||
        session = self.redis_cache.get(external_session_id)
 | 
			
		||||
        if session:
 | 
			
		||||
            session = json.loads(session)
 | 
			
		||||
            self.version = session.get('version') + 1
 | 
			
		||||
        else:
 | 
			
		||||
            self.version = 1
 | 
			
		||||
 | 
			
		||||
        self.session = {
 | 
			
		||||
            'external_session_id': self.external_session_id,
 | 
			
		||||
            'service_code': self.service_code,
 | 
			
		||||
            'msisdn': self.msisdn,
 | 
			
		||||
            'user_input': self.user_input,
 | 
			
		||||
            'state': self.state,
 | 
			
		||||
            'session_data': self.session_data,
 | 
			
		||||
            'version': self.version
 | 
			
		||||
        }
 | 
			
		||||
        self.redis_cache.set(self.external_session_id, json.dumps(self.session))
 | 
			
		||||
        self.redis_cache.persist(self.external_session_id)
 | 
			
		||||
 | 
			
		||||
    def set_data(self, key: str, value: str) -> None:
 | 
			
		||||
        """
 | 
			
		||||
        This function adds or updates data to the session data.
 | 
			
		||||
        :param key: The name used to identify the data.
 | 
			
		||||
        :type key: str.
 | 
			
		||||
        :param value: The actual data to be stored in the session data.
 | 
			
		||||
        :type value: str.
 | 
			
		||||
        """
 | 
			
		||||
        if self.session_data is None:
 | 
			
		||||
            self.session_data = {}
 | 
			
		||||
        self.session_data[key] = value
 | 
			
		||||
        self.redis_cache.set(self.external_session_id, json.dumps(self.session))
 | 
			
		||||
 | 
			
		||||
    def get_data(self, key: str) -> Optional[str]:
 | 
			
		||||
        """
 | 
			
		||||
        This function attempts to fetch data from the session data using the identifier for the specific data.
 | 
			
		||||
        :param key: The name used as the identifier for the specific data.
 | 
			
		||||
        :type key: str.
 | 
			
		||||
        :return: This function returns the queried data if found, else it doesn't return any value.
 | 
			
		||||
        :rtype: str.
 | 
			
		||||
        """
 | 
			
		||||
        if self.session_data is not None:
 | 
			
		||||
            return self.session_data.get(key)
 | 
			
		||||
        else:
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
    def to_json(self):
 | 
			
		||||
        """ This function serializes the in memory ussd session object to a JSON object
 | 
			
		||||
        :return: A JSON object of a ussd session in memory
 | 
			
		||||
        :rtype: dict
 | 
			
		||||
        """
 | 
			
		||||
        return {
 | 
			
		||||
            "external_session_id": self.external_session_id,
 | 
			
		||||
            "service_code": self.service_code,
 | 
			
		||||
            "msisdn": self.msisdn,
 | 
			
		||||
            "user_input": self.user_input,
 | 
			
		||||
            "state": self.state,
 | 
			
		||||
            "session_data": self.session_data,
 | 
			
		||||
            "version": self.version
 | 
			
		||||
        }
 | 
			
		||||
							
								
								
									
										1
									
								
								apps/cic-ussd/cic_ussd/state_machine/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1 @@
 | 
			
		||||
from .state_machine import UssdStateMachine
 | 
			
		||||
							
								
								
									
										16
									
								
								apps/cic-ussd/cic_ussd/state_machine/logic/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,16 @@
 | 
			
		||||
# standard imports
 | 
			
		||||
import os
 | 
			
		||||
import re
 | 
			
		||||
from glob import glob
 | 
			
		||||
from importlib import import_module
 | 
			
		||||
from os.path import basename, dirname, isfile, join
 | 
			
		||||
 | 
			
		||||
# get all modules in the directory
 | 
			
		||||
modules = glob(join(dirname(__file__), "*.py"))
 | 
			
		||||
 | 
			
		||||
for file in modules:
 | 
			
		||||
    # exclude 'init.py'
 | 
			
		||||
    if isfile(file) and not re.match(r'^__', os.path.basename(file)):
 | 
			
		||||
        # strip .py extension
 | 
			
		||||
        file_name = basename(file[:-3])
 | 
			
		||||
        import_module("." + file_name, package=__name__)
 | 
			
		||||
							
								
								
									
										20
									
								
								apps/cic-ussd/cic_ussd/state_machine/logic/balance.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,20 @@
 | 
			
		||||
# standard imports
 | 
			
		||||
import logging
 | 
			
		||||
from typing import Tuple
 | 
			
		||||
 | 
			
		||||
# third-party imports
 | 
			
		||||
 | 
			
		||||
# local imports
 | 
			
		||||
from cic_ussd.db.models.user import User
 | 
			
		||||
 | 
			
		||||
logg = logging.getLogger(__file__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def process_mini_statement_request(state_machine_data: Tuple[str, dict, User]):
 | 
			
		||||
    """This function compiles a brief statement of a user's last three inbound and outbound transactions and send the
 | 
			
		||||
    same as a message on their selected avenue for notification.
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: str
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    logg.debug('This section requires integration with cic-eth. (The last 6 transactions would be sent as an sms.)')
 | 
			
		||||
							
								
								
									
										90
									
								
								apps/cic-ussd/cic_ussd/state_machine/logic/menu.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,90 @@
 | 
			
		||||
"""This module defines functions responsible for interaction with the ussd menu. It takes user input and navigates the
 | 
			
		||||
ussd menu facilitating the return of appropriate menu responses based on said user input.
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# standard imports
 | 
			
		||||
from typing import Tuple
 | 
			
		||||
 | 
			
		||||
# local imports
 | 
			
		||||
from cic_ussd.db.models.user import User
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def menu_one_selected(state_machine_data: Tuple[str, dict, User]) -> bool:
 | 
			
		||||
    """This function checks that user input matches a string with value '1'
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: str
 | 
			
		||||
    :return: A user input's match with '1'
 | 
			
		||||
    :rtype: bool
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    return user_input == '1'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def menu_two_selected(state_machine_data: Tuple[str, dict, User]) -> bool:
 | 
			
		||||
    """This function checks that user input matches a string with value '2'
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: tuple
 | 
			
		||||
    :return: A user input's match with '2'
 | 
			
		||||
    :rtype: bool
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    return user_input == '2'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def menu_three_selected(state_machine_data: Tuple[str, dict, User]) -> bool:
 | 
			
		||||
    """This function checks that user input matches a string with value '3'
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: tuple
 | 
			
		||||
    :return: A user input's match with '3'
 | 
			
		||||
    :rtype: bool
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    return user_input == '3'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def menu_four_selected(state_machine_data: Tuple[str, dict, User]) -> bool:
 | 
			
		||||
    """
 | 
			
		||||
    This function checks that user input matches a string with value '4'
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: tuple
 | 
			
		||||
    :return: A user input's match with '4'
 | 
			
		||||
    :rtype: bool
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    return user_input == '4'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def menu_five_selected(state_machine_data: Tuple[str, dict, User]) -> bool:
 | 
			
		||||
    """
 | 
			
		||||
    This function checks that user input matches a string with value '5'
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: tuple
 | 
			
		||||
    :return: A user input's match with '5'
 | 
			
		||||
    :rtype: bool
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    return user_input == '5'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def menu_zero_zero_selected(state_machine_data: Tuple[str, dict, User]) -> bool:
 | 
			
		||||
    """
 | 
			
		||||
    This function checks that user input matches a string with value '00'
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: tuple
 | 
			
		||||
    :return: A user input's match with '00'
 | 
			
		||||
    :rtype: bool
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    return user_input == '00'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def menu_ninety_nine_selected(state_machine_data: Tuple[str, dict, User]) -> bool:
 | 
			
		||||
    """
 | 
			
		||||
    This function checks that user input matches a string with value '99'
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: tuple
 | 
			
		||||
    :return: A user input's match with '99'
 | 
			
		||||
    :rtype: bool
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    return user_input == '99'
 | 
			
		||||
							
								
								
									
										143
									
								
								apps/cic-ussd/cic_ussd/state_machine/logic/pin.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,143 @@
 | 
			
		||||
"""This module defines functions responsible for creation, validation, reset and any other manipulations on the
 | 
			
		||||
user's pin.
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# standard imports
 | 
			
		||||
import json
 | 
			
		||||
import logging
 | 
			
		||||
import re
 | 
			
		||||
from typing import Tuple
 | 
			
		||||
 | 
			
		||||
# third party imports
 | 
			
		||||
import bcrypt
 | 
			
		||||
 | 
			
		||||
# local imports
 | 
			
		||||
from cic_ussd.db.models.user import AccountStatus, User
 | 
			
		||||
from cic_ussd.encoder import PasswordEncoder, create_password_hash
 | 
			
		||||
from cic_ussd.operations import persist_session_to_db_task, create_or_update_session
 | 
			
		||||
from cic_ussd.redis import InMemoryStore
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
logg = logging.getLogger(__file__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def is_valid_pin(state_machine_data: Tuple[str, dict, User]) -> bool:
 | 
			
		||||
    """This function checks a pin's validity by ensuring it has a length of for characters and the characters are
 | 
			
		||||
    numeric.
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: tuple
 | 
			
		||||
    :return: A pin's validity
 | 
			
		||||
    :rtype: bool
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    pin_is_valid = False
 | 
			
		||||
    matcher = r'^\d{4}$'
 | 
			
		||||
    if re.match(matcher, user_input):
 | 
			
		||||
        pin_is_valid = True
 | 
			
		||||
    return pin_is_valid
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def is_authorized_pin(state_machine_data: Tuple[str, dict, User]) -> bool:
 | 
			
		||||
    """This function checks whether the user input confirming a specific pin matches the initial pin entered.
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: tuple
 | 
			
		||||
    :return: A match between two pin values.
 | 
			
		||||
    :rtype: bool
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    return user.verify_password(password=user_input)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def is_locked_account(state_machine_data: Tuple[str, dict, User]) -> bool:
 | 
			
		||||
    """This function checks whether a user's account is locked due to too many failed attempts.
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: tuple
 | 
			
		||||
    :return: A match between two pin values.
 | 
			
		||||
    :rtype: bool
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    return user.get_account_status() == AccountStatus.LOCKED.name
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def save_initial_pin_to_session_data(state_machine_data: Tuple[str, dict, User]):
 | 
			
		||||
    """This function hashes a pin and stores it in session data.
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: tuple
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
 | 
			
		||||
    # define redis cache entry point
 | 
			
		||||
    cache = InMemoryStore.cache
 | 
			
		||||
 | 
			
		||||
    # get external session id
 | 
			
		||||
    external_session_id = ussd_session.get('external_session_id')
 | 
			
		||||
 | 
			
		||||
    # get corresponding session record
 | 
			
		||||
    in_redis_ussd_session = cache.get(external_session_id)
 | 
			
		||||
    in_redis_ussd_session = json.loads(in_redis_ussd_session)
 | 
			
		||||
 | 
			
		||||
    # set initial pin data
 | 
			
		||||
    initial_pin = create_password_hash(user_input)
 | 
			
		||||
    session_data = {
 | 
			
		||||
        'initial_pin': initial_pin
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    # create new in memory ussd session with current ussd session data
 | 
			
		||||
    create_or_update_session(
 | 
			
		||||
        external_session_id=external_session_id,
 | 
			
		||||
        phone=in_redis_ussd_session.get('msisdn'),
 | 
			
		||||
        service_code=in_redis_ussd_session.get('service_code'),
 | 
			
		||||
        user_input=user_input,
 | 
			
		||||
        current_menu=in_redis_ussd_session.get('state'),
 | 
			
		||||
        session_data=session_data
 | 
			
		||||
    )
 | 
			
		||||
    persist_session_to_db_task(external_session_id=external_session_id, queue='cic-ussd')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def pins_match(state_machine_data: Tuple[str, dict, User]) -> bool:
 | 
			
		||||
    """This function checks whether the user input confirming a specific pin matches the initial pin entered.
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: tuple
 | 
			
		||||
    :return: A match between two pin values.
 | 
			
		||||
    :rtype: bool
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    initial_pin = ussd_session.get('session_data').get('initial_pin')
 | 
			
		||||
    fernet = PasswordEncoder(PasswordEncoder.key)
 | 
			
		||||
    initial_pin = fernet.decrypt(initial_pin.encode())
 | 
			
		||||
    return bcrypt.checkpw(user_input.encode(), initial_pin)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def complete_pin_change(state_machine_data: Tuple[str, dict, User]):
 | 
			
		||||
    """This function persists the user's pin to the database
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: tuple
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    password_hash = ussd_session.get('session_data').get('initial_pin')
 | 
			
		||||
    user.password_hash = password_hash
 | 
			
		||||
    User.session.add(user)
 | 
			
		||||
    User.session.commit()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def is_blocked_pin(state_machine_data: Tuple[str, dict, User]) -> bool:
 | 
			
		||||
    """This function checks whether the user input confirming a specific pin matches the initial pin entered.
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: tuple
 | 
			
		||||
    :return: A match between two pin values.
 | 
			
		||||
    :rtype: bool
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    return user.get_account_status() == AccountStatus.LOCKED.name
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def is_valid_new_pin(state_machine_data: Tuple[str, dict, User]) -> bool:
 | 
			
		||||
    """This function checks whether the user's new pin is a valid pin and that it isn't the same as the old one.
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: tuple
 | 
			
		||||
    :return: A match between two pin values.
 | 
			
		||||
    :rtype: bool
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    is_old_pin = user.verify_password(password=user_input)
 | 
			
		||||
    return is_valid_pin(state_machine_data=state_machine_data) and not is_old_pin
 | 
			
		||||
							
								
								
									
										23
									
								
								apps/cic-ussd/cic_ussd/state_machine/logic/sms.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,23 @@
 | 
			
		||||
# standard imports
 | 
			
		||||
import logging
 | 
			
		||||
from typing import Tuple
 | 
			
		||||
 | 
			
		||||
# local imports
 | 
			
		||||
from cic_ussd.db.models.user import User
 | 
			
		||||
 | 
			
		||||
logg = logging.getLogger()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def send_terms_to_user_if_required(state_machine_data: Tuple[str, dict, User]):
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    logg.debug('Requires integration to cic-notify.')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def process_mini_statement_request(state_machine_data: Tuple[str, dict, User]):
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    logg.debug('Requires integration to cic-notify.')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def upsell_unregistered_recipient(state_machine_data: Tuple[str, dict, User]):
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    logg.debug('Requires integration to cic-notify.')
 | 
			
		||||
							
								
								
									
										119
									
								
								apps/cic-ussd/cic_ussd/state_machine/logic/transaction.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,119 @@
 | 
			
		||||
# standard imports
 | 
			
		||||
import logging
 | 
			
		||||
from typing import Tuple
 | 
			
		||||
 | 
			
		||||
# third party imports
 | 
			
		||||
 | 
			
		||||
# local imports
 | 
			
		||||
from cic_ussd.accounts import BalanceManager
 | 
			
		||||
from cic_ussd.db.models.user import AccountStatus, User
 | 
			
		||||
from cic_ussd.operations import get_user_by_phone_number, save_to_in_memory_ussd_session_data
 | 
			
		||||
from cic_ussd.state_machine.state_machine import UssdStateMachine
 | 
			
		||||
from cic_ussd.transactions import OutgoingTransactionProcessor
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
logg = logging.getLogger(__file__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def is_valid_recipient(state_machine_data: Tuple[str, dict, User]) -> bool:
 | 
			
		||||
    """This function checks that a user exists, is not the initiator of the transaction, has an active account status
 | 
			
		||||
    and is authorized to perform  standard transactions.
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: tuple
 | 
			
		||||
    :return: A user's validity
 | 
			
		||||
    :rtype: bool
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    recipient = get_user_by_phone_number(phone_number=user_input)
 | 
			
		||||
    is_not_initiator = user_input != user.phone_number
 | 
			
		||||
    has_active_account_status = user.get_account_status() == AccountStatus.ACTIVE.name
 | 
			
		||||
    logg.debug('This section requires implementation of checks for user roles and authorization status of an account.')
 | 
			
		||||
    return is_not_initiator and has_active_account_status
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def is_valid_token_agent(state_machine_data: Tuple[str, dict, User]) -> bool:
 | 
			
		||||
    """This function checks that a user exists, is not the initiator of the transaction, has an active account status
 | 
			
		||||
    and is authorized to perform exchange transactions.
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: tuple
 | 
			
		||||
    :return: A user's validity
 | 
			
		||||
    :rtype: bool
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    # is_token_agent = AccountRole.TOKEN_AGENT.value in user.get_user_roles()
 | 
			
		||||
    logg.debug('This section requires implementation of user roles and authorization to facilitate exchanges.')
 | 
			
		||||
    return is_valid_recipient(state_machine_data=state_machine_data)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def is_valid_transaction_amount(state_machine_data: Tuple[str, dict, User]) -> bool:
 | 
			
		||||
    """This function checks that the transaction amount provided is valid as per the criteria for the transaction
 | 
			
		||||
    being attempted.
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: tuple
 | 
			
		||||
    :return: A transaction amount's validity
 | 
			
		||||
    :rtype: bool
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    try:
 | 
			
		||||
        return int(user_input) > 0
 | 
			
		||||
    except ValueError:
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def has_sufficient_balance(state_machine_data: Tuple[str, dict, User]) -> bool:
 | 
			
		||||
    """This function checks that the transaction amount provided is valid as per the criteria for the transaction
 | 
			
		||||
    being attempted.
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: tuple
 | 
			
		||||
    :return: An account balance's validity
 | 
			
		||||
    :rtype: bool
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    balance_manager = BalanceManager(address=user.blockchain_address,
 | 
			
		||||
                                     chain_str=UssdStateMachine.chain_str,
 | 
			
		||||
                                     token_symbol='SRF')
 | 
			
		||||
    balance = balance_manager.get_operational_balance()
 | 
			
		||||
    return int(user_input) <= balance
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def save_recipient_phone_to_session_data(state_machine_data: Tuple[str, dict, User]):
 | 
			
		||||
    """This function saves the phone number corresponding the intended recipients blockchain account.
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: str
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    session_data = {
 | 
			
		||||
        'recipient_phone_number': user_input
 | 
			
		||||
    }
 | 
			
		||||
    save_to_in_memory_ussd_session_data(queue='cic-ussd', session_data=session_data, ussd_session=ussd_session)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def save_transaction_amount_to_session_data(state_machine_data: Tuple[str, dict, User]):
 | 
			
		||||
    """This function saves the phone number corresponding the intended recipients blockchain account.
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: str
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    session_data = {
 | 
			
		||||
        'transaction_amount': user_input
 | 
			
		||||
    }
 | 
			
		||||
    save_to_in_memory_ussd_session_data(queue='cic-ussd', session_data=session_data, ussd_session=ussd_session)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def process_transaction_request(state_machine_data: Tuple[str, dict, User]):
 | 
			
		||||
    """This function saves the phone number corresponding the intended recipients blockchain account.
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: str
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
 | 
			
		||||
    # get user from phone number
 | 
			
		||||
    recipient_phone_number = ussd_session.get('session_data').get('recipient_phone_number')
 | 
			
		||||
    recipient = get_user_by_phone_number(phone_number=recipient_phone_number)
 | 
			
		||||
    to_address = recipient.blockchain_address
 | 
			
		||||
    from_address = user.blockchain_address
 | 
			
		||||
    amount = int(ussd_session.get('session_data').get('transaction_amount'))
 | 
			
		||||
    outgoing_tx_processor = OutgoingTransactionProcessor(chain_str=UssdStateMachine.chain_str,
 | 
			
		||||
                                                         from_address=from_address,
 | 
			
		||||
                                                         to_address=to_address)
 | 
			
		||||
    outgoing_tx_processor.process_outgoing_transfer_transaction(amount=amount)
 | 
			
		||||
							
								
								
									
										89
									
								
								apps/cic-ussd/cic_ussd/state_machine/logic/user.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,89 @@
 | 
			
		||||
# standard imports
 | 
			
		||||
import logging
 | 
			
		||||
from typing import Tuple
 | 
			
		||||
 | 
			
		||||
# local imports
 | 
			
		||||
from cic_ussd.db.models.user import User
 | 
			
		||||
from cic_ussd.operations import save_to_in_memory_ussd_session_data
 | 
			
		||||
 | 
			
		||||
logg = logging.getLogger(__file__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def change_preferred_language_to_en(state_machine_data: Tuple[str, dict, User]):
 | 
			
		||||
    """This function changes the user's preferred language to english.
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: tuple
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    user.preferred_language = 'en'
 | 
			
		||||
    User.session.add(user)
 | 
			
		||||
    User.session.commit()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def change_preferred_language_to_sw(state_machine_data: Tuple[str, dict, User]):
 | 
			
		||||
    """This function changes the user's preferred language to swahili.
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: tuple
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    user.preferred_language = 'sw'
 | 
			
		||||
    User.session.add(user)
 | 
			
		||||
    User.session.commit()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def update_account_status_to_active(state_machine_data: Tuple[str, dict, User]):
 | 
			
		||||
    """This function sets user's account to active.
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: tuple
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    user.activate_account()
 | 
			
		||||
    User.session.add(user)
 | 
			
		||||
    User.session.commit()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def save_profile_attribute_to_session_data(state_machine_data: Tuple[str, dict, User]):
 | 
			
		||||
    """This function saves first name data to the ussd session in the redis cache.
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: tuple
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
 | 
			
		||||
    # get current menu
 | 
			
		||||
    current_state = ussd_session.get('state')
 | 
			
		||||
 | 
			
		||||
    # define session data key from current state
 | 
			
		||||
    key = ''
 | 
			
		||||
    if 'first_name' in current_state:
 | 
			
		||||
        key = 'first_name'
 | 
			
		||||
    elif 'last_name' in current_state:
 | 
			
		||||
        key = 'last_name'
 | 
			
		||||
    elif 'gender' in current_state:
 | 
			
		||||
        key = 'gender'
 | 
			
		||||
    elif 'location' in current_state:
 | 
			
		||||
        key = 'location'
 | 
			
		||||
    elif 'business_profile' in current_state:
 | 
			
		||||
        key = 'business_profile'
 | 
			
		||||
 | 
			
		||||
    # check if there is existing session data
 | 
			
		||||
    if ussd_session.get('session_data'):
 | 
			
		||||
        session_data = ussd_session.get('session_data')
 | 
			
		||||
        session_data[key] = user_input
 | 
			
		||||
    else:
 | 
			
		||||
        session_data = {
 | 
			
		||||
            key: user_input
 | 
			
		||||
        }
 | 
			
		||||
    save_to_in_memory_ussd_session_data(queue='cic-ussd', session_data=session_data, ussd_session=ussd_session)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def persist_profile_data(state_machine_data: Tuple[str, dict, User]):
 | 
			
		||||
    """This function persists elements of the user profile stored in session data
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: tuple
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
 | 
			
		||||
    # get session data
 | 
			
		||||
    profile_data = ussd_session.get('session_data')
 | 
			
		||||
    logg.debug('This section requires implementation of user metadata.')
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										68
									
								
								apps/cic-ussd/cic_ussd/state_machine/logic/validator.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,68 @@
 | 
			
		||||
# standard imports
 | 
			
		||||
import logging
 | 
			
		||||
import re
 | 
			
		||||
from typing import Tuple
 | 
			
		||||
 | 
			
		||||
# local imports
 | 
			
		||||
from cic_ussd.db.models.user import User
 | 
			
		||||
 | 
			
		||||
logg = logging.getLogger()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def has_complete_profile_data(state_machine_data: Tuple[str, dict, User]):
 | 
			
		||||
    """This function checks whether the attributes of the user's metadata constituting a profile are filled out.
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: str
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    logg.debug('This section requires implementation of user metadata.')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def has_empty_username_data(state_machine_data: Tuple[str, dict, User]):
 | 
			
		||||
    """This function checks whether the aspects of the user's name metadata is filled out.
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: str
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    logg.debug('This section requires implementation of user metadata.')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def has_empty_gender_data(state_machine_data: Tuple[str, dict, User]):
 | 
			
		||||
    """This function checks whether the aspects of the user's gender metadata is filled out.
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: str
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    logg.debug('This section requires implementation of user metadata.')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def has_empty_location_data(state_machine_data: Tuple[str, dict, User]):
 | 
			
		||||
    """This function checks whether the aspects of the user's location metadata is filled out.
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: str
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    logg.debug('This section requires implementation of user metadata.')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def has_empty_business_profile_data(state_machine_data: Tuple[str, dict, User]):
 | 
			
		||||
    """This function checks whether the aspects of the user's business profile metadata is filled out.
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: str
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    logg.debug('This section requires implementation of user metadata.')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def is_valid_name(state_machine_data: Tuple[str, dict, User]):
 | 
			
		||||
    """This function checks that a user provided name is valid
 | 
			
		||||
    :param state_machine_data: A tuple containing user input, a ussd session and user object.
 | 
			
		||||
    :type state_machine_data: str
 | 
			
		||||
    """
 | 
			
		||||
    user_input, ussd_session, user = state_machine_data
 | 
			
		||||
    name_matcher = "^[a-zA-Z]+$"
 | 
			
		||||
    valid_name = re.match(name_matcher, user_input)
 | 
			
		||||
    if valid_name:
 | 
			
		||||
        return True
 | 
			
		||||
    else:
 | 
			
		||||
        return False
 | 
			
		||||
							
								
								
									
										42
									
								
								apps/cic-ussd/cic_ussd/state_machine/state_machine.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,42 @@
 | 
			
		||||
# standard imports
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
# third party imports
 | 
			
		||||
from transitions import Machine
 | 
			
		||||
 | 
			
		||||
# local imports
 | 
			
		||||
 | 
			
		||||
logg = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UssdStateMachine(Machine):
 | 
			
		||||
    """This class describes a finite state machine responsible for maintaining all the states that describe the ussd
 | 
			
		||||
    menu  as well as providing a means for navigating through these states based on different user inputs.
 | 
			
		||||
    It defines different helper functions that co-ordinate with the stakeholder components of the ussd menu: i.e  the
 | 
			
		||||
    User, UssdSession, UssdMenu to facilitate user interaction with ussd menu.
 | 
			
		||||
 | 
			
		||||
    :cvar chain_str: The chain name and network id.
 | 
			
		||||
    :type chain_str: str
 | 
			
		||||
    :cvar states: A list of pre-defined states.
 | 
			
		||||
    :type states: list
 | 
			
		||||
    :cvar transitions: A list of pre-defined transitions.
 | 
			
		||||
    :type transitions: list
 | 
			
		||||
    """
 | 
			
		||||
    chain_str = None
 | 
			
		||||
    states = []
 | 
			
		||||
    transitions = []
 | 
			
		||||
 | 
			
		||||
    def __repr__(self):
 | 
			
		||||
        return f'<KenyaUssdStateMachine: {self.state}>'
 | 
			
		||||
 | 
			
		||||
    def __init__(self, ussd_session: dict):
 | 
			
		||||
        """
 | 
			
		||||
        :param ussd_session: A Ussd session object that contains contextual data that informs the state machine's state
 | 
			
		||||
        changes.
 | 
			
		||||
        :type ussd_session: dict
 | 
			
		||||
        """
 | 
			
		||||
        self.ussd_session = ussd_session
 | 
			
		||||
        super(UssdStateMachine, self).__init__(initial=ussd_session.get('state'),
 | 
			
		||||
                                               model=self,
 | 
			
		||||
                                               states=self.states,
 | 
			
		||||
                                               transitions=self.transitions)
 | 
			
		||||
							
								
								
									
										15
									
								
								apps/cic-ussd/cic_ussd/tasks/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,15 @@
 | 
			
		||||
# standard import
 | 
			
		||||
import os
 | 
			
		||||
import logging
 | 
			
		||||
import urllib
 | 
			
		||||
import json
 | 
			
		||||
 | 
			
		||||
# third-party imports
 | 
			
		||||
# this must be included for the package to be recognized as a tasks package
 | 
			
		||||
import celery
 | 
			
		||||
 | 
			
		||||
celery_app = celery.current_app
 | 
			
		||||
# export external celery task modules
 | 
			
		||||
from .foo import log_it_plz
 | 
			
		||||
from .ussd import persist_session_to_db
 | 
			
		||||
from .callback_handler import process_account_creation_callback
 | 
			
		||||
							
								
								
									
										114
									
								
								apps/cic-ussd/cic_ussd/tasks/callback_handler.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,114 @@
 | 
			
		||||
# standard imports
 | 
			
		||||
import json
 | 
			
		||||
import logging
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
 | 
			
		||||
# third-party imports
 | 
			
		||||
import celery
 | 
			
		||||
 | 
			
		||||
# local imports
 | 
			
		||||
from cic_ussd.db.models.base import SessionBase
 | 
			
		||||
from cic_ussd.db.models.user import User
 | 
			
		||||
from cic_ussd.error import ActionDataNotFoundError
 | 
			
		||||
from cic_ussd.redis import InMemoryStore
 | 
			
		||||
from cic_ussd.transactions import IncomingTransactionProcessor
 | 
			
		||||
 | 
			
		||||
logg = logging.getLogger(__file__)
 | 
			
		||||
celery_app = celery.current_app
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@celery_app.task(bind=True)
 | 
			
		||||
def process_account_creation_callback(self, result: str, url: str, status_code: int):
 | 
			
		||||
    """This function defines a task that creates a user and
 | 
			
		||||
    :param result: The blockchain address for the created account
 | 
			
		||||
    :type result: str
 | 
			
		||||
    :param url: URL provided to callback task in cic-eth should http be used for callback.
 | 
			
		||||
    :type url: str
 | 
			
		||||
    :param status_code: The status of the task to create an account
 | 
			
		||||
    :type status_code: int
 | 
			
		||||
    """
 | 
			
		||||
    session = SessionBase.create_session()
 | 
			
		||||
    cache = InMemoryStore.cache
 | 
			
		||||
    task_id = self.request.root_id
 | 
			
		||||
 | 
			
		||||
    # get account creation status
 | 
			
		||||
    account_creation_data = cache.get(task_id)
 | 
			
		||||
 | 
			
		||||
    # check status
 | 
			
		||||
    if account_creation_data:
 | 
			
		||||
        account_creation_data = json.loads(account_creation_data)
 | 
			
		||||
        if status_code == 0:
 | 
			
		||||
            # update redis data
 | 
			
		||||
            account_creation_data['status'] = 'CREATED'
 | 
			
		||||
            cache.set(name=task_id, value=json.dumps(account_creation_data))
 | 
			
		||||
            cache.persist(task_id)
 | 
			
		||||
 | 
			
		||||
            phone_number = account_creation_data.get('phone_number')
 | 
			
		||||
 | 
			
		||||
            # create user
 | 
			
		||||
            user = User(blockchain_address=result, phone_number=phone_number)
 | 
			
		||||
            session.add(user)
 | 
			
		||||
            session.commit()
 | 
			
		||||
 | 
			
		||||
            # expire cache
 | 
			
		||||
            cache.expire(task_id, timedelta(seconds=30))
 | 
			
		||||
            session.close()
 | 
			
		||||
 | 
			
		||||
        else:
 | 
			
		||||
            cache.expire(task_id, timedelta(seconds=30))
 | 
			
		||||
            session.close()
 | 
			
		||||
 | 
			
		||||
    else:
 | 
			
		||||
        session.close()
 | 
			
		||||
        raise ActionDataNotFoundError(f'Account creation task: {task_id}, returned unexpected response: {status_code}')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@celery_app.task
 | 
			
		||||
def process_incoming_transfer_callback(result: dict, param: str, status_code: int):
 | 
			
		||||
    logg.debug(f'PARAM: {param}, RESULT: {result}, STATUS_CODE: {status_code}')
 | 
			
		||||
    session = SessionBase.create_session()
 | 
			
		||||
    if result and status_code == 0:
 | 
			
		||||
 | 
			
		||||
        # collect result data
 | 
			
		||||
        recipient_blockchain_address = result.get('recipient')
 | 
			
		||||
        sender_blockchain_address = result.get('sender')
 | 
			
		||||
        token_symbol = result.get('token_symbol')
 | 
			
		||||
        value = result.get('destination_value')
 | 
			
		||||
 | 
			
		||||
        # try to find users in system
 | 
			
		||||
        recipient_user = session.query(User).filter_by(blockchain_address=recipient_blockchain_address).first()
 | 
			
		||||
        sender_user = session.query(User).filter_by(blockchain_address=sender_blockchain_address).first()
 | 
			
		||||
 | 
			
		||||
        # check whether recipient is in the system
 | 
			
		||||
        if not recipient_user:
 | 
			
		||||
            session.close()
 | 
			
		||||
            raise ValueError(
 | 
			
		||||
                f'Tx for recipient: {recipient_blockchain_address} was received but has no matching user in the system.'
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        # process incoming transactions
 | 
			
		||||
        incoming_tx_processor = IncomingTransactionProcessor(phone_number=recipient_user.phone_number,
 | 
			
		||||
                                                             preferred_language=recipient_user.preferred_language,
 | 
			
		||||
                                                             token_symbol=token_symbol,
 | 
			
		||||
                                                             value=value)
 | 
			
		||||
 | 
			
		||||
        if param == 'tokengift':
 | 
			
		||||
            logg.debug('Name information would require integration with cic meta.')
 | 
			
		||||
            incoming_tx_processor.process_token_gift_incoming_transactions(first_name="")
 | 
			
		||||
        elif param == 'transfer':
 | 
			
		||||
            logg.debug('Name information would require integration with cic meta.')
 | 
			
		||||
            if sender_user:
 | 
			
		||||
                sender_information = f'{sender_user.phone_number}, {""}, {""}'
 | 
			
		||||
                incoming_tx_processor.process_transfer_incoming_transaction(sender_information=sender_information)
 | 
			
		||||
            else:
 | 
			
		||||
                logg.warning(
 | 
			
		||||
                    f'Tx with sender: {sender_blockchain_address} was received but has no matching user in the system.'
 | 
			
		||||
                )
 | 
			
		||||
                incoming_tx_processor.process_transfer_incoming_transaction(
 | 
			
		||||
                    sender_information=sender_blockchain_address)
 | 
			
		||||
        else:
 | 
			
		||||
            session.close()
 | 
			
		||||
            raise ValueError(f'Unexpected transaction param: {param}.')
 | 
			
		||||
    else:
 | 
			
		||||
        session.close()
 | 
			
		||||
        raise ValueError(f'Unexpected status code: {status_code}.')
 | 
			
		||||
							
								
								
									
										11
									
								
								apps/cic-ussd/cic_ussd/tasks/foo.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,11 @@
 | 
			
		||||
# third-party imports
 | 
			
		||||
import celery
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
celery_app = celery.current_app
 | 
			
		||||
logg = logging.getLogger()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@celery_app.task()
 | 
			
		||||
def log_it_plz(whatever):
 | 
			
		||||
    logg.info('logged it plz: {}'.format(whatever)) 
 | 
			
		||||
							
								
								
									
										72
									
								
								apps/cic-ussd/cic_ussd/tasks/ussd.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,72 @@
 | 
			
		||||
# standard imports
 | 
			
		||||
import json
 | 
			
		||||
from datetime import timedelta
 | 
			
		||||
 | 
			
		||||
# third party imports
 | 
			
		||||
import celery
 | 
			
		||||
from celery.utils.log import get_logger
 | 
			
		||||
 | 
			
		||||
# local imports
 | 
			
		||||
from cic_ussd.db.models.base import SessionBase
 | 
			
		||||
from cic_ussd.db.models.ussd_session import UssdSession
 | 
			
		||||
from cic_ussd.error import SessionNotFoundError
 | 
			
		||||
from cic_ussd.session.ussd_session import UssdSession as InMemoryUssdSession
 | 
			
		||||
 | 
			
		||||
celery_app = celery.current_app
 | 
			
		||||
logg = get_logger(__file__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@celery_app.task
 | 
			
		||||
def persist_session_to_db(external_session_id: str):
 | 
			
		||||
    """
 | 
			
		||||
    This task initiates the saving of the session object to the database and it's removal from the in-memory storage.
 | 
			
		||||
    :param external_session_id: The session id of the session to be saved.
 | 
			
		||||
    :type external_session_id: str.
 | 
			
		||||
    :return: The representation of the newly created database object or en error message if session is not found.
 | 
			
		||||
    :rtype: str.
 | 
			
		||||
    :raises SessionNotFoundError: If the session object is not found in memory.
 | 
			
		||||
    :raises VersionTooLowError: If the session's version doesn't match the latest version.
 | 
			
		||||
    """
 | 
			
		||||
    # create session
 | 
			
		||||
    session = SessionBase.create_session()
 | 
			
		||||
 | 
			
		||||
    # get ussd session in redis cache
 | 
			
		||||
    in_memory_session = InMemoryUssdSession.redis_cache.get(external_session_id)
 | 
			
		||||
 | 
			
		||||
    # process persistence to db
 | 
			
		||||
    if in_memory_session:
 | 
			
		||||
        in_memory_session = json.loads(in_memory_session)
 | 
			
		||||
        in_db_ussd_session = session.query(UssdSession).filter_by(external_session_id=external_session_id).first()
 | 
			
		||||
        if in_db_ussd_session:
 | 
			
		||||
            in_db_ussd_session.update(
 | 
			
		||||
                session=session,
 | 
			
		||||
                user_input=in_memory_session.get('user_input'),
 | 
			
		||||
                state=in_memory_session.get('state'),
 | 
			
		||||
                version=in_memory_session.get('version'),
 | 
			
		||||
            )
 | 
			
		||||
        else:
 | 
			
		||||
            in_db_ussd_session = UssdSession(
 | 
			
		||||
                external_session_id=external_session_id,
 | 
			
		||||
                service_code=in_memory_session.get('service_code'),
 | 
			
		||||
                msisdn=in_memory_session.get('msisdn'),
 | 
			
		||||
                user_input=in_memory_session.get('user_input'),
 | 
			
		||||
                state=in_memory_session.get('state'),
 | 
			
		||||
                version=in_memory_session.get('version'),
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        # handle the updating of session data for persistence to db
 | 
			
		||||
        session_data = in_memory_session.get('session_data')
 | 
			
		||||
 | 
			
		||||
        if session_data:
 | 
			
		||||
            for key, value in session_data.items():
 | 
			
		||||
                in_db_ussd_session.set_data(key=key, value=value, session=session)
 | 
			
		||||
 | 
			
		||||
        session.add(in_db_ussd_session)
 | 
			
		||||
        InMemoryUssdSession.redis_cache.expire(external_session_id, timedelta(minutes=1))
 | 
			
		||||
    else:
 | 
			
		||||
        session.close()
 | 
			
		||||
        raise SessionNotFoundError('Session does not exist!')
 | 
			
		||||
 | 
			
		||||
    session.commit()
 | 
			
		||||
    session.close()
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										132
									
								
								apps/cic-ussd/cic_ussd/transactions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,132 @@
 | 
			
		||||
# standard imports
 | 
			
		||||
import decimal
 | 
			
		||||
import logging
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
 | 
			
		||||
# third-party imports
 | 
			
		||||
from cic_eth.api import Api
 | 
			
		||||
 | 
			
		||||
# local imports
 | 
			
		||||
from cic_ussd.notifications import Notifier
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
logg = logging.getLogger()
 | 
			
		||||
notifier = Notifier()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def truncate(value: float, decimals: int):
 | 
			
		||||
    """This function truncates a value to a specified number of decimals places.
 | 
			
		||||
    :param value: The value to be truncated.
 | 
			
		||||
    :type value: float
 | 
			
		||||
    :param decimals: The number of decimals for the value to be truncated to
 | 
			
		||||
    :type decimals: int
 | 
			
		||||
    :return: The truncated value.
 | 
			
		||||
    :rtype: int
 | 
			
		||||
    """
 | 
			
		||||
    decimal.getcontext().rounding = decimal.ROUND_DOWN
 | 
			
		||||
    contextualized_value = decimal.Decimal(value)
 | 
			
		||||
    return round(contextualized_value, decimals)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def from_wei(value: int) -> float:
 | 
			
		||||
    """This function converts values in Wei to a token in the cic network.
 | 
			
		||||
    :param value: Value in Wei
 | 
			
		||||
    :type value: int
 | 
			
		||||
    :return: SRF equivalent of value in Wei
 | 
			
		||||
    :rtype: float
 | 
			
		||||
    """
 | 
			
		||||
    value = float(value) / 1e+18
 | 
			
		||||
    return truncate(value=value, decimals=2)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def to_wei(value: int) -> int:
 | 
			
		||||
    """This functions converts values from a token in the cic network to Wei.
 | 
			
		||||
    :param value: Value in SRF
 | 
			
		||||
    :type value: int
 | 
			
		||||
    :return: Wei equivalent of value in SRF
 | 
			
		||||
    :rtype: int
 | 
			
		||||
    """
 | 
			
		||||
    return int(value * 1e+18)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class IncomingTransactionProcessor:
 | 
			
		||||
 | 
			
		||||
    def __init__(self, phone_number: str, preferred_language: str, token_symbol: str, value: int):
 | 
			
		||||
        """
 | 
			
		||||
        :param phone_number: The recipient's phone number.
 | 
			
		||||
        :type phone_number: str
 | 
			
		||||
        :param preferred_language: The user's preferred language.
 | 
			
		||||
        :type preferred_language: str
 | 
			
		||||
        :param token_symbol: The symbol for the token the recipient receives.
 | 
			
		||||
        :type token_symbol: str
 | 
			
		||||
        :param value: The amount of tokens received in the transactions.
 | 
			
		||||
        :type value: int
 | 
			
		||||
        """
 | 
			
		||||
        self.phone_number = phone_number
 | 
			
		||||
        self.preferred_language = preferred_language
 | 
			
		||||
        self.token_symbol = token_symbol
 | 
			
		||||
        self.value = value
 | 
			
		||||
 | 
			
		||||
    def process_token_gift_incoming_transactions(self, first_name: str):
 | 
			
		||||
        """This function processes incoming transactions with a "tokengift" param, it collects all appropriate data to
 | 
			
		||||
        send out notifications to users when their accounts are successfully created.
 | 
			
		||||
        :param first_name: The first name of the recipient of the token gift transaction.
 | 
			
		||||
        :type first_name: str
 | 
			
		||||
 | 
			
		||||
        """
 | 
			
		||||
        balance = from_wei(value=self.value)
 | 
			
		||||
        key = 'sms.account_successfully_created'
 | 
			
		||||
        notifier.send_sms_notification(key=key,
 | 
			
		||||
                                       phone_number=self.phone_number,
 | 
			
		||||
                                       preferred_language=self.preferred_language,
 | 
			
		||||
                                       balance=balance,
 | 
			
		||||
                                       first_name=first_name,
 | 
			
		||||
                                       token_symbol=self.token_symbol)
 | 
			
		||||
 | 
			
		||||
    def process_transfer_incoming_transaction(self, sender_information: str):
 | 
			
		||||
        """This function processes incoming transactions with the "transfer" param and issues notifications to users
 | 
			
		||||
        about reception of funds into their accounts.
 | 
			
		||||
        :param sender_information: A string with a user's full name and phone number.
 | 
			
		||||
        :type sender_information: str
 | 
			
		||||
        """
 | 
			
		||||
        key = 'sms.received_tokens'
 | 
			
		||||
        amount = from_wei(value=self.value)
 | 
			
		||||
        timestamp = datetime.now().strftime('%d-%m-%y, %H:%M %p')
 | 
			
		||||
 | 
			
		||||
        logg.debug('Balance requires implementation of cic-eth integration with balance.')
 | 
			
		||||
        notifier.send_sms_notification(key=key,
 | 
			
		||||
                                       phone_number=self.phone_number,
 | 
			
		||||
                                       preferred_language=self.preferred_language,
 | 
			
		||||
                                       amount=amount,
 | 
			
		||||
                                       token_symbol=self.token_symbol,
 | 
			
		||||
                                       tx_sender_information=sender_information,
 | 
			
		||||
                                       timestamp=timestamp,
 | 
			
		||||
                                       balance='')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class OutgoingTransactionProcessor:
 | 
			
		||||
 | 
			
		||||
    def __init__(self, chain_str: str, from_address: str, to_address: str):
 | 
			
		||||
        """
 | 
			
		||||
        :param chain_str: The chain name and network id.
 | 
			
		||||
        :type chain_str: str
 | 
			
		||||
        :param from_address: Ethereum address of the sender
 | 
			
		||||
        :type from_address: str, 0x-hex
 | 
			
		||||
        :param to_address: Ethereum address of the recipient
 | 
			
		||||
        :type to_address: str, 0x-hex
 | 
			
		||||
        """
 | 
			
		||||
        self.cic_eth_api = Api(chain_str=chain_str)
 | 
			
		||||
        self.from_address = from_address
 | 
			
		||||
        self.to_address = to_address
 | 
			
		||||
 | 
			
		||||
    def process_outgoing_transfer_transaction(self, amount: int, token_symbol='SRF'):
 | 
			
		||||
        """This function initiates standard transfers between one account to another
 | 
			
		||||
        :param amount: The amount of tokens to be sent
 | 
			
		||||
        :type amount: int
 | 
			
		||||
        :param token_symbol: ERC20 token symbol of token to send
 | 
			
		||||
        :type token_symbol: str
 | 
			
		||||
        """
 | 
			
		||||
        self.cic_eth_api.transfer(from_address=self.from_address,
 | 
			
		||||
                                  to_address=self.to_address,
 | 
			
		||||
                                  value=to_wei(value=amount),
 | 
			
		||||
                                  token_symbol=token_symbol)
 | 
			
		||||
							
								
								
									
										22
									
								
								apps/cic-ussd/cic_ussd/translation.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,22 @@
 | 
			
		||||
"""
 | 
			
		||||
This module is responsible for translation of ussd menu text based on a user's set preferred language.
 | 
			
		||||
"""
 | 
			
		||||
import i18n
 | 
			
		||||
from typing import Optional
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def translation_for(key: str, preferred_language: Optional[str] = None, **kwargs) -> str:
 | 
			
		||||
    """
 | 
			
		||||
    Translates text mapped to a specific YAML key into the user's set preferred language.
 | 
			
		||||
    :param preferred_language: User's preferred language in which to view the ussd menu.
 | 
			
		||||
    :type preferred_language str
 | 
			
		||||
    :param key: Key to a specific YAML test entry
 | 
			
		||||
    :type key: str
 | 
			
		||||
    :param kwargs: Dynamic values to be interpolated into the YAML texts for specific keys
 | 
			
		||||
    :type kwargs: any
 | 
			
		||||
    :return: Appropriately translated text for corresponding provided key
 | 
			
		||||
    :rtype: str
 | 
			
		||||
    """
 | 
			
		||||
    if preferred_language:
 | 
			
		||||
        i18n.set('locale', preferred_language)
 | 
			
		||||
    return i18n.t(key, **kwargs)
 | 
			
		||||
							
								
								
									
										128
									
								
								apps/cic-ussd/cic_ussd/validator.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,128 @@
 | 
			
		||||
# standard imports
 | 
			
		||||
import logging
 | 
			
		||||
import re
 | 
			
		||||
 | 
			
		||||
# third-party imports
 | 
			
		||||
from confini import Config
 | 
			
		||||
 | 
			
		||||
# local imports
 | 
			
		||||
from cic_ussd.db.models.user import User
 | 
			
		||||
 | 
			
		||||
logg = logging.getLogger(__file__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def check_ip(config: Config, env: dict):
 | 
			
		||||
    """Check whether request origin IP is whitelisted
 | 
			
		||||
    :param config: A dictionary object containing configuration values
 | 
			
		||||
    :type config: Config
 | 
			
		||||
    :param env: Object containing server and request information
 | 
			
		||||
    :type env: dict
 | 
			
		||||
    :return: Request IP validity
 | 
			
		||||
    :rtype: boolean
 | 
			
		||||
    """
 | 
			
		||||
    return env.get('REMOTE_ADDR') == config.get('APP_ALLOWED_IP')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def check_request_content_length(config: Config, env: dict):
 | 
			
		||||
    """Checks whether the request's content is less than or equal to the system's set maximum content length
 | 
			
		||||
    :param config: A dictionary object containing configuration values
 | 
			
		||||
    :type config: Config
 | 
			
		||||
    :param env: Object containing server and request information
 | 
			
		||||
    :type env: dict
 | 
			
		||||
    :return: Content length validity
 | 
			
		||||
    :rtype: boolean
 | 
			
		||||
    """
 | 
			
		||||
    return env.get('CONTENT_LENGTH') is not None and int(env.get('CONTENT_LENGTH')) <= int(
 | 
			
		||||
        config.get('APP_MAX_BODY_LENGTH'))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def check_service_code(code: str, config: Config):
 | 
			
		||||
    """Checks whether provided code matches expected service code
 | 
			
		||||
    :param config: A dictionary object containing configuration values
 | 
			
		||||
    :type config: Config
 | 
			
		||||
    :param code: Service code passed over request
 | 
			
		||||
    :type code: str
 | 
			
		||||
 | 
			
		||||
    :return: Service code validity
 | 
			
		||||
    :rtype: boolean
 | 
			
		||||
    """
 | 
			
		||||
    return code == config.get('APP_SERVICE_CODE')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def check_known_user(phone: str):
 | 
			
		||||
    """
 | 
			
		||||
    This method attempts to ascertain whether the user already exists and is known to the system.
 | 
			
		||||
    It sends a get request to the platform application and attempts to retrieve the user's data which it persists in
 | 
			
		||||
    memory.
 | 
			
		||||
    :param phone: A valid phone number
 | 
			
		||||
    :type phone: str
 | 
			
		||||
    :return: Is known phone number
 | 
			
		||||
    :rtype: boolean
 | 
			
		||||
    """
 | 
			
		||||
    user = User.session.query(User).filter_by(phone_number=phone).first()
 | 
			
		||||
    return user is not None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def check_phone_number(number: str):
 | 
			
		||||
    """
 | 
			
		||||
    Checks whether phone number is present
 | 
			
		||||
    :param number: A valid phone number
 | 
			
		||||
    :type number: str
 | 
			
		||||
    :return: Phone number presence
 | 
			
		||||
    :rtype: boolean
 | 
			
		||||
    """
 | 
			
		||||
    return number is not None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def check_request_method(env: dict):
 | 
			
		||||
    """
 | 
			
		||||
    Checks whether request method is POST
 | 
			
		||||
    :param env: Object containing server and request information
 | 
			
		||||
    :type env: dict
 | 
			
		||||
    :return: Request method validity
 | 
			
		||||
    :rtype: boolean
 | 
			
		||||
    """
 | 
			
		||||
    return env.get('REQUEST_METHOD').upper() == 'POST'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def check_session_id(session_id: str):
 | 
			
		||||
    """
 | 
			
		||||
    Checks whether session id is present
 | 
			
		||||
    :param session_id: Session id value provided by AT
 | 
			
		||||
    :type session_id: str
 | 
			
		||||
    :return: Session id presence
 | 
			
		||||
    :rtype: boolean
 | 
			
		||||
    """
 | 
			
		||||
    return session_id is not None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def validate_phone_number(phone: str):
 | 
			
		||||
    """
 | 
			
		||||
    Check if phone number is in the correct format.
 | 
			
		||||
    :param phone: The phone number to be validated.
 | 
			
		||||
    :rtype phone: str
 | 
			
		||||
    :return: Whether the phone number is of the correct format.
 | 
			
		||||
    :rtype: bool
 | 
			
		||||
    """
 | 
			
		||||
    if phone and re.match('[+]?[0-9]{10,12}$', phone):
 | 
			
		||||
        return True
 | 
			
		||||
    return False
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def validate_response_type(processor_response: str) -> bool:
 | 
			
		||||
    """
 | 
			
		||||
    This function checks the prefix for a corresponding menu's text from the response offered by the Ussd Processor and
 | 
			
		||||
    determines whether the response should prompt the end of a ussd session or the
 | 
			
		||||
    :param processor_response: A ussd menu's text value.
 | 
			
		||||
    :type processor_response: str
 | 
			
		||||
    :return: Value representing validity of a response.
 | 
			
		||||
    :rtype: bool
 | 
			
		||||
    """
 | 
			
		||||
    matcher = r'^(CON|END)'
 | 
			
		||||
    if len(processor_response) > 164:
 | 
			
		||||
        logg.warning(f'Warning, text has length {len(processor_response)}, display may be truncated')
 | 
			
		||||
 | 
			
		||||
    if re.match(matcher, processor_response):
 | 
			
		||||
        return True
 | 
			
		||||
    return False
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										13
									
								
								apps/cic-ussd/cic_ussd/version.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,13 @@
 | 
			
		||||
# standard imports
 | 
			
		||||
import semver
 | 
			
		||||
 | 
			
		||||
version = (0, 3, 0, 'alpha.1')
 | 
			
		||||
 | 
			
		||||
version_object = semver.VersionInfo(
 | 
			
		||||
        major=version[0],
 | 
			
		||||
        minor=version[1],
 | 
			
		||||
        patch=version[2],
 | 
			
		||||
        prerelease=version[3],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
version_string = str(version_object)
 | 
			
		||||
							
								
								
									
										28
									
								
								apps/cic-ussd/doc/renderer.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,28 @@
 | 
			
		||||
var path = require('path');
 | 
			
		||||
var fs = require('fs');
 | 
			
		||||
var nomnoml = require('nomnoml');
 | 
			
		||||
 | 
			
		||||
const directoryPath = path.join(__dirname, 'workflow');
 | 
			
		||||
const imagesPath = path.join(directoryPath, 'images');
 | 
			
		||||
 | 
			
		||||
fs.readdir(directoryPath, (err, files) => {
 | 
			
		||||
    if (err) {
 | 
			
		||||
        return console.log('Unable to scan directory: ' + err);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    files.forEach(file => {
 | 
			
		||||
        const filePath = path.join(directoryPath, file);
 | 
			
		||||
        fs.readFile(filePath, 'utf-8', (err, data) => {
 | 
			
		||||
            if (err) {
 | 
			
		||||
                return console.log('Unable to scan file: ' + err);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const image = nomnoml.renderSvg(data);
 | 
			
		||||
            const name = file.split('.')[0];
 | 
			
		||||
            fs.writeFile(`${imagesPath}/${name}.svg`, image, (err) => {
 | 
			
		||||
                if (err) throw err;
 | 
			
		||||
                console.log('Image saved!');
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
@ -0,0 +1,14 @@
 | 
			
		||||
[<reference> account management]-> [<input> user input]
 | 
			
		||||
[user input]-> [<choice> 1. My profile]
 | 
			
		||||
[1. My profile]-> yes [<reference> user profile]
 | 
			
		||||
[1. My profile]-> no [<choice> 2. Change language]
 | 
			
		||||
[2. Change language]-> yes [<reference> choose language]
 | 
			
		||||
[2. Change language]-> no [<choice> 3. Check balance]
 | 
			
		||||
[3. Check balance]-> yes [<reference> mini statement inquiry pin authorization]
 | 
			
		||||
[3. Check balance]-> no [<choice> 4. Change PIN]
 | 
			
		||||
[4. Change PIN]-> yes [<reference> current pin]
 | 
			
		||||
[4. Change PIN]-> no [<choice> 5. Opt out of market place]
 | 
			
		||||
[5. Opt out of market place]-> yes [<reference> opt out of market place pin authorization]
 | 
			
		||||
[5. Opt out of market place]-> no [<choice> 0. Back]
 | 
			
		||||
[0. Back]-> yes [<reference> start menu]
 | 
			
		||||
[0. Back]-> no [<reference> exit invalid menu option]
 | 
			
		||||
@ -0,0 +1,8 @@
 | 
			
		||||
[<reference> bio change pin authorization]-> [<input> Please enter your PIN]
 | 
			
		||||
[Please enter your PIN]-> [<choice> is authorized]
 | 
			
		||||
[is authorized]-> yes [save user information]
 | 
			
		||||
[save user information]-> [<reference> complete]
 | 
			
		||||
[is authorized]-> no [<choice> is blocked]
 | 
			
		||||
[is blocked]-> yes [<label> exit pin blocked]
 | 
			
		||||
[is blocked]-> no [increase failed attempts]
 | 
			
		||||
[increase failed attempts]-> [Please enter your PIN]
 | 
			
		||||
@ -0,0 +1,8 @@
 | 
			
		||||
[<reference> choose language]-> [<input> Choose language]
 | 
			
		||||
[Choose language]-> [<choice> 1. English]
 | 
			
		||||
[1. English]-> yes [change language to English]
 | 
			
		||||
[change language to English]-> [<reference> complete]
 | 
			
		||||
[1. English]-> no [<choice> 2. Kiswahili]
 | 
			
		||||
[2. Kiswahili]-> yes [change language to Swahili]
 | 
			
		||||
[change language to Swahili]-> [<label> complete]
 | 
			
		||||
[2. Kiswahili]-> no [<reference> exit invalid menu option]
 | 
			
		||||
@ -0,0 +1,9 @@
 | 
			
		||||
[<reference> change business prompt]-> [<input> Enter your bio]
 | 
			
		||||
[Enter your bio]-> [add bio to session data]
 | 
			
		||||
[add bio to session data]-> [<choice>has empty name]
 | 
			
		||||
[has empty name]-> yes [<reference> first name entry]
 | 
			
		||||
[has empty name]-> no [<choice> has empty gender]
 | 
			
		||||
[has empty gender]-> yes [<reference> gender entry]
 | 
			
		||||
[has empty gender]-> no [<choice> has empty location]
 | 
			
		||||
[has empty location]-> yes [<reference> location entry]
 | 
			
		||||
[has empty location]-> no [<reference> bio change pin authorization]
 | 
			
		||||
							
								
								
									
										5
									
								
								apps/cic-ussd/doc/workflow/complete_transitions.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,5 @@
 | 
			
		||||
[<reference> complete]-> [<input> user input]
 | 
			
		||||
[user input]-> [<choice> 00. Back]
 | 
			
		||||
[00. Back]-> yes [<reference> start menu]
 | 
			
		||||
[00. Back]-> no [<choice> 99. Exit]
 | 
			
		||||
[99. Exit]-> [<label> exit]
 | 
			
		||||
@ -0,0 +1,10 @@
 | 
			
		||||
[<reference> directory listing other]-> [<input> menu option]
 | 
			
		||||
[menu option]-> [<choice> 9. Show more]
 | 
			
		||||
[9. Show more] yes ->[Set usage menu number]
 | 
			
		||||
[Set usage menu number]-> [<reference> directory listing other]
 | 
			
		||||
[9. Show more]-> no [<choice> 10. Show previous]
 | 
			
		||||
[10. Show previous]-> yes [Set usage menu number]
 | 
			
		||||
[10. Show previous]-> no [<choice> is valid other menu option]
 | 
			
		||||
[is valid other menu option]-> yes [send directory listing]
 | 
			
		||||
[send directory listing]-> [<reference> complete]
 | 
			
		||||
[is valid other menu option]-> no [<reference>exit invalid menu option]
 | 
			
		||||
@ -0,0 +1,8 @@
 | 
			
		||||
[<reference> directory listing]-> [<input> menu option]
 | 
			
		||||
[menu option]-> [<choice> 9. Show more]
 | 
			
		||||
[9. Show more]-> yes [Set usage menu number]
 | 
			
		||||
[Set usage menu number]-> [<reference> directory listing other]
 | 
			
		||||
[9. Show more]-> no [<choice>is valid menu option]
 | 
			
		||||
[is valid menu option]-> yes [send directory listing]
 | 
			
		||||
[send directory listing]-> [<reference> complete]
 | 
			
		||||
[is valid menu option]-> no [<reference>exit invalid menu option]
 | 
			
		||||
@ -0,0 +1,7 @@
 | 
			
		||||
[<reference> send enter recipient]-> [<input> Enter phone number]
 | 
			
		||||
[Enter phone number]-> [<choice> is user]
 | 
			
		||||
[is user]-> yes [save recipient phone number]
 | 
			
		||||
[save recipient phone number]-> [<reference> send token amount]
 | 
			
		||||
[is user]-> no [<choice> is token agent]
 | 
			
		||||
[is token agent]-> yes [<reference> exit use exchange menu]
 | 
			
		||||
[is token agent]-> no [<reference> exit invalid recipient]
 | 
			
		||||
@ -0,0 +1,5 @@
 | 
			
		||||
[<reference> exchange token agent number entry]-> [<input> Enter agent phone number]
 | 
			
		||||
[Enter agent phone number]-> [<choice> is valid token agent]
 | 
			
		||||
[is valid token agent]-> yes [save token agent phone number to session data]
 | 
			
		||||
[save token agent phone number to session data]-> [<reference> exchange token amount entry]
 | 
			
		||||
[is valid token agent]-> no [<reference> exit invalid token agent]
 | 
			
		||||
@ -0,0 +1,5 @@
 | 
			
		||||
[<reference> exchange token amount entry]-> [<input> Enter amount]
 | 
			
		||||
[Enter amount]-> [<choice> is valid token amount]
 | 
			
		||||
[is valid token amount]-> yes [save token amount to session data]
 | 
			
		||||
[save token amount to session data] -> [<reference> exchange token pin authorization]
 | 
			
		||||
[is valid token amount]-> no [<label> exit invalid exchange amount]
 | 
			
		||||
@ -0,0 +1,7 @@
 | 
			
		||||
[<reference> exchange token confirmation]-> [<input> menu option]
 | 
			
		||||
[menu option]-> [<choice> 1.Confirm]
 | 
			
		||||
[1.Confirm]-> yes [Process exchange request *(celery tasks)]
 | 
			
		||||
[Process exchange request *(celery tasks)]-> [<reference> complete]
 | 
			
		||||
[1.Confirm]-> no [<choice> 2. Cancel]
 | 
			
		||||
[2. Cancel]-> yes [<reference> start menu]
 | 
			
		||||
[2. Cancel]-> no [<reference> exit invalid menu option]
 | 
			
		||||
@ -0,0 +1,7 @@
 | 
			
		||||
[<reference> exchange token pin authorization]-> [<input> Please enter your PIN]
 | 
			
		||||
[Please enter your PIN]-> [<choice> is authorized]
 | 
			
		||||
[is authorized]-> yes [<reference> exchange token confirmation]
 | 
			
		||||
[is authorized]-> no [<choice> is blocked]
 | 
			
		||||
[is blocked]-> yes [<label> exit pin blocked]
 | 
			
		||||
[is blocked]-> no [increase failed attempts]
 | 
			
		||||
[increase failed attempts]-> [Please enter your PIN]
 | 
			
		||||
@ -0,0 +1,7 @@
 | 
			
		||||
[<reference> exchange token]-> [<input> menu option]
 | 
			
		||||
[menu option]-> [<choice> 1.Check exchange rate]
 | 
			
		||||
[1.Check exchange rate]-> yes [Fetch user exchange rate *(celery tasks)]
 | 
			
		||||
[Fetch user exchange rate *(celery tasks)]-> [<reference> complete]
 | 
			
		||||
[1.Check exchange rate]-> no [<choice> 2. Exchange]
 | 
			
		||||
[2. Exchange]-> yes [<reference> exchange token agent number entry]
 | 
			
		||||
[2. Exchange]-> no [<reference> exit invalid menu option]
 | 
			
		||||
@ -0,0 +1,5 @@
 | 
			
		||||
[<reference> exit invalid input]-> [<input> user input]
 | 
			
		||||
[user input]-> [<choice> 00. Back]
 | 
			
		||||
[00. Back]-> yes [<reference> start menu]
 | 
			
		||||
[00. Back]-> no [<choice> 99. Exit]
 | 
			
		||||
[99. Exit]-> [<label> exit]
 | 
			
		||||
@ -0,0 +1,5 @@
 | 
			
		||||
[<reference> exit invalid menu option]-> [<input> user input]
 | 
			
		||||
[user input]-> [<choice> 00. Back]
 | 
			
		||||
[00. Back]-> yes [<reference> start menu]
 | 
			
		||||
[00. Back]-> no [<choice> 99. Exit]
 | 
			
		||||
[99. Exit]-> [<label> exit]
 | 
			
		||||
@ -0,0 +1,6 @@
 | 
			
		||||
[<reference> exit invalid recipient]-> [<input> user input]
 | 
			
		||||
[user input]-> [<choice> 00. Back]
 | 
			
		||||
[00. Back]-> yes [<reference> send enter recipient]
 | 
			
		||||
[00. Back]-> no [<choice> 99. Exit]
 | 
			
		||||
[99. Exit]-> [upsell unregistered recipient]
 | 
			
		||||
[upsell unregistered recipient]-> [<label> exit]
 | 
			
		||||
@ -0,0 +1,5 @@
 | 
			
		||||
[<reference> exit invalid token agent]-> [<input> user input]
 | 
			
		||||
[user input]-> [<choice> 00. Back]
 | 
			
		||||
[00. Back]-> yes [<reference> exchange token agent number entry]
 | 
			
		||||
[00. Back]-> no [<choice> 99. Exit]
 | 
			
		||||
[99. Exit]-> [<label> exit]
 | 
			
		||||
@ -0,0 +1,5 @@
 | 
			
		||||
[<reference> exit successful send token]-> [<input> user input]
 | 
			
		||||
[user input]-> [<choice> 00. Back]
 | 
			
		||||
[00. Back]-> yes [<reference> start menu]
 | 
			
		||||
[00. Back]-> no [<choice> 99. Exit]
 | 
			
		||||
[99. Exit]-> [<label> exit]
 | 
			
		||||
@ -0,0 +1,5 @@
 | 
			
		||||
[<reference> exit use exchange menu]-> [<input> user input]
 | 
			
		||||
[user input]-> [<choice> 00. Back]
 | 
			
		||||
[00. Back]-> yes [<reference> exchange token]
 | 
			
		||||
[00. Back]-> no [<choice> 99. Exit]
 | 
			
		||||
[99. Exit]-> [<label> exit]
 | 
			
		||||
@ -0,0 +1,3 @@
 | 
			
		||||
[<reference> first name entry]-> [<input> Enter first name]
 | 
			
		||||
[Enter first name]-> [add first name to session data]
 | 
			
		||||
[add first name to session data]-> [<reference> last name entry]
 | 
			
		||||
@ -0,0 +1,8 @@
 | 
			
		||||
[<reference> gender change pin authorization]-> [<input> Please enter your PIN]
 | 
			
		||||
[Please enter your PIN]-> [<choice> is authorized]
 | 
			
		||||
[is authorized]-> yes [save user information]
 | 
			
		||||
[save user information]-> [<reference> complete]
 | 
			
		||||
[is authorized]-> no [<choice> is blocked]
 | 
			
		||||
[is blocked]-> yes [<label> exit pin blocked]
 | 
			
		||||
[is blocked]-> no [increase failed attempts]
 | 
			
		||||
[increase failed attempts]-> [Please enter your PIN]
 | 
			
		||||
							
								
								
									
										9
									
								
								apps/cic-ussd/doc/workflow/gender_entry_transitions.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,9 @@
 | 
			
		||||
[<reference> gender entry]-> [<input> Enter your gender]
 | 
			
		||||
[Enter your gender]-> [add gender to session data]
 | 
			
		||||
[add gender to session data]-> [<choice> has empty name]
 | 
			
		||||
[has empty name]-> yes [<reference> first name entry]
 | 
			
		||||
[has empty name]-> no [<choice> has empty location]
 | 
			
		||||
[has empty location]-> yes [<reference> location entry]
 | 
			
		||||
[has empty location]-> no [<choice> has empty bio]
 | 
			
		||||
[has empty bio]-> yes [<reference> change business prompt]
 | 
			
		||||
[has empty bio]-> no [<reference> gender change pin authorization]
 | 
			
		||||
							
								
								
									
										3
									
								
								apps/cic-ussd/doc/workflow/help_transitions.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,3 @@
 | 
			
		||||
[<reference> help]-> [<frame> help menu]
 | 
			
		||||
[help menu]-> [<choice> 99. Exit]
 | 
			
		||||
[99. Exit]-> [<label> exit]
 | 
			
		||||
@ -0,0 +1,88 @@
 | 
			
		||||
<svg version="1.1" baseProfile="full" width="1027.625" height="718" viewbox="0 0 1027.625 718" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:ev="http://www.w3.org/2001/xml-events" style="font:bold normal 12pt Helvetica, Helvetica, sans-serif;;stroke-linejoin:round;stroke-linecap:round">
 | 
			
		||||
  <title >nomnoml</title>
 | 
			
		||||
  <desc >[<reference> account management]-> [<input> user input]
 | 
			
		||||
[user input]-> [<choice> 1. My profile]
 | 
			
		||||
[1. My profile]-> yes [<reference> user profile]
 | 
			
		||||
[1. My profile]-> no [<choice> 2. Change language]
 | 
			
		||||
[2. Change language]-> yes [<reference> choose language]
 | 
			
		||||
[2. Change language]-> no [<choice> 3. Check balance]
 | 
			
		||||
[3. Check balance]-> yes [<reference> mini statement inquiry pin authorization]
 | 
			
		||||
[3. Check balance]-> no [<choice> 4. Change PIN]
 | 
			
		||||
[4. Change PIN]-> yes [<reference> current pin]
 | 
			
		||||
[4. Change PIN]-> no [<choice> 5. Opt out of market place]
 | 
			
		||||
[5. Opt out of market place]-> yes [<reference> opt out of market place pin authorization]
 | 
			
		||||
[5. Opt out of market place]-> no [<choice> 0. Back]
 | 
			
		||||
[0. Back]-> yes [<reference> start menu]
 | 
			
		||||
[0. Back]-> no [<reference> exit invalid menu option]</desc>
 | 
			
		||||
  <rect x="0" y="0" height="718" width="1027.625" style="stroke:none; fill:transparent;"></rect>
 | 
			
		||||
  <path d="M178.6 44.5 L178.6 64.5 L178.6 84.5 L178.6 84.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M173.3 71.2 L178.6 77.8 L184 71.2 L178.6 84.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M178.6 115.5 L178.6 135.5 L178.6 155.5 L178.6 155.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M173.3 142.2 L178.6 148.8 L184 142.2 L178.6 155.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="30" y="241.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">yes</text>
 | 
			
		||||
  <path d="M118.1 202 L66 222 L66 249.8 L66 249.8 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M60.7 236.4 L66 243.1 L71.3 236.4 L66 249.8 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="299.3" y="234" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">no</text>
 | 
			
		||||
  <path d="M239.2 202 L291.3 222 L291.3 242 L291.3 242 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M285.9 228.7 L291.3 235.3 L296.6 228.7 L291.3 242 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="139.3" y="328.3" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">yes</text>
 | 
			
		||||
  <path d="M228.9 288.5 L175.3 308.5 L175.3 336.3 L175.3 336.3 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M169.9 322.9 L175.3 329.6 L180.6 322.9 L175.3 336.3 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="415.3" y="320.5" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">no</text>
 | 
			
		||||
  <path d="M353.6 288.5 L407.3 308.5 L407.3 328.5 L407.3 328.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M401.9 315.2 L407.3 321.8 L412.6 315.2 L407.3 328.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="220.6" y="414.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">yes</text>
 | 
			
		||||
  <path d="M326.3 375 L256.6 395 L256.6 422.8 L256.6 422.8 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M251.3 409.4 L256.6 416.1 L262 409.4 L256.6 422.8 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="565.9" y="407" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">no</text>
 | 
			
		||||
  <path d="M488.2 375 L557.9 395 L557.9 415 L557.9 415 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M552.5 401.7 L557.9 408.3 L563.2 401.7 L557.9 415 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="393.8" y="501.3" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">yes</text>
 | 
			
		||||
  <path d="M489 461.5 L429.8 481.5 L429.8 509.3 L429.8 509.3 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M424.4 495.9 L429.8 502.6 L435.1 495.9 L429.8 509.3 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="694" y="493.5" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">no</text>
 | 
			
		||||
  <path d="M626.8 461.5 L686 481.5 L686 501.5 L686 501.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M680.7 488.2 L686 494.8 L691.3 488.2 L686 501.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="518.1" y="587.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">yes</text>
 | 
			
		||||
  <path d="M615.1 548 L554.1 568 L554.1 595.8 L554.1 595.8 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M548.8 582.4 L554.1 589.1 L559.5 582.4 L554.1 595.8 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="825.9" y="580" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">no</text>
 | 
			
		||||
  <path d="M756.9 548 L817.9 568 L817.9 588 L817.9 588 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M812.5 574.7 L817.9 581.3 L823.2 574.7 L817.9 588 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="686.1" y="666.5" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">yes</text>
 | 
			
		||||
  <path d="M766.4 634.5 L722.1 654.5 L722.1 674.5 L722.1 674.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M716.8 661.2 L722.1 667.8 L727.5 661.2 L722.1 674.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="921.6" y="666.5" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">no</text>
 | 
			
		||||
  <path d="M869.3 634.5 L913.6 654.5 L913.6 674.5 L913.6 674.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M908.3 661.2 L913.6 667.8 L919 661.2 L913.6 674.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <rect x="85.5" y="13.5" height="31" width="187" data-name="account management" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="179" y="35" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="account management">account management</text>
 | 
			
		||||
  <path d="M139.5 84.5 L226.5 84.5 L218.5 115.5 L131.5 115.5 Z" data-name="user input" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="179" y="106" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="user input">user input</text>
 | 
			
		||||
  <path d="M178.6 155.5 L263 178.8 L178.6 202 L93.5 178.8 Z" data-name="1. My profile" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="178.3" y="184.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="1. My profile">1. My profile</text>
 | 
			
		||||
  <rect x="13.5" y="249.5" height="31" width="105" data-name="user profile" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="66" y="271" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="user profile">user profile</text>
 | 
			
		||||
  <path d="M291.3 242.5 L424 265.3 L291.3 289 L158.5 265.3 Z" data-name="2. Change language" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="291.3" y="271.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="2. Change language">2. Change language</text>
 | 
			
		||||
  <rect x="98.5" y="336.5" height="31" width="153" data-name="choose language" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="175" y="358" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="choose language">choose language</text>
 | 
			
		||||
  <path d="M407.3 328.5 L522.5 351.8 L407.3 375 L291.5 351.8 Z" data-name="3. Check balance" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="407" y="357.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="3. Check balance">3. Check balance</text>
 | 
			
		||||
  <rect x="93.5" y="422.5" height="31" width="326" data-name="mini statement inquiry pin authorization" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="256.5" y="444" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="mini statement inquiry pin authorization">mini statement inquiry pin authorization</text>
 | 
			
		||||
  <path d="M557.9 415.5 L656 438.3 L557.9 462 L459.5 438.3 Z" data-name="4. Change PIN" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="557.8" y="444.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="4. Change PIN">4. Change PIN</text>
 | 
			
		||||
  <rect x="379.5" y="509.5" height="31" width="101" data-name="current pin" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="430" y="531" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="current pin">current pin</text>
 | 
			
		||||
  <path d="M686 501.5 L852 524.8 L686 548 L520.5 524.8 Z" data-name="5. Opt out of market place" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="686.3" y="530.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="5. Opt out of market place">5. Opt out of market place</text>
 | 
			
		||||
  <rect x="386.5" y="595.5" height="31" width="335" data-name="opt out of market place pin authorization" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="554" y="617" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="opt out of market place pin authorization">opt out of market place pin authorization</text>
 | 
			
		||||
  <path d="M817.9 588.5 L874 611.3 L817.9 635 L761.5 611.3 Z" data-name="0. Back" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="817.8" y="617.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="0. Back">0. Back</text>
 | 
			
		||||
  <rect x="672.5" y="674.5" height="31" width="100" data-name="start menu" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="722.5" y="696" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="start menu">start menu</text>
 | 
			
		||||
  <rect x="812.5" y="674.5" height="31" width="203" data-name="exit invalid menu option" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="914" y="696" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="exit invalid menu option">exit invalid menu option</text>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 12 KiB  | 
@ -0,0 +1,47 @@
 | 
			
		||||
<svg version="1.1" baseProfile="full" width="614.25" height="372" viewbox="0 0 614.25 372" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:ev="http://www.w3.org/2001/xml-events" style="font:bold normal 12pt Helvetica, Helvetica, sans-serif;;stroke-linejoin:round;stroke-linecap:round">
 | 
			
		||||
  <title >nomnoml</title>
 | 
			
		||||
  <desc >[<reference> bio change pin authorization]-> [<input> Please enter your PIN]
 | 
			
		||||
[Please enter your PIN]-> [<choice> is authorized]
 | 
			
		||||
[is authorized]-> yes [save user information]
 | 
			
		||||
[save user information]-> [<reference> complete]
 | 
			
		||||
[is authorized]-> no [<choice> is blocked]
 | 
			
		||||
[is blocked]-> yes [<label> exit pin blocked]
 | 
			
		||||
[is blocked]-> no [increase failed attempts]
 | 
			
		||||
[increase failed attempts]-> [Please enter your PIN]</desc>
 | 
			
		||||
  <rect x="0" y="0" height="372" width="614.25" style="stroke:none; fill:transparent;"></rect>
 | 
			
		||||
  <path d="M290.3 44.5 L290.3 64.5 L290.3 84.5 L290.3 84.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M284.9 71.2 L290.3 77.8 L295.6 71.2 L290.3 84.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M255.1 115.5 L209.8 135.5 L209.8 155.5 L209.8 155.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M204.4 142.2 L209.8 148.8 L215.1 142.2 L209.8 155.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="71" y="241.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">yes</text>
 | 
			
		||||
  <path d="M154.5 202 L107 222 L107 249.8 L107 249.8 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M101.7 236.4 L107 243.1 L112.3 236.4 L107 249.8 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M107 280.8 L107 308.5 L107 328.5 L107 328.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M101.7 315.2 L107 321.8 L112.3 315.2 L107 328.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="320.5" y="234" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">no</text>
 | 
			
		||||
  <path d="M265 202 L312.5 222 L312.5 242 L312.5 242 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M307.2 228.7 L312.5 235.3 L317.8 228.7 L312.5 242 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="250.8" y="320.5" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">yes</text>
 | 
			
		||||
  <path d="M298.7 288.5 L286.8 308.5 L286.8 328.5 L286.8 328.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M281.4 315.2 L286.8 321.8 L292.1 315.2 L286.8 328.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="472.1" y="320.5" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">no</text>
 | 
			
		||||
  <path d="M369.6 288.5 L418.8 308.5 L464.1 328.5 L464.1 328.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M449.8 328 L458 325.8 L454.1 318.2 L464.1 328.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M508 328.5 L519.3 308.5 L519.3 265.3 L519.3 265.3 L519.3 222 L519.3 222 L519.3 178.8 L519.3 178.8 L519.3 135.5 L519.3 135.5 L384.8 114.6 L384.8 114.6 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M398.7 111.4 L391.3 115.7 L397.1 122 L384.8 114.6 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <rect x="169.5" y="13.5" height="31" width="241" data-name="bio change pin authorization" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="290" y="35" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="bio change pin authorization">bio change pin authorization</text>
 | 
			
		||||
  <path d="M203.5 84.5 L384.5 84.5 L376.5 115.5 L195.5 115.5 Z" data-name="Please enter your PIN" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="290" y="106" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="Please enter your PIN">Please enter your PIN</text>
 | 
			
		||||
  <path d="M209.8 155.5 L298.5 178.8 L209.8 202 L121.5 178.8 Z" data-name="is authorized" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="210" y="184.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="is authorized">is authorized</text>
 | 
			
		||||
  <rect x="13.5" y="249.5" height="31" width="187" data-name="save user information" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="107" y="271" style="fill: #33322E;font:bold  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="save user information">save user information</text>
 | 
			
		||||
  <rect x="63.5" y="328.5" height="31" width="88" data-name="complete" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="107.5" y="350" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="complete">complete</text>
 | 
			
		||||
  <path d="M312.5 242.5 L384.5 265.3 L312.5 289 L240.5 265.3 Z" data-name="is blocked" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="312.5" y="271.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="is blocked">is blocked</text>
 | 
			
		||||
  <text x="224.5" y="350" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;" data-name="exit pin blocked">exit pin blocked</text>
 | 
			
		||||
  <rect x="396.5" y="328.5" height="31" width="205" data-name="increase failed attempts" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="499" y="350" style="fill: #33322E;font:bold  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="increase failed attempts">increase failed attempts</text>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 6.2 KiB  | 
@ -0,0 +1,49 @@
 | 
			
		||||
<svg version="1.1" baseProfile="full" width="780" height="443" viewbox="0 0 780 443" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:ev="http://www.w3.org/2001/xml-events" style="font:bold normal 12pt Helvetica, Helvetica, sans-serif;;stroke-linejoin:round;stroke-linecap:round">
 | 
			
		||||
  <title >nomnoml</title>
 | 
			
		||||
  <desc >[<reference> choose language]-> [<input> Choose language]
 | 
			
		||||
[Choose language]-> [<choice> 1. English]
 | 
			
		||||
[1. English]-> yes [change language to English]
 | 
			
		||||
[change language to English]-> [<reference> complete]
 | 
			
		||||
[1. English]-> no [<choice> 2. Kiswahili]
 | 
			
		||||
[2. Kiswahili]-> yes [change language to Swahili]
 | 
			
		||||
[change language to Swahili]-> [<label> complete]
 | 
			
		||||
[2. Kiswahili]-> no [<reference> exit invalid menu option]
 | 
			
		||||
</desc>
 | 
			
		||||
  <rect x="0" y="0" height="443" width="780" style="stroke:none; fill:transparent;"></rect>
 | 
			
		||||
  <path d="M399 44.5 L399 64.5 L399 84.5 L399 84.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M393.7 71.2 L399 77.8 L404.3 71.2 L399 84.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M399 115.5 L399 135.5 L399 155.5 L399 155.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M393.7 142.2 L399 148.8 L404.3 142.2 L399 155.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="96" y="320.5" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">yes</text>
 | 
			
		||||
  <path d="M328.5 190.2 L132 222 L132 265.3 L132 265.3 L132 308.5 L132 308.5 L132 328.5 L132 328.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M126.7 315.2 L132 321.8 L137.3 315.2 L132 328.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M132 359.5 L132 379.5 L225.8 403.7 L225.8 403.7 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M211.5 405.5 L219.3 402 L214.2 395.2 L225.8 403.7 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="544.8" y="234" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">no</text>
 | 
			
		||||
  <path d="M469.5 200.9 L536.8 222 L536.8 242 L536.8 242 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M531.4 228.7 L536.8 235.3 L542.1 228.7 L536.8 242 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="371.5" y="320.5" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">yes</text>
 | 
			
		||||
  <path d="M467.3 288.5 L407.5 308.5 L407.5 328.5 L407.5 328.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M402.2 315.2 L407.5 321.8 L412.8 315.2 L407.5 328.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M407.5 359.5 L407.5 379.5 L313.8 403.7 L313.8 403.7 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M325.3 395.2 L320.2 402 L328 405.5 L313.8 403.7 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="674" y="320.5" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">no</text>
 | 
			
		||||
  <path d="M606.2 288.5 L666 308.5 L666 328.5 L666 328.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M660.7 315.2 L666 321.8 L671.3 315.2 L666 328.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <rect x="322.5" y="13.5" height="31" width="153" data-name="choose language" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="399" y="35" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="choose language">choose language</text>
 | 
			
		||||
  <path d="M329.5 84.5 L477.5 84.5 L469.5 115.5 L321.5 115.5 Z" data-name="Choose language" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="399.5" y="106" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="Choose language">Choose language</text>
 | 
			
		||||
  <path d="M399 155.5 L469.5 178.8 L399 202 L328.5 178.8 Z" data-name="1. English" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="399" y="184.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="1. English">1. English</text>
 | 
			
		||||
  <rect x="13.5" y="328.5" height="31" width="237" data-name="change language to English" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="132" y="350" style="fill: #33322E;font:bold  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="change language to English">change language to English</text>
 | 
			
		||||
  <rect x="225.5" y="399.5" height="31" width="88" data-name="complete" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="269.5" y="421" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="complete">complete</text>
 | 
			
		||||
  <path d="M536.8 242.5 L614.5 265.3 L536.8 289 L458.5 265.3 Z" data-name="2. Kiswahili" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="536.5" y="271.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="2. Kiswahili">2. Kiswahili</text>
 | 
			
		||||
  <rect x="290.5" y="328.5" height="31" width="234" data-name="change language to Swahili" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="407.5" y="350" style="fill: #33322E;font:bold  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="change language to Swahili">change language to Swahili</text>
 | 
			
		||||
  <rect x="564.5" y="328.5" height="31" width="203" data-name="exit invalid menu option" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="666" y="350" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="exit invalid menu option">exit invalid menu option</text>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 6.3 KiB  | 
@ -0,0 +1,57 @@
 | 
			
		||||
<svg version="1.1" baseProfile="full" width="666.875" height="529.5" viewbox="0 0 666.875 529.5" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:ev="http://www.w3.org/2001/xml-events" style="font:bold normal 12pt Helvetica, Helvetica, sans-serif;;stroke-linejoin:round;stroke-linecap:round">
 | 
			
		||||
  <title >nomnoml</title>
 | 
			
		||||
  <desc >[<reference> change business prompt]-> [<input> Enter your bio]
 | 
			
		||||
[Enter your bio]-> [add bio to session data]
 | 
			
		||||
[add bio to session data]-> [<choice>has empty name]
 | 
			
		||||
[has empty name]-> yes [<reference> first name entry]
 | 
			
		||||
[has empty name]-> no [<choice> has empty gender]
 | 
			
		||||
[has empty gender]-> yes [<reference> gender entry]
 | 
			
		||||
[has empty gender]-> no [<choice> has empty location]
 | 
			
		||||
[has empty location]-> yes [<reference> location entry]
 | 
			
		||||
[has empty location]-> no [<reference> bio change pin authorization]</desc>
 | 
			
		||||
  <rect x="0" y="0" height="529.5" width="666.875" style="stroke:none; fill:transparent;"></rect>
 | 
			
		||||
  <path d="M197.4 44.5 L197.4 64.5 L197.4 84.5 L197.4 84.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M192 71.2 L197.4 77.8 L202.7 71.2 L197.4 84.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M197.4 115.5 L197.4 135.5 L197.4 155.5 L197.4 155.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M192 142.2 L197.4 148.8 L202.7 142.2 L197.4 155.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M197.4 186.5 L197.4 206.5 L197.4 226.5 L197.4 226.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M192 213.2 L197.4 219.8 L202.7 213.2 L197.4 226.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="47" y="312.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">yes</text>
 | 
			
		||||
  <path d="M135.9 273 L83 293 L83 320.8 L83 320.8 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M77.7 307.4 L83 314.1 L88.3 307.4 L83 320.8 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="319.8" y="305" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">no</text>
 | 
			
		||||
  <path d="M258.9 273 L311.8 293 L311.8 313 L311.8 313 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M306.4 299.7 L311.8 306.3 L317.1 299.7 L311.8 313 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="164.6" y="399.3" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">yes</text>
 | 
			
		||||
  <path d="M252 359.5 L200.6 379.5 L200.6 407.3 L200.6 407.3 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M195.3 393.9 L200.6 400.6 L206 393.9 L200.6 407.3 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="430.9" y="391.5" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">no</text>
 | 
			
		||||
  <path d="M371.5 359.5 L422.9 379.5 L422.9 399.5 L422.9 399.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M417.5 386.2 L422.9 392.8 L428.2 386.2 L422.9 399.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="275.9" y="478" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">yes</text>
 | 
			
		||||
  <path d="M363.2 446 L311.9 466 L311.9 486 L311.9 486 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M306.5 472.7 L311.9 479.3 L317.2 472.7 L311.9 486 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="541.9" y="478" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">no</text>
 | 
			
		||||
  <path d="M482.5 446 L533.9 466 L533.9 486 L533.9 486 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M528.5 472.7 L533.9 479.3 L539.2 472.7 L533.9 486 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <rect x="91.5" y="13.5" height="31" width="211" data-name="change business prompt" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="197" y="35" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="change business prompt">change business prompt</text>
 | 
			
		||||
  <path d="M141.5 84.5 L260.5 84.5 L252.5 115.5 L133.5 115.5 Z" data-name="Enter your bio" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="197" y="106" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="Enter your bio">Enter your bio</text>
 | 
			
		||||
  <rect x="96.5" y="155.5" height="31" width="201" data-name="add bio to session data" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="197" y="177" style="fill: #33322E;font:bold  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="add bio to session data">add bio to session data</text>
 | 
			
		||||
  <path d="M197.4 226.5 L308 249.8 L197.4 273 L87.5 249.8 Z" data-name="has empty name" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="197.8" y="255.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="has empty name">has empty name</text>
 | 
			
		||||
  <rect x="13.5" y="320.5" height="31" width="139" data-name="first name entry" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="83" y="342" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="first name entry">first name entry</text>
 | 
			
		||||
  <path d="M311.8 313.5 L431 336.3 L311.8 360 L192.5 336.3 Z" data-name="has empty gender" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="311.8" y="342.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="has empty gender">has empty gender</text>
 | 
			
		||||
  <rect x="142.5" y="407.5" height="31" width="117" data-name="gender entry" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="201" y="429" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="gender entry">gender entry</text>
 | 
			
		||||
  <path d="M422.9 399.5 L547 422.8 L422.9 446 L299.5 422.8 Z" data-name="has empty location" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="423.3" y="428.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="has empty location">has empty location</text>
 | 
			
		||||
  <rect x="250.5" y="486.5" height="31" width="123" data-name="location entry" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="312" y="508" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="location entry">location entry</text>
 | 
			
		||||
  <rect x="413.5" y="486.5" height="31" width="241" data-name="bio change pin authorization" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="534" y="508" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="bio change pin authorization">bio change pin authorization</text>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 7.5 KiB  | 
							
								
								
									
										32
									
								
								apps/cic-ussd/doc/workflow/images/complete_transitions.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,32 @@
 | 
			
		||||
<svg version="1.1" baseProfile="full" width="278.5" height="372" viewbox="0 0 278.5 372" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:ev="http://www.w3.org/2001/xml-events" style="font:bold normal 12pt Helvetica, Helvetica, sans-serif;;stroke-linejoin:round;stroke-linecap:round">
 | 
			
		||||
  <title >nomnoml</title>
 | 
			
		||||
  <desc >[<reference> complete]-> [<input> user input]
 | 
			
		||||
[user input]-> [<choice> 00. Back]
 | 
			
		||||
[00. Back]-> yes [<reference> start menu]
 | 
			
		||||
[00. Back]-> no [<choice> 99. Exit]
 | 
			
		||||
[99. Exit]-> [<label> exit]</desc>
 | 
			
		||||
  <rect x="0" y="0" height="372" width="278.5" style="stroke:none; fill:transparent;"></rect>
 | 
			
		||||
  <path d="M136.6 44.5 L136.6 64.5 L136.6 84.5 L136.6 84.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M131.3 71.2 L136.6 77.8 L142 71.2 L136.6 84.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M136.6 115.5 L136.6 135.5 L136.6 155.5 L136.6 155.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M131.3 142.2 L136.6 148.8 L142 142.2 L136.6 155.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="27.5" y="241.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">yes</text>
 | 
			
		||||
  <path d="M97.3 202 L63.5 222 L63.5 249.8 L63.5 249.8 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M58.2 236.4 L63.5 243.1 L68.8 236.4 L63.5 249.8 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="217.8" y="234" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">no</text>
 | 
			
		||||
  <path d="M175.9 202 L209.8 222 L209.8 242 L209.8 242 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M204.4 228.7 L209.8 235.3 L215.1 228.7 L209.8 242 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M209.8 288.5 L209.8 308.5 L209.8 328.5 L209.8 328.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M204.4 315.2 L209.8 321.8 L215.1 315.2 L209.8 328.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <rect x="92.5" y="13.5" height="31" width="88" data-name="complete" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="136.5" y="35" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="complete">complete</text>
 | 
			
		||||
  <path d="M97.5 84.5 L184.5 84.5 L176.5 115.5 L89.5 115.5 Z" data-name="user input" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="137" y="106" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="user input">user input</text>
 | 
			
		||||
  <path d="M136.6 155.5 L200 178.8 L136.6 202 L72.5 178.8 Z" data-name="00. Back" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="136.3" y="184.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="00. Back">00. Back</text>
 | 
			
		||||
  <rect x="13.5" y="249.5" height="31" width="100" data-name="start menu" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="63.5" y="271" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="start menu">start menu</text>
 | 
			
		||||
  <path d="M209.8 242.5 L266 265.3 L209.8 289 L153.5 265.3 Z" data-name="99. Exit" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="209.8" y="271.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="99. Exit">99. Exit</text>
 | 
			
		||||
  <text x="195.5" y="350" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;" data-name="exit">exit</text>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 4.0 KiB  | 
@ -0,0 +1,59 @@
 | 
			
		||||
<svg version="1.1" baseProfile="full" width="755.5" height="529.5" viewbox="0 0 755.5 529.5" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:ev="http://www.w3.org/2001/xml-events" style="font:bold normal 12pt Helvetica, Helvetica, sans-serif;;stroke-linejoin:round;stroke-linecap:round">
 | 
			
		||||
  <title >nomnoml</title>
 | 
			
		||||
  <desc >[<reference> directory listing other]-> [<input> menu option]
 | 
			
		||||
[menu option]-> [<choice> 9. Show more]
 | 
			
		||||
[9. Show more] yes ->[Set usage menu number]
 | 
			
		||||
[Set usage menu number]-> [<reference> directory listing other]
 | 
			
		||||
[9. Show more]-> no [<choice> 10. Show previous]
 | 
			
		||||
[10. Show previous]-> yes [Set usage menu number]
 | 
			
		||||
[10. Show previous]-> no [<choice> is valid other menu option]
 | 
			
		||||
[is valid other menu option]-> yes [send directory listing]
 | 
			
		||||
[send directory listing]-> [<reference> complete]
 | 
			
		||||
[is valid other menu option]-> no [<reference>exit invalid menu option]
 | 
			
		||||
</desc>
 | 
			
		||||
  <rect x="0" y="0" height="529.5" width="755.5" style="stroke:none; fill:transparent;"></rect>
 | 
			
		||||
  <path d="M152 44.5 L106.5 64.5 L106.5 84.5 L106.5 84.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M101.2 71.2 L106.5 77.8 L111.8 71.2 L106.5 84.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M106.5 115.5 L106.5 135.5 L106.5 155.5 L106.5 155.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M101.2 142.2 L106.5 148.8 L111.8 142.2 L106.5 155.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="70.5" y="222" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">yes</text>
 | 
			
		||||
  <path d="M106.5 202 L106.5 222 L106.5 265.3 L106.5 265.3 L106.5 308.5 L106.5 308.5 L210.1 336.3 L210.1 336.3 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M195.9 338 L203.7 334.5 L198.6 327.6 L210.1 336.3 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M325.9 336.3 L429.5 308.5 L429.5 265.3 L429.5 265.3 L429.5 222 L429.5 222 L429.5 178.8 L429.5 178.8 L429.5 135.5 L429.5 135.5 L429.5 100 L429.5 100 L429.5 64.5 L429.5 64.5 L278.3 42.3 L278.3 42.3 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M292.2 39 L284.8 43.3 L290.7 49.5 L278.3 42.3 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="276" y="234" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">no</text>
 | 
			
		||||
  <path d="M193.3 202 L268 222 L268 242 L268 242 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M262.7 228.7 L268 235.3 L273.3 228.7 L268 242 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="232" y="328.3" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">yes</text>
 | 
			
		||||
  <path d="M268 288.5 L268 308.5 L268 336.3 L268 336.3 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M262.7 322.9 L268 329.6 L273.3 322.9 L268 336.3 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="586" y="320.5" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">no</text>
 | 
			
		||||
  <path d="M389.5 282.2 L578 308.5 L578 328.5 L578 328.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M572.7 315.2 L578 321.8 L583.3 315.2 L578 328.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="372" y="407" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">yes</text>
 | 
			
		||||
  <path d="M486.6 375 L408 395 L408 415 L408 415 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M402.7 401.7 L408 408.3 L413.3 401.7 L408 415 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M408 446 L408 466 L408 486 L408 486 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M402.7 472.7 L408 479.3 L413.3 472.7 L408 486 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="647.5" y="407" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">no</text>
 | 
			
		||||
  <path d="M611.1 375 L639.5 395 L639.5 415 L639.5 415 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M634.2 401.7 L639.5 408.3 L644.8 401.7 L639.5 415 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <rect x="96.5" y="13.5" height="31" width="182" data-name="directory listing other" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="187.5" y="35" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="directory listing other">directory listing other</text>
 | 
			
		||||
  <path d="M57.5 84.5 L163.5 84.5 L155.5 115.5 L49.5 115.5 Z" data-name="menu option" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="106.5" y="106" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="menu option">menu option</text>
 | 
			
		||||
  <path d="M106.5 155.5 L199.5 178.8 L106.5 202 L13.5 178.8 Z" data-name="9. Show more" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="106.5" y="184.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="9. Show more">9. Show more</text>
 | 
			
		||||
  <rect x="163.5" y="336.5" height="31" width="210" data-name="Set usage menu number" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="268.5" y="358" style="fill: #33322E;font:bold  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="Set usage menu number">Set usage menu number</text>
 | 
			
		||||
  <path d="M268 242.5 L389.5 265.3 L268 289 L146.5 265.3 Z" data-name="10. Show previous" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="268" y="271.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="10. Show previous">10. Show previous</text>
 | 
			
		||||
  <path d="M578 328.5 L743.5 351.8 L578 375 L413.5 351.8 Z" data-name="is valid other menu option" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="578.5" y="357.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="is valid other menu option">is valid other menu option</text>
 | 
			
		||||
  <rect x="318.5" y="415.5" height="31" width="180" data-name="send directory listing" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="408.5" y="437" style="fill: #33322E;font:bold  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="send directory listing">send directory listing</text>
 | 
			
		||||
  <rect x="364.5" y="486.5" height="31" width="88" data-name="complete" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="408.5" y="508" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="complete">complete</text>
 | 
			
		||||
  <rect x="538.5" y="415.5" height="31" width="203" data-name="exit invalid menu option" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="640" y="437" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="exit invalid menu option">exit invalid menu option</text>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 7.7 KiB  | 
@ -0,0 +1,51 @@
 | 
			
		||||
<svg version="1.1" baseProfile="full" width="712.25" height="443" viewbox="0 0 712.25 443" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:ev="http://www.w3.org/2001/xml-events" style="font:bold normal 12pt Helvetica, Helvetica, sans-serif;;stroke-linejoin:round;stroke-linecap:round">
 | 
			
		||||
  <title >nomnoml</title>
 | 
			
		||||
  <desc >[<reference> directory listing]-> [<input> menu option]
 | 
			
		||||
[menu option]-> [<choice> 9. Show more]
 | 
			
		||||
[9. Show more]-> yes [Set usage menu number]
 | 
			
		||||
[Set usage menu number]-> [<reference> directory listing other]
 | 
			
		||||
[9. Show more]-> no [<choice>is valid menu option]
 | 
			
		||||
[is valid menu option]-> yes [send directory listing]
 | 
			
		||||
[send directory listing]-> [<reference> complete]
 | 
			
		||||
[is valid menu option]-> no [<reference>exit invalid menu option]
 | 
			
		||||
</desc>
 | 
			
		||||
  <rect x="0" y="0" height="443" width="712.25" style="stroke:none; fill:transparent;"></rect>
 | 
			
		||||
  <path d="M344.8 44.5 L344.8 64.5 L344.8 84.5 L344.8 84.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M339.4 71.2 L344.8 77.8 L350.1 71.2 L344.8 84.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M344.8 115.5 L344.8 135.5 L344.8 155.5 L344.8 155.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M339.4 142.2 L344.8 148.8 L350.1 142.2 L344.8 155.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="82.5" y="241.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">yes</text>
 | 
			
		||||
  <path d="M251.8 196.5 L118.5 222 L118.5 249.8 L118.5 249.8 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M113.2 236.4 L118.5 243.1 L123.8 236.4 L118.5 249.8 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M118.5 280.8 L118.5 308.5 L118.5 328.5 L118.5 328.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M113.2 315.2 L118.5 321.8 L123.8 315.2 L118.5 328.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="490.5" y="234" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">no</text>
 | 
			
		||||
  <path d="M418.8 202 L482.5 222 L482.5 242 L482.5 242 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M477.2 228.7 L482.5 235.3 L487.8 228.7 L482.5 242 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="330.8" y="320.5" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">yes</text>
 | 
			
		||||
  <path d="M420.3 288.5 L366.8 308.5 L366.8 328.5 L366.8 328.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M361.4 315.2 L366.8 321.8 L372.1 315.2 L366.8 328.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M366.8 359.5 L366.8 379.5 L366.8 399.5 L366.8 399.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M361.4 386.2 L366.8 392.8 L372.1 386.2 L366.8 399.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="606.3" y="320.5" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">no</text>
 | 
			
		||||
  <path d="M544.7 288.5 L598.3 308.5 L598.3 328.5 L598.3 328.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M592.9 315.2 L598.3 321.8 L603.6 315.2 L598.3 328.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <rect x="276.5" y="13.5" height="31" width="136" data-name="directory listing" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="344.5" y="35" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="directory listing">directory listing</text>
 | 
			
		||||
  <path d="M295.5 84.5 L401.5 84.5 L393.5 115.5 L287.5 115.5 Z" data-name="menu option" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="344.5" y="106" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="menu option">menu option</text>
 | 
			
		||||
  <path d="M344.8 155.5 L437.5 178.8 L344.8 202 L251.5 178.8 Z" data-name="9. Show more" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="344.5" y="184.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="9. Show more">9. Show more</text>
 | 
			
		||||
  <rect x="13.5" y="249.5" height="31" width="210" data-name="Set usage menu number" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="118.5" y="271" style="fill: #33322E;font:bold  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="Set usage menu number">Set usage menu number</text>
 | 
			
		||||
  <rect x="27.5" y="328.5" height="31" width="182" data-name="directory listing other" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="118.5" y="350" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="directory listing other">directory listing other</text>
 | 
			
		||||
  <path d="M482.5 242.5 L613.5 265.3 L482.5 289 L352.5 265.3 Z" data-name="is valid menu option" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="483" y="271.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="is valid menu option">is valid menu option</text>
 | 
			
		||||
  <rect x="276.5" y="328.5" height="31" width="180" data-name="send directory listing" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="366.5" y="350" style="fill: #33322E;font:bold  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="send directory listing">send directory listing</text>
 | 
			
		||||
  <rect x="322.5" y="399.5" height="31" width="88" data-name="complete" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="366.5" y="421" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="complete">complete</text>
 | 
			
		||||
  <rect x="496.5" y="328.5" height="31" width="203" data-name="exit invalid menu option" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="598" y="350" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="exit invalid menu option">exit invalid menu option</text>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 6.7 KiB  | 
@ -0,0 +1,46 @@
 | 
			
		||||
<svg version="1.1" baseProfile="full" width="711.75" height="372" viewbox="0 0 711.75 372" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:ev="http://www.w3.org/2001/xml-events" style="font:bold normal 12pt Helvetica, Helvetica, sans-serif;;stroke-linejoin:round;stroke-linecap:round">
 | 
			
		||||
  <title >nomnoml</title>
 | 
			
		||||
  <desc >[<reference> send enter recipient]-> [<input> Enter phone number]
 | 
			
		||||
[Enter phone number]-> [<choice> is user]
 | 
			
		||||
[is user]-> yes [save recipient phone number]
 | 
			
		||||
[save recipient phone number]-> [<reference> send token amount]
 | 
			
		||||
[is user]-> no [<choice> is token agent]
 | 
			
		||||
[is token agent]-> yes [<reference> exit use exchange menu]
 | 
			
		||||
[is token agent]-> no [<reference> exit invalid recipient]
 | 
			
		||||
</desc>
 | 
			
		||||
  <rect x="0" y="0" height="372" width="711.75" style="stroke:none; fill:transparent;"></rect>
 | 
			
		||||
  <path d="M367.3 44.5 L367.3 64.5 L367.3 84.5 L367.3 84.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M361.9 71.2 L367.3 77.8 L372.6 71.2 L367.3 84.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M367.3 115.5 L367.3 135.5 L367.3 155.5 L367.3 155.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M361.9 142.2 L367.3 148.8 L372.6 142.2 L367.3 155.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="101" y="241.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">yes</text>
 | 
			
		||||
  <path d="M315.5 188.5 L137 222 L137 249.8 L137 249.8 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M131.7 236.4 L137 243.1 L142.3 236.4 L137 249.8 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M137 280.8 L137 308.5 L137 328.5 L137 328.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M131.7 315.2 L137 321.8 L142.3 315.2 L137 328.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="505" y="234" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">no</text>
 | 
			
		||||
  <path d="M419 196 L497 222 L497 242 L497 242 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M491.7 228.7 L497 235.3 L502.3 228.7 L497 242 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="345.3" y="320.5" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">yes</text>
 | 
			
		||||
  <path d="M434.8 288.5 L381.3 308.5 L381.3 328.5 L381.3 328.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M375.9 315.2 L381.3 321.8 L386.6 315.2 L381.3 328.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="620.8" y="320.5" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">no</text>
 | 
			
		||||
  <path d="M559.2 288.5 L612.8 308.5 L612.8 328.5 L612.8 328.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M607.4 315.2 L612.8 321.8 L618.1 315.2 L612.8 328.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <rect x="280.5" y="13.5" height="31" width="174" data-name="send enter recipient" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="367.5" y="35" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="send enter recipient">send enter recipient</text>
 | 
			
		||||
  <path d="M286.5 84.5 L456.5 84.5 L448.5 115.5 L278.5 115.5 Z" data-name="Enter phone number" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="367.5" y="106" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="Enter phone number">Enter phone number</text>
 | 
			
		||||
  <path d="M367.3 155.5 L419 178.8 L367.3 202 L315.5 178.8 Z" data-name="is user" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="367.3" y="184.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="is user">is user</text>
 | 
			
		||||
  <rect x="13.5" y="249.5" height="31" width="247" data-name="save recipient phone number" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="137" y="271" style="fill: #33322E;font:bold  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="save recipient phone number">save recipient phone number</text>
 | 
			
		||||
  <rect x="53.5" y="328.5" height="31" width="168" data-name="send token amount" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="137.5" y="350" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="send token amount">send token amount</text>
 | 
			
		||||
  <path d="M497 242.5 L593.5 265.3 L497 289 L401.5 265.3 Z" data-name="is token agent" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="497.5" y="271.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="is token agent">is token agent</text>
 | 
			
		||||
  <rect x="276.5" y="328.5" height="31" width="210" data-name="exit use exchange menu" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="381.5" y="350" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="exit use exchange menu">exit use exchange menu</text>
 | 
			
		||||
  <rect x="526.5" y="328.5" height="31" width="173" data-name="exit invalid recipient" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="613" y="350" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="exit invalid recipient">exit invalid recipient</text>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 6.0 KiB  | 
@ -0,0 +1,34 @@
 | 
			
		||||
<svg version="1.1" baseProfile="full" width="664" height="356.5" viewbox="0 0 664 356.5" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:ev="http://www.w3.org/2001/xml-events" style="font:bold normal 12pt Helvetica, Helvetica, sans-serif;;stroke-linejoin:round;stroke-linecap:round">
 | 
			
		||||
  <title >nomnoml</title>
 | 
			
		||||
  <desc >[<reference> exchange token agent number entry]-> [<input> Enter agent phone number]
 | 
			
		||||
[Enter agent phone number]-> [<choice> is valid token agent]
 | 
			
		||||
[is valid token agent]-> yes [save token agent phone number to session data]
 | 
			
		||||
[save token agent phone number to session data]-> [<reference> exchange token amount entry]
 | 
			
		||||
[is valid token agent]-> no [<reference> exit invalid token agent]
 | 
			
		||||
</desc>
 | 
			
		||||
  <rect x="0" y="0" height="356.5" width="664" style="stroke:none; fill:transparent;"></rect>
 | 
			
		||||
  <path d="M382.5 44.5 L382.5 64.5 L382.5 84.5 L382.5 84.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M377.2 71.2 L382.5 77.8 L387.8 71.2 L382.5 84.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M382.5 115.5 L382.5 135.5 L382.5 155.5 L382.5 155.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M377.2 142.2 L382.5 148.8 L387.8 142.2 L382.5 155.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="177" y="234" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">yes</text>
 | 
			
		||||
  <path d="M291.4 202 L213 222 L213 242 L213 242 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M207.7 228.7 L213 235.3 L218.3 228.7 L213 242 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M213 273 L213 293 L213 313 L213 313 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M207.7 299.7 L213 306.3 L218.3 299.7 L213 313 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="560" y="234" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">no</text>
 | 
			
		||||
  <path d="M473.6 202 L552 222 L552 242 L552 242 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M546.7 228.7 L552 235.3 L557.3 228.7 L552 242 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <rect x="231.5" y="13.5" height="31" width="303" data-name="exchange token agent number entry" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="383" y="35" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="exchange token agent number entry">exchange token agent number entry</text>
 | 
			
		||||
  <path d="M276.5 84.5 L496.5 84.5 L488.5 115.5 L268.5 115.5 Z" data-name="Enter agent phone number" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="382.5" y="106" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="Enter agent phone number">Enter agent phone number</text>
 | 
			
		||||
  <path d="M382.5 155.5 L510.5 178.8 L382.5 202 L255.5 178.8 Z" data-name="is valid token agent" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="383" y="184.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="is valid token agent">is valid token agent</text>
 | 
			
		||||
  <rect x="13.5" y="242.5" height="31" width="399" data-name="save token agent phone number to session data" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="213" y="264" style="fill: #33322E;font:bold  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="save token agent phone number to session data">save token agent phone number to session data</text>
 | 
			
		||||
  <rect x="87.5" y="313.5" height="31" width="252" data-name="exchange token amount entry" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="213.5" y="335" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="exchange token amount entry">exchange token amount entry</text>
 | 
			
		||||
  <rect x="452.5" y="242.5" height="31" width="199" data-name="exit invalid token agent" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="552" y="264" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="exit invalid token agent">exit invalid token agent</text>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 4.6 KiB  | 
@ -0,0 +1,33 @@
 | 
			
		||||
<svg version="1.1" baseProfile="full" width="606" height="356.5" viewbox="0 0 606 356.5" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:ev="http://www.w3.org/2001/xml-events" style="font:bold normal 12pt Helvetica, Helvetica, sans-serif;;stroke-linejoin:round;stroke-linecap:round">
 | 
			
		||||
  <title >nomnoml</title>
 | 
			
		||||
  <desc >[<reference> exchange token amount entry]-> [<input> Enter amount]
 | 
			
		||||
[Enter amount]-> [<choice> is valid token amount]
 | 
			
		||||
[is valid token amount]-> yes [save token amount to session data]
 | 
			
		||||
[save token amount to session data] -> [<reference> exchange token pin authorization]
 | 
			
		||||
[is valid token amount]-> no [<label> exit invalid exchange amount]
 | 
			
		||||
</desc>
 | 
			
		||||
  <rect x="0" y="0" height="356.5" width="606" style="stroke:none; fill:transparent;"></rect>
 | 
			
		||||
  <path d="M315 44.5 L315 64.5 L315 84.5 L315 84.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M309.7 71.2 L315 77.8 L320.3 71.2 L315 84.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M315 115.5 L315 135.5 L315 155.5 L315 155.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M309.7 142.2 L315 148.8 L320.3 142.2 L315 155.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="124" y="234" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">yes</text>
 | 
			
		||||
  <path d="M231.7 202 L160 222 L160 242 L160 242 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M154.7 228.7 L160 235.3 L165.3 228.7 L160 242 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M160 273 L160 293 L160 313 L160 313 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M154.7 299.7 L160 306.3 L165.3 299.7 L160 313 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="478" y="234" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">no</text>
 | 
			
		||||
  <path d="M398.3 202 L470 222 L470 242 L470 242 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M464.7 228.7 L470 235.3 L475.3 228.7 L470 242 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <rect x="189.5" y="13.5" height="31" width="252" data-name="exchange token amount entry" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="315.5" y="35" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="exchange token amount entry">exchange token amount entry</text>
 | 
			
		||||
  <path d="M262.5 84.5 L376.5 84.5 L368.5 115.5 L254.5 115.5 Z" data-name="Enter amount" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="315.5" y="106" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="Enter amount">Enter amount</text>
 | 
			
		||||
  <path d="M315 155.5 L453.5 178.8 L315 202 L177.5 178.8 Z" data-name="is valid token amount" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="315.5" y="184.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="is valid token amount">is valid token amount</text>
 | 
			
		||||
  <rect x="13.5" y="242.5" height="31" width="293" data-name="save token amount to session data" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="160" y="264" style="fill: #33322E;font:bold  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="save token amount to session data">save token amount to session data</text>
 | 
			
		||||
  <rect x="20.5" y="313.5" height="31" width="280" data-name="exchange token pin authorization" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="160.5" y="335" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="exchange token pin authorization">exchange token pin authorization</text>
 | 
			
		||||
  <text x="354.5" y="264" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;" data-name="exit invalid exchange amount">exit invalid exchange amount</text>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 4.3 KiB  | 
@ -0,0 +1,45 @@
 | 
			
		||||
<svg version="1.1" baseProfile="full" width="700.375" height="372" viewbox="0 0 700.375 372" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:ev="http://www.w3.org/2001/xml-events" style="font:bold normal 12pt Helvetica, Helvetica, sans-serif;;stroke-linejoin:round;stroke-linecap:round">
 | 
			
		||||
  <title >nomnoml</title>
 | 
			
		||||
  <desc >[<reference> exchange token confirmation]-> [<input> menu option]
 | 
			
		||||
[menu option]-> [<choice> 1.Confirm]
 | 
			
		||||
[1.Confirm]-> yes [Process exchange request *(celery tasks)]
 | 
			
		||||
[Process exchange request *(celery tasks)]-> [<reference> complete]
 | 
			
		||||
[1.Confirm]-> no [<choice> 2. Cancel]
 | 
			
		||||
[2. Cancel]-> yes [<reference> start menu]
 | 
			
		||||
[2. Cancel]-> no [<reference> exit invalid menu option]</desc>
 | 
			
		||||
  <rect x="0" y="0" height="372" width="700.375" style="stroke:none; fill:transparent;"></rect>
 | 
			
		||||
  <path d="M349.8 44.5 L349.8 64.5 L349.8 84.5 L349.8 84.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M344.4 71.2 L349.8 77.8 L355.1 71.2 L349.8 84.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M349.8 115.5 L349.8 135.5 L349.8 155.5 L349.8 155.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M344.4 142.2 L349.8 148.8 L355.1 142.2 L349.8 155.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="151" y="241.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">yes</text>
 | 
			
		||||
  <path d="M280.8 197.1 L187 222 L187 249.8 L187 249.8 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M181.7 236.4 L187 243.1 L192.3 236.4 L187 249.8 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M187 280.8 L187 308.5 L187 328.5 L187 328.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M181.7 315.2 L187 321.8 L192.3 315.2 L187 328.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="498.6" y="234" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">no</text>
 | 
			
		||||
  <path d="M418.8 199.9 L490.6 222 L490.6 242 L490.6 242 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M485.3 228.7 L490.6 235.3 L496 228.7 L490.6 242 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="358.9" y="320.5" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">yes</text>
 | 
			
		||||
  <path d="M439.2 288.5 L394.9 308.5 L394.9 328.5 L394.9 328.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M389.5 315.2 L394.9 321.8 L400.2 315.2 L394.9 328.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="594.4" y="320.5" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;">no</text>
 | 
			
		||||
  <path d="M542.1 288.5 L586.4 308.5 L586.4 328.5 L586.4 328.5 " style="stroke:#33322E;fill:none;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <path d="M581 315.2 L586.4 321.8 L591.7 315.2 L586.4 328.5 Z" style="stroke:#33322E;fill:#33322E;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <rect x="227.5" y="13.5" height="31" width="245" data-name="exchange token confirmation" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="350" y="35" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="exchange token confirmation">exchange token confirmation</text>
 | 
			
		||||
  <path d="M300.5 84.5 L406.5 84.5 L398.5 115.5 L292.5 115.5 Z" data-name="menu option" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="349.5" y="106" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="menu option">menu option</text>
 | 
			
		||||
  <path d="M349.8 155.5 L418.5 178.8 L349.8 202 L280.5 178.8 Z" data-name="1.Confirm" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="349.5" y="184.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="1.Confirm">1.Confirm</text>
 | 
			
		||||
  <rect x="13.5" y="249.5" height="31" width="347" data-name="Process exchange request *(celery tasks)" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="187" y="271" style="fill: #33322E;font:bold  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="Process exchange request *(celery tasks)">Process exchange request *(celery tasks)</text>
 | 
			
		||||
  <rect x="143.5" y="328.5" height="31" width="88" data-name="complete" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="187.5" y="350" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="complete">complete</text>
 | 
			
		||||
  <path d="M490.6 242.5 L559 265.3 L490.6 289 L422.5 265.3 Z" data-name="2. Cancel" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:none;stroke-width:3;"></path>
 | 
			
		||||
  <text x="490.8" y="271.8" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="2. Cancel">2. Cancel</text>
 | 
			
		||||
  <rect x="344.5" y="328.5" height="31" width="100" data-name="start menu" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="394.5" y="350" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="start menu">start menu</text>
 | 
			
		||||
  <rect x="484.5" y="328.5" height="31" width="203" data-name="exit invalid menu option" style="stroke:#33322E;fill:#eee8d5;stroke-dasharray:6 6;stroke-width:3;"></rect>
 | 
			
		||||
  <text x="586" y="350" style="fill: #33322E;font:normal  12pt Helvetica, Helvetica, sans-serif;text-anchor: middle;" data-name="exit invalid menu option">exit invalid menu option</text>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 5.9 KiB  |