Remove submodule cic ussd

This commit is contained in:
Blair Vanderlugt 2021-02-06 15:13:47 +00:00
parent 8680d57a67
commit f386625844
221 changed files with 10030 additions and 4 deletions

3
.gitmodules vendored
View File

@ -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

View 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/

View File

@ -0,0 +1,2 @@
[cic]
chain_spec = Bloxberg:8995

View File

@ -0,0 +1,8 @@
[database]
NAME=cic_ussd
USER=postgres
PASSWORD=password
HOST=localhost
PORT=5432
ENGINE=postgresql
DRIVER=psycopg2

View File

@ -0,0 +1,5 @@
[PIP]
extra_index_host = pip.grassrootseconomics.net
extra_index_port = 8433
extra_index_path = /
extra_index_proto = https

View File

@ -0,0 +1,9 @@
[celery]
BROKER_URL=redis://
RESULT_URL=redis://
[redis]
HOSTNAME=localhost
PASSWORD=
PORT=6379
DATABASE=0

View 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/

View File

@ -0,0 +1,2 @@
[cic]
chain_spec = Bloxberg:8995

View File

@ -0,0 +1,8 @@
[database]
NAME=cic_ussd_test
USER=postgres
PASSWORD=
HOST=localhost
PORT=5432
ENGINE=sqlite
DRIVER=pysqlite

View File

@ -0,0 +1,5 @@
[PIP]
extra_index_host = pip.grassrootseconomics.net
extra_index_port = 8433
extra_index_path = /
extra_index_proto = https

View File

@ -0,0 +1,9 @@
[celery]
BROKER_URL = filesystem://
RESULT_URL = filesystem://
[redis]
HOSTNAME=localhost
PASSWORD=
PORT=6379
DATABASE=0

View File

@ -0,0 +1,6 @@
[report]
omit =
venv/*
scripts/*
cic_ussd/db/migrations/*
cic_ussd/runnable/*

5
apps/cic-ussd/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
__pycache__
.idea
venv
node_modules
.coverage

View 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
View 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>.

View File

View 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

View 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

View File

@ -0,0 +1 @@
Generic single-database configuration.

View 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

View 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()

View 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"}

View File

@ -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')

View File

@ -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')

View File

@ -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')

View 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

View 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)

View 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

View 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
}

View 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
}
}
}

View 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)

View 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

View File

View 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

View File

View 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}>"

View 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)

View 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

View 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)

View File

@ -0,0 +1,6 @@
# third-party imports
from redis import Redis
class InMemoryStore:
cache: Redis = None

View 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'

View 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]

View 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()

View 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
}

View File

@ -0,0 +1 @@
from .state_machine import UssdStateMachine

View 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__)

View 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.)')

View 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'

View 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

View 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.')

View 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)

View 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.')

View 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

View 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)

View 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

View 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}.')

View 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))

View 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()

View 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)

View 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)

View 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

View 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)

View 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!');
});
});
});
});

View File

@ -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]

View File

@ -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]

View File

@ -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]

View File

@ -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]

View 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]

View File

@ -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]

View File

@ -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]

View File

@ -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]

View File

@ -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]

View File

@ -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]

View File

@ -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]

View File

@ -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]

View File

@ -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]

View File

@ -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]

View File

@ -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]

View File

@ -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]

View File

@ -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]

View File

@ -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]

View File

@ -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]

View File

@ -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]

View File

@ -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]

View 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]

View File

@ -0,0 +1,3 @@
[<reference> help]-> [<frame> help menu]
[help menu]-> [<choice> 99. Exit]
[99. Exit]-> [<label> exit]

View File

@ -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 >[&lt;reference&gt; account management]-&gt; [&lt;input&gt; user input]
[user input]-&gt; [&lt;choice&gt; 1. My profile]
[1. My profile]-&gt; yes [&lt;reference&gt; user profile]
[1. My profile]-&gt; no [&lt;choice&gt; 2. Change language]
[2. Change language]-&gt; yes [&lt;reference&gt; choose language]
[2. Change language]-&gt; no [&lt;choice&gt; 3. Check balance]
[3. Check balance]-&gt; yes [&lt;reference&gt; mini statement inquiry pin authorization]
[3. Check balance]-&gt; no [&lt;choice&gt; 4. Change PIN]
[4. Change PIN]-&gt; yes [&lt;reference&gt; current pin]
[4. Change PIN]-&gt; no [&lt;choice&gt; 5. Opt out of market place]
[5. Opt out of market place]-&gt; yes [&lt;reference&gt; opt out of market place pin authorization]
[5. Opt out of market place]-&gt; no [&lt;choice&gt; 0. Back]
[0. Back]-&gt; yes [&lt;reference&gt; start menu]
[0. Back]-&gt; no [&lt;reference&gt; 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

View File

@ -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 >[&lt;reference&gt; bio change pin authorization]-&gt; [&lt;input&gt; Please enter your PIN]
[Please enter your PIN]-&gt; [&lt;choice&gt; is authorized]
[is authorized]-&gt; yes [save user information]
[save user information]-&gt; [&lt;reference&gt; complete]
[is authorized]-&gt; no [&lt;choice&gt; is blocked]
[is blocked]-&gt; yes [&lt;label&gt; exit pin blocked]
[is blocked]-&gt; no [increase failed attempts]
[increase failed attempts]-&gt; [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

View File

@ -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 >[&lt;reference&gt; choose language]-&gt; [&lt;input&gt; Choose language]
[Choose language]-&gt; [&lt;choice&gt; 1. English]
[1. English]-&gt; yes [change language to English]
[change language to English]-&gt; [&lt;reference&gt; complete]
[1. English]-&gt; no [&lt;choice&gt; 2. Kiswahili]
[2. Kiswahili]-&gt; yes [change language to Swahili]
[change language to Swahili]-&gt; [&lt;label&gt; complete]
[2. Kiswahili]-&gt; no [&lt;reference&gt; 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

View File

@ -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 >[&lt;reference&gt; change business prompt]-&gt; [&lt;input&gt; Enter your bio]
[Enter your bio]-&gt; [add bio to session data]
[add bio to session data]-&gt; [&lt;choice&gt;has empty name]
[has empty name]-&gt; yes [&lt;reference&gt; first name entry]
[has empty name]-&gt; no [&lt;choice&gt; has empty gender]
[has empty gender]-&gt; yes [&lt;reference&gt; gender entry]
[has empty gender]-&gt; no [&lt;choice&gt; has empty location]
[has empty location]-&gt; yes [&lt;reference&gt; location entry]
[has empty location]-&gt; no [&lt;reference&gt; 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

View 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 >[&lt;reference&gt; complete]-&gt; [&lt;input&gt; user input]
[user input]-&gt; [&lt;choice&gt; 00. Back]
[00. Back]-&gt; yes [&lt;reference&gt; start menu]
[00. Back]-&gt; no [&lt;choice&gt; 99. Exit]
[99. Exit]-&gt; [&lt;label&gt; 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

View File

@ -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 >[&lt;reference&gt; directory listing other]-&gt; [&lt;input&gt; menu option]
[menu option]-&gt; [&lt;choice&gt; 9. Show more]
[9. Show more] yes -&gt;[Set usage menu number]
[Set usage menu number]-&gt; [&lt;reference&gt; directory listing other]
[9. Show more]-&gt; no [&lt;choice&gt; 10. Show previous]
[10. Show previous]-&gt; yes [Set usage menu number]
[10. Show previous]-&gt; no [&lt;choice&gt; is valid other menu option]
[is valid other menu option]-&gt; yes [send directory listing]
[send directory listing]-&gt; [&lt;reference&gt; complete]
[is valid other menu option]-&gt; no [&lt;reference&gt;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

View File

@ -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 >[&lt;reference&gt; directory listing]-&gt; [&lt;input&gt; menu option]
[menu option]-&gt; [&lt;choice&gt; 9. Show more]
[9. Show more]-&gt; yes [Set usage menu number]
[Set usage menu number]-&gt; [&lt;reference&gt; directory listing other]
[9. Show more]-&gt; no [&lt;choice&gt;is valid menu option]
[is valid menu option]-&gt; yes [send directory listing]
[send directory listing]-&gt; [&lt;reference&gt; complete]
[is valid menu option]-&gt; no [&lt;reference&gt;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

View File

@ -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 >[&lt;reference&gt; send enter recipient]-&gt; [&lt;input&gt; Enter phone number]
[Enter phone number]-&gt; [&lt;choice&gt; is user]
[is user]-&gt; yes [save recipient phone number]
[save recipient phone number]-&gt; [&lt;reference&gt; send token amount]
[is user]-&gt; no [&lt;choice&gt; is token agent]
[is token agent]-&gt; yes [&lt;reference&gt; exit use exchange menu]
[is token agent]-&gt; no [&lt;reference&gt; 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

View File

@ -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 >[&lt;reference&gt; exchange token agent number entry]-&gt; [&lt;input&gt; Enter agent phone number]
[Enter agent phone number]-&gt; [&lt;choice&gt; is valid token agent]
[is valid token agent]-&gt; yes [save token agent phone number to session data]
[save token agent phone number to session data]-&gt; [&lt;reference&gt; exchange token amount entry]
[is valid token agent]-&gt; no [&lt;reference&gt; 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

View File

@ -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 >[&lt;reference&gt; exchange token amount entry]-&gt; [&lt;input&gt; Enter amount]
[Enter amount]-&gt; [&lt;choice&gt; is valid token amount]
[is valid token amount]-&gt; yes [save token amount to session data]
[save token amount to session data] -&gt; [&lt;reference&gt; exchange token pin authorization]
[is valid token amount]-&gt; no [&lt;label&gt; 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

View File

@ -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 >[&lt;reference&gt; exchange token confirmation]-&gt; [&lt;input&gt; menu option]
[menu option]-&gt; [&lt;choice&gt; 1.Confirm]
[1.Confirm]-&gt; yes [Process exchange request *(celery tasks)]
[Process exchange request *(celery tasks)]-&gt; [&lt;reference&gt; complete]
[1.Confirm]-&gt; no [&lt;choice&gt; 2. Cancel]
[2. Cancel]-&gt; yes [&lt;reference&gt; start menu]
[2. Cancel]-&gt; no [&lt;reference&gt; 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

Some files were not shown because too many files have changed in this diff Show More