Compare commits
4 Commits
master
...
remove-sub
Author | SHA1 | Date | |
---|---|---|---|
cdca4c6d9f | |||
cd4508e343 | |||
a50e469eb6 | |||
f53d0857e8 |
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"]
|
[submodule "apps/cic-notify"]
|
||||||
path = apps/cic-notify
|
path = apps/cic-notify
|
||||||
url = git@gitlab.com:grassrootseconomics/cic-notify.git
|
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 |