diff --git a/.bandit_config b/.bandit_config new file mode 100644 index 00000000..ff68cb98 --- /dev/null +++ b/.bandit_config @@ -0,0 +1,156 @@ +### This config may optionally select a subset of tests to run or skip by +### filling out the 'tests' and 'skips' lists given below. If no tests are +### specified for inclusion then it is assumed all tests are desired. The skips +### set will remove specific tests from the include set. +### Note that the same test ID should not appear in both 'tests' and 'skips', +### this would be nonsensical and is detected by Bandit at runtime. + +# (optional) list included test IDs here, eg '[B101, B406]': +tests: + +# (optional) list skipped test IDs here, eg '[B101, B406]': +skips: + +### (optional) plugin settings - some test plugins require configuration data +### that may be given here, per-plugin. All bandit test plugins have a built in +### set of sensible defaults and these will be used if no configuration is +### provided. It is not necessary to provide settings for every (or any) plugin +### if the defaults are acceptable. + +any_other_function_with_shell_equals_true: + no_shell: [os.execl, os.execle, os.execlp, os.execlpe, os.execv, os.execve, os.execvp, + os.execvpe, os.spawnl, os.spawnle, os.spawnlp, os.spawnlpe, os.spawnv, os.spawnve, + os.spawnvp, os.spawnvpe, os.startfile] + shell: [os.system, os.popen, os.popen2, os.popen3, os.popen4, popen2.popen2, popen2.popen3, + popen2.popen4, popen2.Popen3, popen2.Popen4, commands.getoutput, commands.getstatusoutput] + subprocess: [subprocess.Popen, subprocess.call, subprocess.check_call, subprocess.check_output, + utils.execute, utils.execute_with_timeout] +execute_with_run_as_root_equals_true: + function_names: [ceilometer.utils.execute, cinder.utils.execute, neutron.agent.linux.utils.execute, + nova.utils.execute, nova.utils.trycmd] +hardcoded_tmp_directory: + tmp_dirs: [/tmp, /var/tmp, /dev/shm] +linux_commands_wildcard_injection: + no_shell: [os.execl, os.execle, os.execlp, os.execlpe, os.execv, os.execve, os.execvp, + os.execvpe, os.spawnl, os.spawnle, os.spawnlp, os.spawnlpe, os.spawnv, os.spawnve, + os.spawnvp, os.spawnvpe, os.startfile] + shell: [os.system, os.popen, os.popen2, os.popen3, os.popen4, popen2.popen2, popen2.popen3, + popen2.popen4, popen2.Popen3, popen2.Popen4, commands.getoutput, commands.getstatusoutput] + subprocess: [subprocess.Popen, subprocess.call, subprocess.check_call, subprocess.check_output, + utils.execute, utils.execute_with_timeout] +password_config_option_not_marked_secret: + function_names: [oslo.config.cfg.StrOpt, oslo_config.cfg.StrOpt] +ssl_with_bad_defaults: + bad_protocol_versions: [PROTOCOL_SSLv2, SSLv2_METHOD, SSLv23_METHOD, PROTOCOL_SSLv3, + PROTOCOL_TLSv1, SSLv3_METHOD, TLSv1_METHOD] +ssl_with_bad_version: + bad_protocol_versions: [PROTOCOL_SSLv2, SSLv2_METHOD, SSLv23_METHOD, PROTOCOL_SSLv3, + PROTOCOL_TLSv1, SSLv3_METHOD, TLSv1_METHOD] +start_process_with_a_shell: + no_shell: [os.execl, os.execle, os.execlp, os.execlpe, os.execv, os.execve, os.execvp, + os.execvpe, os.spawnl, os.spawnle, os.spawnlp, os.spawnlpe, os.spawnv, os.spawnve, + os.spawnvp, os.spawnvpe, os.startfile] + shell: [os.system, os.popen, os.popen2, os.popen3, os.popen4, popen2.popen2, popen2.popen3, + popen2.popen4, popen2.Popen3, popen2.Popen4, commands.getoutput, commands.getstatusoutput] + subprocess: [subprocess.Popen, subprocess.call, subprocess.check_call, subprocess.check_output, + utils.execute, utils.execute_with_timeout] +start_process_with_no_shell: + no_shell: [os.execl, os.execle, os.execlp, os.execlpe, os.execv, os.execve, os.execvp, + os.execvpe, os.spawnl, os.spawnle, os.spawnlp, os.spawnlpe, os.spawnv, os.spawnve, + os.spawnvp, os.spawnvpe, os.startfile] + shell: [os.system, os.popen, os.popen2, os.popen3, os.popen4, popen2.popen2, popen2.popen3, + popen2.popen4, popen2.Popen3, popen2.Popen4, commands.getoutput, commands.getstatusoutput] + subprocess: [subprocess.Popen, subprocess.call, subprocess.check_call, subprocess.check_output, + utils.execute, utils.execute_with_timeout] +start_process_with_partial_path: + no_shell: [os.execl, os.execle, os.execlp, os.execlpe, os.execv, os.execve, os.execvp, + os.execvpe, os.spawnl, os.spawnle, os.spawnlp, os.spawnlpe, os.spawnv, os.spawnve, + os.spawnvp, os.spawnvpe, os.startfile] + shell: [os.system, os.popen, os.popen2, os.popen3, os.popen4, popen2.popen2, popen2.popen3, + popen2.popen4, popen2.Popen3, popen2.Popen4, commands.getoutput, commands.getstatusoutput] + subprocess: [subprocess.Popen, subprocess.call, subprocess.check_call, subprocess.check_output, + utils.execute, utils.execute_with_timeout] +subprocess_popen_with_shell_equals_true: + no_shell: [os.execl, os.execle, os.execlp, os.execlpe, os.execv, os.execve, os.execvp, + os.execvpe, os.spawnl, os.spawnle, os.spawnlp, os.spawnlpe, os.spawnv, os.spawnve, + os.spawnvp, os.spawnvpe, os.startfile] + shell: [os.system, os.popen, os.popen2, os.popen3, os.popen4, popen2.popen2, popen2.popen3, + popen2.popen4, popen2.Popen3, popen2.Popen4, commands.getoutput, commands.getstatusoutput] + subprocess: [subprocess.Popen, subprocess.call, subprocess.check_call, subprocess.check_output, + utils.execute, utils.execute_with_timeout] +subprocess_without_shell_equals_true: + no_shell: [os.execl, os.execle, os.execlp, os.execlpe, os.execv, os.execve, os.execvp, + os.execvpe, os.spawnl, os.spawnle, os.spawnlp, os.spawnlpe, os.spawnv, os.spawnve, + os.spawnvp, os.spawnvpe, os.startfile] + shell: [os.system, os.popen, os.popen2, os.popen3, os.popen4, popen2.popen2, popen2.popen3, + popen2.popen4, popen2.Popen3, popen2.Popen4, commands.getoutput, commands.getstatusoutput] + subprocess: [subprocess.Popen, subprocess.call, subprocess.check_call, subprocess.check_output, + utils.execute, utils.execute_with_timeout] +try_except_continue: {check_typed_exception: false} +try_except_pass: {check_typed_exception: false} + +### Reference of Available tests: +# B101 : assert_used +# B102 : exec_used +# B103 : set_bad_file_permissions +# B104 : hardcoded_bind_all_interfaces +# B105 : hardcoded_password_string +# B106 : hardcoded_password_funcarg +# B107 : hardcoded_password_default +# B108 : hardcoded_tmp_directory +# B109 : password_config_option_not_marked_secret +# B110 : try_except_pass +# B111 : execute_with_run_as_root_equals_true +# B112 : try_except_continue +# B201 : flask_debug_true +# B301 : pickle +# B302 : marshal +# B303 : md5 +# B304 : ciphers +# B305 : cipher_modes +# B306 : mktemp_q +# B307 : eval +# B308 : mark_safe +# B309 : httpsconnection +# B310 : urllib_urlopen +# B311 : random +# B312 : telnetlib +# B313 : xml_bad_cElementTree +# B314 : xml_bad_ElementTree +# B315 : xml_bad_expatreader +# B316 : xml_bad_expatbuilder +# B317 : xml_bad_sax +# B318 : xml_bad_minidom +# B319 : xml_bad_pulldom +# B320 : xml_bad_etree +# B321 : ftplib +# B322 : input +# B401 : import_telnetlib +# B402 : import_ftplib +# B403 : import_pickle +# B404 : import_subprocess +# B405 : import_xml_etree +# B406 : import_xml_sax +# B407 : import_xml_expat +# B408 : import_xml_minidom +# B409 : import_xml_pulldom +# B410 : import_lxml +# B411 : import_xmlrpclib +# B412 : import_httpoxy +# B501 : request_with_no_cert_validation +# B502 : ssl_with_bad_version +# B503 : ssl_with_bad_defaults +# B504 : ssl_with_no_version +# B505 : weak_cryptographic_key +# B506 : yaml_load +# B601 : paramiko_calls +# B602 : subprocess_popen_with_shell_equals_true +# B603 : subprocess_without_shell_equals_true +# B604 : any_other_function_with_shell_equals_true +# B605 : start_process_with_a_shell +# B606 : start_process_with_no_shell +# B607 : start_process_with_partial_path +# B608 : hardcoded_sql_expressions +# B609 : linux_commands_wildcard_injection +# B701 : jinja2_autoescape_false +# B702 : use_of_mako_templates diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..e2e55be7 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "script/include"] + path = script/include + url = git@github.com:dod-ccpo/scriptz.git + branch = master diff --git a/.travis.yml b/.travis.yml index 9b5500d2..02dab0db 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,20 +2,28 @@ sudo: required language: python python: "3.6" services: docker +git: + submodules: false env: global: - TESTER_IMAGE_NAME=atst-tester - PROD_IMAGE_NAME=atst-prod before_install: + # Use sed to replace the SSH URL with the public URL + - sed -i 's/git@github.com:/https:\/\/github.com\//' .gitmodules + # Manually initialize submodules + - git submodule update --init --recursive + +before_script: - docker login -u $ATAT_DOCKER_REGISTRY_USERNAME -p $ATAT_DOCKER_REGISTRY_PASSWORD $ATAT_DOCKER_REGISTRY_URL - - docker build --tag "${TESTER_IMAGE_NAME}" . -f docker/tester/Dockerfile + - docker build --tag "${TESTER_IMAGE_NAME}" . -f deploy/docker/tester/Dockerfile script: - docker run "${TESTER_IMAGE_NAME}" before_deploy: - - docker build --tag "${PROD_IMAGE_NAME}" . -f docker/prod/Dockerfile + - docker build --tag "${PROD_IMAGE_NAME}" . -f deploy/docker/prod/Dockerfile - git_sha="$(git rev-parse --short HEAD)" - remote_image_name="${ATAT_DOCKER_REGISTRY_URL}/${PROD_IMAGE_NAME}:${git_sha}" - docker tag "${PROD_IMAGE_NAME}" "${remote_image_name}" diff --git a/Pipfile b/Pipfile index f772a51a..eb946fb9 100644 --- a/Pipfile +++ b/Pipfile @@ -19,6 +19,7 @@ ipython = "*" ipdb = "*" pylint = "*" black = "*" +pytest-watch = "*" [requires] python_version = "3.6" diff --git a/Pipfile.lock b/Pipfile.lock index 2cf47dcc..2fadae1e 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "53a2048f5dc853a0ac2d565164c23dcdb2df802e055c1530864bfc8390af3c3e" + "sha256": "3bea02ccdb0e3877f2595d7fb405408114ec8947e0484d5b4aaf14a4c8ff78b2" }, "pipfile-spec": 6, "requires": { @@ -119,6 +119,13 @@ "markers": "sys_platform == 'darwin'", "version": "==0.1.0" }, + "argh": { + "hashes": [ + "sha256:a9b3aaa1904eeb78e32394cd46c6f37ac0fb4af6dc488daa58971bdc7d7fcaf3", + "sha256:e9535b8c84dc9571a48999094fda7f33e63c3f1b74f3e5f3ac0105a58405bb65" + ], + "version": "==0.26.2" + }, "astroid": { "hashes": [ "sha256:a8d8c7fe34e34e868426b9bafce852c355a3951eef60bc831b2ed541558f8d37", @@ -170,6 +177,13 @@ ], "version": "==6.7" }, + "colorama": { + "hashes": [ + "sha256:463f8483208e921368c9f306094eb6f725c6ca42b0f97e313cb5d5512459feda", + "sha256:48eb22f4f8461b1df5734a074b57042430fb06e1d61bd1e11b078c0fe6d7a1f1" + ], + "version": "==0.3.9" + }, "decorator": { "hashes": [ "sha256:2c51dff8ef3c447388fe5e4453d24a2bf128d3a4c32af3fabef1f01c6851ab82", @@ -177,6 +191,12 @@ ], "version": "==4.3.0" }, + "docopt": { + "hashes": [ + "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491" + ], + "version": "==0.6.2" + }, "gitdb2": { "hashes": [ "sha256:b60e29d4533e5e25bb50b7678bbc187c8f6bcff1344b4f293b2ba55c85795f09", @@ -280,10 +300,16 @@ }, "parso": { "hashes": [ - "sha256:8105449d86d858e53ce3e0044ede9dd3a395b1c9716c696af8aa3787158ab806", - "sha256:d250235e52e8f9fc5a80cc2a5f804c9fefd886b2e67a2b1099cf085f403f8e33" + "sha256:35704a43a3c113cce4de228ddb39aab374b8004f4f2407d070b6a2ca784ce8a2", + "sha256:895c63e93b94ac1e1690f5fdd40b65f07c8171e3e53cbd7793b5b96c0e0a7f24" ], - "version": "==0.3.0" + "version": "==0.3.1" + }, + "pathtools": { + "hashes": [ + "sha256:7c35c5421a39bb82e58018febd90e3b6e5db34c5443aaaf742b3f33d4655f1c0" + ], + "version": "==0.1.2" }, "pbr": { "hashes": [ @@ -370,6 +396,13 @@ "index": "pypi", "version": "==0.5.0" }, + "pytest-watch": { + "hashes": [ + "sha256:06136f03d5b361718b8d0d234042f7b2f203910d8568f63df2f866b547b3d4b9" + ], + "index": "pypi", + "version": "==4.2.0" + }, "pyyaml": { "hashes": [ "sha256:254bf6fda2b7c651837acb2c718e213df29d531eebf00edb54743d10bcb694eb", @@ -460,6 +493,12 @@ "markers": "python_version < '3.7' and implementation_name == 'cpython'", "version": "==1.1.0" }, + "watchdog": { + "hashes": [ + "sha256:7e65882adb7746039b6f3876ee174952f8eaaa34491ba34333ddf1fe35de4162" + ], + "version": "==0.8.3" + }, "wcwidth": { "hashes": [ "sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e", diff --git a/README.md b/README.md index 88698d19..fbd0daf9 100644 --- a/README.md +++ b/README.md @@ -3,35 +3,74 @@ [](https://travis-ci.org/dod-ccpo/atst) +## Description + +This is the main user-facing web application for the ATAT stack. All end-user +requests are handled by ATST, with it making backend calls to various +microservices when appropriate. + ## Installation +### Requirements +See the [scriptz](https://github.com/dod-ccpo/scriptz) repository for the shared +requirements and guidelines for all ATAT applications. +Additionally, ATST requires a redis instance for session management. Have redis +installed and running. By default, ATST will try to connect to a redis instance +running on localhost on its default port, 6379. + +### Cloning +This project contains git submodules. Here is an example clone command that will +automatically initialize and update those modules: + + git clone --recurse-submodules git@github.com:dod-ccpo/atst.git + +If you have an existing clone that does not yet contain the submodules, you can +set them up with the following command: + + git submodule update --init --recursive + +### Setup +This application uses Pipenv to manage Python dependencies and a virtual +environment. Instead of the classic `requirements.txt` file, pipenv uses a +Pipfile and Pipfile.lock, making it more similar to other modern package managers +like yarn or mix. + +To perform the installation, run the setup script: + script/setup -The setup script installs pipenv, which is what this application uses to manage its dependences and virtualenv. Instead of the classic `requirements.txt` file, pipenv uses a Pipfile and Pipfile.lock, making it more similar to other modern package managers like yarn or mix. +The setup script creates the virtual environment, and then calls script/bootstrap +to install all of the Python and Node dependencies. To enter the virtualenv manually (a la `source .venv/bin/activate`): pipenv shell -If you want to automatically load the virtual environment whenever you enter the project directory, take a look at [direnv](https://direnv.net/). An `.envrc` file is included in this repository. direnv will activate and deactivate virtualenvs for you when you enter and leave the directory. +If you want to automatically load the virtual environment whenever you enter the +project directory, take a look at [direnv](https://direnv.net/). An `.envrc` +file is included in this repository. direnv will activate and deactivate +virtualenvs for you when you enter and leave the directory. -Additionally, ATST requires a redis instance for session management. Have redis installed and running. By default, ATST will try to connect to a redis instance running on localhost on its default port, 6379. ## Running (development) -To start the app and watch for changes: +To start the app locally in the foreground and watch for changes: - DEBUG=1 script/server + script/dev_server ## Testing -To run unit tests: +To run lint, static analysis, and unit tests: script/test -or +To run only the unit tests: - python -m pytest + pipenv run python -m pytest + +To re-run tests each time a file is changed: + + pipenv run ptw ## Notes diff --git a/atst/app.py b/atst/app.py index 094a61b1..ed1be4a6 100644 --- a/atst/app.py +++ b/atst/app.py @@ -4,12 +4,13 @@ import tornado.web from tornado.web import url from redis import StrictRedis -from atst.handlers.main import MainHandler -from atst.handlers.home import Home -from atst.handlers.login import Login +from atst.handlers.main import Main +from atst.handlers.root import Root +from atst.handlers.login_redirect import LoginRedirect from atst.handlers.workspace import Workspace from atst.handlers.request import Request from atst.handlers.request_new import RequestNew +from atst.handlers.request_submit import RequestsSubmit from atst.handlers.dev import Dev from atst.home import home from atst.api_client import ApiClient @@ -21,23 +22,23 @@ ENV = os.getenv("TORNADO_ENV", "dev") def make_app(config, deps, **kwargs): routes = [ - url(r"/", Home, {"page": "login"}, name="main"), + url(r"/", Root, {"page": "root"}, name="root"), url( - r"/login", - Login, + r"/login-redirect", + LoginRedirect, {"sessions": deps["sessions"], "authnid_client": deps["authnid_client"]}, - name="login", + name="login_redirect", ), - url(r"/home", MainHandler, {"page": "home"}, name="home"), + url(r"/home", Main, {"page": "home"}, name="home"), url( r"/styleguide", - MainHandler, + Main, {"page": "styleguide"}, name="styleguide", ), url( r"/workspaces/blank", - MainHandler, + Main, {"page": "workspaces_blank"}, name="workspaces_blank", ), @@ -71,9 +72,15 @@ def make_app(config, deps, **kwargs): {"page": "requests_new", "requests_client": deps["requests_client"]}, name="request_form_update", ), - url(r"/users", MainHandler, {"page": "users"}, name="users"), - url(r"/reports", MainHandler, {"page": "reports"}, name="reports"), - url(r"/calculator", MainHandler, {"page": "calculator"}, name="calculator"), + url( + r"/requests/submit/(\S+)", + RequestsSubmit, + {"requests_client": deps["requests_client"]}, + name="requests_submit", + ), + url(r"/users", Main, {"page": "users"}, name="users"), + url(r"/reports", Main, {"page": "reports"}, name="reports"), + url(r"/calculator", Main, {"page": "calculator"}, name="calculator"), ] if not ENV == "production": diff --git a/atst/forms/financial.py b/atst/forms/financial.py index 23e6a27d..7f8e37e0 100644 --- a/atst/forms/financial.py +++ b/atst/forms/financial.py @@ -15,10 +15,14 @@ class FinancialForm(Form): "Unique Item Identifier (UII)s related to your application(s) if you already have them." ) - pe_id = NewlineListField( - "Program Element (PE) Numbers related to your request" + pe_id = StringField( + "Program Element (PE) Number related to your request" ) + treasury_code = StringField("Please provide your Program Treasury Code") + + ba_code = StringField("Please provide your Program BA Code") + fname_co = StringField("Contracting Officer First Name", validators=[Required()]) lname_co = StringField("Contracting Officer Last Name", validators=[Required()]) diff --git a/atst/forms/request.py b/atst/forms/request.py index 0029a22e..e6af039e 100644 --- a/atst/forms/request.py +++ b/atst/forms/request.py @@ -2,7 +2,7 @@ from wtforms.fields.html5 import IntegerField from wtforms.fields import RadioField, StringField, TextAreaField from wtforms.validators import NumberRange, InputRequired from wtforms_tornado import Form -from .fields import DateField, NewlineListField +from .fields import DateField from .validators import DateRange import pendulum diff --git a/atst/handler.py b/atst/handler.py index 2ad1acaf..ac6dc4b4 100644 --- a/atst/handler.py +++ b/atst/handler.py @@ -23,6 +23,7 @@ class BaseHandler(tornado.web.RequestHandler): try: session = self.application.sessions.get_session(cookie) except SessionNotFoundError: + self.clear_cookie("atat") return None else: return None diff --git a/atst/handlers/login.py b/atst/handlers/login_redirect.py similarity index 96% rename from atst/handlers/login.py rename to atst/handlers/login_redirect.py index 4e8b6f2d..9fa9998f 100644 --- a/atst/handlers/login.py +++ b/atst/handlers/login_redirect.py @@ -2,7 +2,7 @@ import tornado from atst.handler import BaseHandler -class Login(BaseHandler): +class LoginRedirect(BaseHandler): def initialize(self, authnid_client, sessions): self.authnid_client = authnid_client self.sessions = sessions diff --git a/atst/handlers/main.py b/atst/handlers/main.py index 025c3c53..737d3bd7 100644 --- a/atst/handlers/main.py +++ b/atst/handlers/main.py @@ -2,7 +2,7 @@ import tornado from atst.handler import BaseHandler -class MainHandler(BaseHandler): +class Main(BaseHandler): def initialize(self, page): self.page = page diff --git a/atst/handlers/request_new.py b/atst/handlers/request_new.py index fbf51f98..d15a1ef4 100644 --- a/atst/handlers/request_new.py +++ b/atst/handlers/request_new.py @@ -1,43 +1,15 @@ import tornado +from collections import defaultdict + from atst.handler import BaseHandler from atst.forms.request import RequestForm from atst.forms.org import OrgForm from atst.forms.poc import POCForm from atst.forms.review import ReviewForm from atst.forms.financial import FinancialForm -import tornado.httputil class RequestNew(BaseHandler): - screens = [ - { - "title": "Details of Use", - "section": "details_of_use", - "form": RequestForm, - "subitems": [ - {"title": "Overall request details", "id": "overall-request-details"}, - {"title": "Cloud Resources", "id": "cloud-resources"}, - {"title": "Support Staff", "id": "support-staff"}, - ], - }, - { - "title": "Information About You", - "section": "information_about_you", - "form": OrgForm, - }, - { - "title": "Primary Point of Contact", - "section": "primary_poc", - "form": POCForm, - }, - {"title": "Review & Submit", "section": "review_submit", "form": ReviewForm}, - { - "title": "Financial Verification", - "section": "financial_verification", - "form": FinancialForm, - }, - ] - def initialize(self, page, requests_client): self.page = page self.requests_client = requests_client @@ -47,58 +19,62 @@ class RequestNew(BaseHandler): def post(self, screen=1, request_id=None): self.check_xsrf_cookie() screen = int(screen) - form_metadata = self.screens[screen - 1] - form_section = form_metadata["section"] - form = form_metadata["form"](self.request.arguments) + post_data = self.request.arguments + jedi_flow = JEDIRequestFlow( + self.requests_client, screen, post_data=post_data, request_id=request_id + ) - if form.validate(): - response = yield self.create_or_update_request( - form_section, form.data, request_id - ) + if jedi_flow.validate(): + response = yield jedi_flow.create_or_update_request(self.get_current_user()) if response.ok: where = self.application.default_router.reverse_url( - "request_form_update", - str(screen + 1), - request_id or response.json["id"], + "request_form_update", str(screen + 1), jedi_flow.request_id ) self.redirect(where) else: self.set_status(response.code) else: - self.show_form(screen, form) + self.render( + "requests/screen-%d.html.to" % int(screen), + f=jedi_flow.form, + data=post_data, + page=self.page, + screens=jedi_flow.screens, + current=screen, + next_screen=jedi_flow.next_screen, + request_id=jedi_flow.request_id, + ) @tornado.web.authenticated @tornado.gen.coroutine def get(self, screen=1, request_id=None): - form = None - form_data = None - is_review_section = screen == 4 + screen = int(screen) + request = None if request_id: - request = yield self.get_request(request_id) - if request.ok: - if is_review_section: - form_data = request.json["body"] - else: - form_metadata = self.screens[int(screen) - 1] - section = form_metadata["section"] - form_data = request.json["body"].get(section, request.json["body"]) - form = form_metadata["form"](data=form_data) + response = yield self.requests_client.get( + "/users/{}/requests/{}".format( + self.get_current_user()["id"], request_id + ), + raise_error=False, + ) + if response.ok: + request = response.json - self.show_form(screen=screen, form=form, request_id=request_id, data=form_data) + jedi_flow = JEDIRequestFlow( + self.requests_client, screen, request, request_id=request_id + ) - def show_form(self, screen=1, form=None, request_id=None, data=None): - if not form: - form = self.screens[int(screen) - 1]["form"](self.request.arguments) self.render( "requests/screen-%d.html.to" % int(screen), - f=form, - data=data, + f=jedi_flow.form, + data=jedi_flow.current_step_data, page=self.page, - screens=self.screens, - current=int(screen), - next_screen=int(screen) + 1, + screens=jedi_flow.screens, + current=screen, + next_screen=screen + 1, request_id=request_id, + can_submit=jedi_flow.can_submit ) @tornado.gen.coroutine @@ -109,16 +85,128 @@ class RequestNew(BaseHandler): ) return request + +class JEDIRequestFlow(object): + def __init__( + self, + requests_client, + current_step, + request=None, + post_data=None, + request_id=None, + ): + self.requests_client = requests_client + + self.current_step = current_step + self.request = request + + self.post_data = post_data + self.is_post = self.post_data is not None + + self.request_id = request_id + self.form = self._form() + + def _form(self): + if self.is_post: + return self.form_class()(self.post_data) + elif self.request: + return self.form_class()(data=self.current_step_data) + else: + return self.form_class()() + + def validate(self): + return self.form.validate() + + @property + def current_screen(self): + return self.screens[self.current_step - 1] + + @property + def form_section(self): + return self.current_screen["section"] + + def form_class(self): + return self.current_screen["form"] + + @property + def current_step_data(self): + data = {} + + if self.is_post: + data = self.post_data + + if self.request: + if self.form_section == "review_submit": + data = self.request["body"] + else: + data = self.request["body"].get(self.form_section, {}) + + return defaultdict(lambda: defaultdict(lambda: 'Input required'), data) + + @property + def can_submit(self): + return self.request and self.request["status"] != "incomplete" + + @property + def next_screen(self): + return self.current_step + 1 + + @property + def screens(self): + return [ + { + "title": "Details of Use", + "section": "details_of_use", + "form": RequestForm, + "subitems": [ + { + "title": "Overall request details", + "id": "overall-request-details", + }, + {"title": "Cloud Resources", "id": "cloud-resources"}, + {"title": "Support Staff", "id": "support-staff"}, + ], + "show": True, + }, + { + "title": "Information About You", + "section": "information_about_you", + "form": OrgForm, + "show": True, + }, + { + "title": "Primary Point of Contact", + "section": "primary_poc", + "form": POCForm, + "show": True, + }, + { + "title": "Review & Submit", + "section": "review_submit", + "form": ReviewForm, + "show":True, + }, + { + "title": "Financial Verification", + "section": "financial_verification", + "form": FinancialForm, + "show": self.request and self.request["status"] == "approved", + }, + ] + @tornado.gen.coroutine - def create_or_update_request(self, form_section, form_data, request_id=None): + def create_or_update_request(self, user): request_data = { - "creator_id": self.get_current_user()["id"], - "request": {form_section: form_data}, + "creator_id": user["id"], + "request": {self.form_section: self.form.data}, } - if request_id: + if self.request_id: response = yield self.requests_client.patch( - "/requests/{}".format(request_id), json=request_data + "/requests/{}".format(self.request_id), json=request_data ) else: response = yield self.requests_client.post("/requests", json=request_data) + self.request = response.json + self.request_id = self.request["id"] + return response diff --git a/atst/handlers/request_submit.py b/atst/handlers/request_submit.py new file mode 100644 index 00000000..ed842449 --- /dev/null +++ b/atst/handlers/request_submit.py @@ -0,0 +1,17 @@ +import tornado + +from atst.handler import BaseHandler + + +class RequestsSubmit(BaseHandler): + def initialize(self, requests_client): + self.requests_client = requests_client + + @tornado.web.authenticated + @tornado.gen.coroutine + def post(self, request_id): + yield self.requests_client.post( + "/requests/{}/submit".format(request_id), + allow_nonstandard_methods=True + ) + self.redirect("/requests") diff --git a/atst/handlers/home.py b/atst/handlers/root.py similarity index 87% rename from atst/handlers/home.py rename to atst/handlers/root.py index 483bd161..ec68bfcc 100644 --- a/atst/handlers/home.py +++ b/atst/handlers/root.py @@ -1,7 +1,7 @@ from atst.handler import BaseHandler -class Home(BaseHandler): +class Root(BaseHandler): def initialize(self, page): self.page = page diff --git a/docker/prod/Dockerfile b/deploy/docker/prod/Dockerfile similarity index 100% rename from docker/prod/Dockerfile rename to deploy/docker/prod/Dockerfile diff --git a/docker/tester/Dockerfile b/deploy/docker/tester/Dockerfile similarity index 86% rename from docker/tester/Dockerfile rename to deploy/docker/tester/Dockerfile index 30c6cc11..c490c6ec 100644 --- a/docker/tester/Dockerfile +++ b/deploy/docker/tester/Dockerfile @@ -23,12 +23,12 @@ RUN set -x ; \ # Set working dir WORKDIR ${APP_DIR} -# Copy over alpine setup script -COPY script/alpine_setup ./script/ +# Copy over setup scripts +COPY script/ ./script/ # Add required system packages and app user RUN set -x ; \ - script/alpine_setup "${APP_USER}" "${APP_GROUP}" + script/alpine_setup ### Items that will change almost every build ############################################# diff --git a/script/alpine_setup b/script/alpine_setup index c029d19b..28f836c2 100755 --- a/script/alpine_setup +++ b/script/alpine_setup @@ -3,20 +3,11 @@ # script/alpine_setup: Adds all the system packages, directors, users, etc. # required to run the application on Alpine -# If a command fails, exit the script -set -e +source "$(dirname "${0}")"/../script/include/global_header.inc.sh -# Ensure we are in the app root directory (not the /script directory) -cd "$(dirname "${0}")/.." +# Set app specific items +APP_USER="atst" +APP_UID="8010" -APP_USER=${1} -APP_GROUP=${2} - -apk update -apk upgrade - -apk add bash -apk add dumb-init - -addgroup -g 8000 -S "${APP_GROUP}" -adduser -u 8010 -D -S -G "${APP_GROUP}" "${APP_USER}" +# Run the shared alpine setup script +source ./script/include/run_alpine_setup diff --git a/script/bootstrap b/script/bootstrap index 78f9391f..e30b69da 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -3,33 +3,18 @@ # script/bootstrap: Resolve all dependencies that the application requires to # run. -# If a command fails, exit the script -set -e +source "$(dirname "${0}")"/../script/include/global_header.inc.sh -# Ensure we are in the app root directory (not the /script directory) -cd "$(dirname "${0}")/.." +# Set sass compiling command for this app +COMPILE_SASS_CMD="webassets -m atst.assets build" -if [ -z "${CIBUILD+xxxx}" ]; then - CMD_PREFIX='pipenv run ' -fi -PIP_CMD="${CMD_PREFIX}pip" -WEBASSETS_CMD="${CMD_PREFIX}webassets" +# Enable python and node package installation +INSTALL_PYTHON_PACKAGES="true" +INSTALL_NODE_PACKAGES="true" -PIPENV_INSTALL_FLAGS='--dev' -if [ -n "${CIBUILD}" ]; then - PIPENV_INSTALL_FLAGS+=' --system --ignore-pipfile' -fi +# Run the shared bootstrap script +source ./script/include/run_bootstrap -# Install Python dependencies -${PIP_CMD} install --upgrade pip -pipenv install ${PIPENV_INSTALL_FLAGS} - -# Install uswds node module and dependencies -npm install - -# Relink uswds fonts into the /static directory +# Link USWDS fonts into the /static directory rm -f ./static/fonts -ln -s ../node_modules/uswds/src/fonts ./static/fonts - -# Precompile assets for deployment -${WEBASSETS_CMD} -m atst.assets build +ln -s ../node/modules/uswds/src/fonts ./static/fonts diff --git a/script/cibuild b/script/cibuild index c9fd2975..e9d01564 100755 --- a/script/cibuild +++ b/script/cibuild @@ -2,15 +2,7 @@ # script/cibuild: Run CI related checks and tests -# If a command fails, exit the script -set -e - -# Ensure we are in the app root directory (not the /script directory) -cd "$(dirname "${0}")/.." +source "$(dirname "${0}")"/../script/include/global_header.inc.sh # Run lint/style checks and unit tests -script/test - -# Run static code analysis security checks -# (excluding the tests and node_modules subdirs) -bandit -r . -x node_modules,tests +source ./script/test diff --git a/script/dev_server b/script/dev_server new file mode 100755 index 00000000..1f5f75a1 --- /dev/null +++ b/script/dev_server @@ -0,0 +1,32 @@ +#!/bin/bash + +# script/dev_server: Launch a local dev version of the server in the background + +# +# WIP +# + +source "$(dirname "${0}")"/../script/include/global_header.inc.sh + +# Create a function to run after a trap is triggered +reap() { + kill -s SIGTERM -- "-$$" + sleep 0.1 + exit +} + +# Register trapping of SIGTERM and SIGINT +trap reap SIGTERM SIGINT + +# Display the script PID, which will also be the process group ID for all +# child processes +echo "Process Group: $$" + +# Set server launch related environment variables +DEBUG=1 +LAUNCH_ARGS="$*" + + +# Launch the app +source ./script/server & +wait diff --git a/script/include b/script/include new file mode 160000 index 00000000..7417942f --- /dev/null +++ b/script/include @@ -0,0 +1 @@ +Subproject commit 7417942f1614d6a7ad94e94d1621dca9b422dec2 diff --git a/script/server b/script/server index fa78012d..47e7cbaa 100755 --- a/script/server +++ b/script/server @@ -1,26 +1,8 @@ #!/bin/bash -reap() { - kill -TERM $child - sleep 0.1 - exit -} +# script/server: Launch the server -trap reap TERM INT - -# If a command fails, exit the script -set -e - -# Ensure we are in the app root directory (not the /script directory) -cd "$(dirname "${0}")/.." - -if [ -z "${SKIP_PIPENV+xxxx}" ]; then - CMD_PREFIX='pipenv run ' -fi -PYTHON_CMD="${CMD_PREFIX}python" +source "$(dirname "${0}")"/../script/include/global_header.inc.sh # Launch the app -${PYTHON_CMD} app.py ${@} & -child=$! - -wait $child +run_command "./app.py ${LAUNCH_ARGS}" diff --git a/script/setup b/script/setup index 0fae92e6..925ced94 100755 --- a/script/setup +++ b/script/setup @@ -3,24 +3,10 @@ # script/setup: Set up application for the first time after cloning, or set it # back to the initial first unused state. -# If a command fails, exit the script -set -e +source "$(dirname "${0}")"/../script/include/global_header.inc.sh -# Ensure we are in the app root directory (not the /script directory) -cd "$(dirname "${0}")/.." +# Turn on sass compiler installation +INSTALL_SASS="true" -# Install virtualenv -pip install pipenv -pipenv --python 3.6 - -if ! type sass > /dev/null; then - if type gem > /dev/null; then - echo 'installing a sass compiler...' - gem install sass - else - echo 'Could not install a sass compiler. Please install a version of sass.' - fi -fi - -# Install application dependencies -script/bootstrap +# Run the shared setup script +source ./script/include/run_setup diff --git a/script/test b/script/test index fe2d7ecb..1f6c3380 100755 --- a/script/test +++ b/script/test @@ -2,22 +2,13 @@ # script/test: Run static code checks and unit tests -# If a command fails, exit the script -set -e +source "$(dirname "${0}")"/../script/include/global_header.inc.sh -# Ensure we are in the app root directory (not the /script directory) -cd "$(dirname "${0}")/.." +# Define all relevant python files and directories for this app +PYTHON_FILES="./app.py ./atst ./config" -if [ -z "${SKIP_PIPENV+xxxx}" ]; then - CMD_PREFIX='pipenv run ' -fi -PYLINT_CMD="${CMD_PREFIX}pylint" -PYTHON_CMD="${CMD_PREFIX}python" +# Enable Python testing +RUN_PYTHON_TESTS="true" -# Run lint check -echo "Running lint..." -${PYLINT_CMD} app.py atst/ tests/ - -# Run unit tests -echo "Running unit tests..." -${PYTHON_CMD} -m pytest -s $* +# Run the shared test script +source ./script/include/run_test diff --git a/script/update b/script/update index 752ad3ad..8cb772c1 100755 --- a/script/update +++ b/script/update @@ -1,10 +1,8 @@ #!/bin/bash -# If a command fails, exit the script -set -e +# script/update: Update dependencies -# Ensure we are in the app root directory (not the /script directory) -cd "$(dirname "${0}")/.." +source "$(dirname "${0}")"/../script/include/global_header.inc.sh -# Update dependencies -script/bootstrap +# Run the bootstrap script +source ./script/bootstrap diff --git a/scss/elements/_panels.scss b/scss/elements/_panels.scss index 04bf75f9..baff7f4a 100644 --- a/scss/elements/_panels.scss +++ b/scss/elements/_panels.scss @@ -47,6 +47,9 @@ @include media($medium-screen) { @include margin(($site-margins * 2) null); + } + + @include media($large-screen) { flex-wrap: nowrap; } diff --git a/scss/sections/_sidenav.scss b/scss/sections/_sidenav.scss index d695d742..4415c3ae 100644 --- a/scss/sections/_sidenav.scss +++ b/scss/sections/_sidenav.scss @@ -79,6 +79,10 @@ > ul { @include panel-margin; + &:last-child { + margin: 0; + } + > li { &:last-child { > .sidenav__link { diff --git a/templates/requests/screen-4.html.to b/templates/requests/screen-4.html.to index d60dd907..22da27a0 100644 --- a/templates/requests/screen-4.html.to +++ b/templates/requests/screen-4.html.to @@ -1,5 +1,9 @@ {% extends '../requests_new.html.to' %} +{% block form_action %} +