diff --git a/atst/app.py b/atst/app.py index 625e2a74..393edbc5 100644 --- a/atst/app.py +++ b/atst/app.py @@ -58,19 +58,31 @@ def make_app(config, deps, **kwargs): url( r"/requests/new", RequestNew, - {"page": "requests_new", "requests_client": deps["requests_client"]}, + { + "page": "requests_new", + "requests_client": deps["requests_client"], + "fundz_client": deps["fundz_client"], + }, name="request_new", ), url( r"/requests/new/([0-9])", RequestNew, - {"page": "requests_new", "requests_client": deps["requests_client"]}, + { + "page": "requests_new", + "requests_client": deps["requests_client"], + "fundz_client": deps["fundz_client"], + }, name="request_form_new", ), url( r"/requests/new/([0-9])/(\S+)", RequestNew, - {"page": "requests_new", "requests_client": deps["requests_client"]}, + { + "page": "requests_new", + "requests_client": deps["requests_client"], + "fundz_client": deps["fundz_client"], + }, name="request_form_update", ), url( @@ -128,6 +140,10 @@ def make_deps(config): api_version="v1", validate_cert=validate_cert, ), + "fundz_client": ApiClient( + config["default"]["FUNDZ_BASE_URL"], + validate_cert=validate_cert, + ), "requests_client": ApiClient( config["default"]["REQUESTS_QUEUE_BASE_URL"], api_version="v1", diff --git a/atst/forms/financial.py b/atst/forms/financial.py index da4915f3..77811ee1 100644 --- a/atst/forms/financial.py +++ b/atst/forms/financial.py @@ -1,12 +1,40 @@ +import tornado +from tornado.gen import Return from wtforms.fields.html5 import EmailField from wtforms.fields import StringField, SelectField +from wtforms.form import Form from wtforms.validators import Required, Email -from wtforms_tornado import Form from .fields import NewlineListField +from .forms import ValidatedForm -class FinancialForm(Form): +@tornado.gen.coroutine +def validate_pe_id(field, existing_request, fundz_client): + response = yield fundz_client.get( + "/pe-number/{}".format(field.data), + raise_error=False, + ) + if not response.ok: + field.errors.append( + "We couldn't find that PE number, but if you have double checked " + "it you can submit anyway. Your request will need to go through a " + "manual review." + ) + return False + + return True + + +class FinancialForm(ValidatedForm): + + @tornado.gen.coroutine + def perform_extra_validation(self, existing_request, fundz_client): + valid = True + if not existing_request or existing_request.get('pe_id') != self.pe_id.data: + valid = yield validate_pe_id(self.pe_id, existing_request, fundz_client) + raise Return(valid) + task_order_id = StringField( "Task Order Number associated with this request.", validators=[Required()] ) diff --git a/atst/forms/forms.py b/atst/forms/forms.py new file mode 100644 index 00000000..c3ea02c4 --- /dev/null +++ b/atst/forms/forms.py @@ -0,0 +1,12 @@ +import tornado +from tornado.gen import Return +from wtforms_tornado import Form + + +class ValidatedForm(Form): + + @tornado.gen.coroutine + def perform_extra_validation(self, *args, **kwargs): + """A coroutine that performs any applicable extra validation. Must + return True if the form is valid or False otherwise.""" + raise Return(True) diff --git a/atst/forms/org.py b/atst/forms/org.py index 50d80123..80bb5722 100644 --- a/atst/forms/org.py +++ b/atst/forms/org.py @@ -1,13 +1,13 @@ from wtforms.fields.html5 import EmailField, TelField from wtforms.fields import RadioField, StringField from wtforms.validators import Required, Email -from wtforms_tornado import Form import pendulum from .fields import DateField +from .forms import ValidatedForm from .validators import DateRange, PhoneNumber, Alphabet -class OrgForm(Form): +class OrgForm(ValidatedForm): fname_request = StringField("First Name", validators=[Required(), Alphabet()]) lname_request = StringField("Last Name", validators=[Required(), Alphabet()]) diff --git a/atst/forms/poc.py b/atst/forms/poc.py index 5d64182d..cd57add1 100644 --- a/atst/forms/poc.py +++ b/atst/forms/poc.py @@ -1,10 +1,10 @@ from wtforms.fields import StringField from wtforms.validators import Required, Email, Length -from wtforms_tornado import Form +from .forms import ValidatedForm from .validators import IsNumber, Alphabet -class POCForm(Form): +class POCForm(ValidatedForm): fname_poc = StringField("POC First Name", validators=[Required(), Alphabet()]) lname_poc = StringField("POC Last Name", validators=[Required(), Alphabet()]) diff --git a/atst/forms/request.py b/atst/forms/request.py index e6af039e..6c89aeb2 100644 --- a/atst/forms/request.py +++ b/atst/forms/request.py @@ -1,13 +1,13 @@ 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 +from .forms import ValidatedForm from .validators import DateRange import pendulum -class RequestForm(Form): +class RequestForm(ValidatedForm): # Details of Use: Overall Request Details dollar_value = IntegerField( diff --git a/atst/forms/review.py b/atst/forms/review.py index b3cd2a21..68b6f198 100644 --- a/atst/forms/review.py +++ b/atst/forms/review.py @@ -1,6 +1,7 @@ from wtforms.fields import BooleanField -from wtforms_tornado import Form + +from .forms import ValidatedForm -class ReviewForm(Form): +class ReviewForm(ValidatedForm): reviewed = BooleanField("I have reviewed this data and it is correct.") diff --git a/atst/handlers/request_new.py b/atst/handlers/request_new.py index 59092ddf..096572d3 100644 --- a/atst/handlers/request_new.py +++ b/atst/handlers/request_new.py @@ -10,9 +10,17 @@ from atst.forms.financial import FinancialForm class RequestNew(BaseHandler): - def initialize(self, page, requests_client): + def initialize(self, page, requests_client, fundz_client): self.page = page self.requests_client = requests_client + self.fundz_client = fundz_client + + @tornado.gen.coroutine + def get_existing_request(self, request_id): + if request_id is None: + return {} + request = yield self.requests_client.get("/requests/{}".format(request_id)) + return request.json @tornado.web.authenticated @tornado.gen.coroutine @@ -20,29 +28,51 @@ class RequestNew(BaseHandler): self.check_xsrf_cookie() screen = int(screen) post_data = self.request.arguments + current_user = self.get_current_user() + existing_request = yield self.get_existing_request(request_id) jedi_flow = JEDIRequestFlow( - self.requests_client, screen, post_data=post_data, request_id=request_id + self.requests_client, + self.fundz_client, + screen, + post_data=post_data, + request_id=request_id, + current_user=current_user, + existing_request=existing_request, + ) + + rerender_args = dict( + 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, ) if jedi_flow.validate(): - response = yield jedi_flow.create_or_update_request(self.get_current_user()) + response = yield jedi_flow.create_or_update_request() if response.ok: - where = self.application.default_router.reverse_url( - "request_form_update", str(screen + 1), jedi_flow.request_id - ) - self.redirect(where) + valid = yield jedi_flow.validate_warnings() + if valid: + if jedi_flow.next_screen >= len(jedi_flow.screens): + where = "/requests" + else: + where = self.application.default_router.reverse_url( + "request_form_update", jedi_flow.next_screen, jedi_flow.request_id + ) + self.redirect(where) + else: + self.render( + "requests/screen-%d.html.to" % int(screen), + **rerender_args + ) else: self.set_status(response.code) else: 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, + **rerender_args ) @tornado.web.authenticated @@ -60,7 +90,7 @@ class RequestNew(BaseHandler): request = response.json jedi_flow = JEDIRequestFlow( - self.requests_client, screen, request, request_id=request_id + self.requests_client, self.fundz_client, screen, request, request_id=request_id ) self.render( @@ -80,12 +110,16 @@ class JEDIRequestFlow(object): def __init__( self, requests_client, + fundz_client, current_step, request=None, post_data=None, request_id=None, + current_user=None, + existing_request=None, ): self.requests_client = requests_client + self.fundz_client = fundz_client self.current_step = current_step self.request = request @@ -96,6 +130,9 @@ class JEDIRequestFlow(object): self.request_id = request_id self.form = self._form() + self.current_user = current_user + self.existing_request = existing_request + def _form(self): if self.is_post: return self.form_class()(self.post_data) @@ -107,6 +144,14 @@ class JEDIRequestFlow(object): def validate(self): return self.form.validate() + @tornado.gen.coroutine + def validate_warnings(self): + valid = yield self.form.perform_extra_validation( + self.existing_request.get('body', {}).get(self.form_section), + self.fundz_client, + ) + return valid + @property def current_screen(self): return self.screens[self.current_step - 1] @@ -185,9 +230,9 @@ class JEDIRequestFlow(object): ] @tornado.gen.coroutine - def create_or_update_request(self, user): + def create_or_update_request(self): request_data = { - "creator_id": user["id"], + "creator_id": self.current_user["id"], "request": {self.form_section: self.form.data}, } if self.request_id: diff --git a/config/base.ini b/config/base.ini index 6a367c63..431eacf7 100644 --- a/config/base.ini +++ b/config/base.ini @@ -4,6 +4,7 @@ ENVIRONMENT = dev DEBUG = true AUTHZ_BASE_URL = http://localhost:8002 AUTHNID_BASE_URL= https://localhost:8001 +FUNDZ_BASE_URL= http://localhost:8004 COOKIE_SECRET = some-secret-please-replace SECRET = change_me_into_something_secret CAC_URL = https://localhost:8001 diff --git a/tests/conftest.py b/tests/conftest.py index 469a2100..c650b5f1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ import pytest from atst.app import make_app, make_deps, make_config -from tests.mocks import MockApiClient, MockRequestsClient, MockAuthzClient +from tests.mocks import MockApiClient, MockFundzClient, MockRequestsClient, MockAuthzClient from atst.sessions import DictSessions @@ -11,6 +11,7 @@ def app(): "authz_client": MockAuthzClient("authz"), "requests_client": MockRequestsClient("requests"), "authnid_client": MockApiClient("authnid"), + "fundz_client": MockFundzClient("fundz"), "sessions": DictSessions(), } diff --git a/tests/handlers/test_request_new.py b/tests/handlers/test_request_new.py index 7cd99495..6a8a37f6 100644 --- a/tests/handlers/test_request_new.py +++ b/tests/handlers/test_request_new.py @@ -1,5 +1,8 @@ import re import pytest +import tornado +import urllib +from tests.mocks import MOCK_REQUEST, MOCK_VALID_PE_ID ERROR_CLASS = "usa-input-error-message" MOCK_USER = { @@ -47,3 +50,82 @@ def test_submit_valid_request_form(monkeypatch, http_client, base_url): body="meaning=42", ) assert "/requests/new/2" in response.effective_url + + +class TestPENumberInForm: + + required_data = { + "pe_id": "123", + "task_order_id": "1234567899C0001", + "fname_co": "Contracting", + "lname_co": "Officer", + "email_co": "jane@mail.mil", + "office_co": "WHS", + "fname_cor": "Officer", + "lname_cor": "Representative", + "email_cor": "jane@mail.mil", + "office_cor": "WHS", + "funding_type": "RDTE", + "funding_type_other": "other", + "clin_0001": "50,000", + "clin_0003": "13,000", + "clin_1001": "30,000", + "clin_1003": "7,000", + "clin_2001": "30,000", + "clin_2003": "7,000", + } + + def _set_monkeypatches(self, monkeypatch): + monkeypatch.setattr( + "atst.handlers.request_new.RequestNew.get_current_user", lambda s: MOCK_USER + ) + monkeypatch.setattr( + "atst.handlers.request_new.RequestNew.check_xsrf_cookie", lambda s: True + ) + monkeypatch.setattr("atst.forms.request.RequestForm.validate", lambda s: True) + + @tornado.gen.coroutine + def submit_data(self, http_client, base_url, data): + response = yield http_client.fetch( + base_url + "/requests/new/5/{}".format(MOCK_REQUEST["id"]), + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + body=urllib.parse.urlencode(data), + follow_redirects=False, + raise_error=False, + ) + return response + + @pytest.mark.gen_test + def test_submit_request_form_with_invalid_pe_id(self, monkeypatch, http_client, base_url): + self._set_monkeypatches(monkeypatch) + + response = yield self.submit_data(http_client, base_url, self.required_data) + + assert "We couldn\'t find that PE number" in response.body.decode() + assert response.code == 200 + assert "/requests/new/5" in response.effective_url + + @pytest.mark.gen_test + def test_submit_request_form_with_unchanged_pe_id(self, monkeypatch, http_client, base_url): + self._set_monkeypatches(monkeypatch) + + data = dict(self.required_data) + data['pe_id'] = MOCK_REQUEST['body']['financial_verification']['pe_id'] + + response = yield self.submit_data(http_client, base_url, data) + + assert response.code == 302 + assert response.headers.get("Location") == "/requests" + + @pytest.mark.gen_test + def test_submit_request_form_with_new_valid_pe_id(self, monkeypatch, http_client, base_url): + self._set_monkeypatches(monkeypatch) + + data = dict(self.required_data) + data['pe_id'] = MOCK_VALID_PE_ID + + response = yield self.submit_data(http_client, base_url, data) + + assert response.code == 302 + assert response.headers.get("Location") == "/requests" diff --git a/tests/mocks.py b/tests/mocks.py index 2b91e25f..6cd3540c 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -43,26 +43,39 @@ class MockApiClient(ApiClient): return response +MOCK_REQUEST = { + "id": "66b8ef71-86d3-48ef-abc2-51bfa1732b6b", + "creator": "49903ae7-da4a-49bf-a6dc-9dff5d004238", + "body": { + "financial_verification": { + "pe_id": "0203752A", + }, + }, + "status": "incomplete" +} + class MockRequestsClient(MockApiClient): @tornado.gen.coroutine def get(self, path, **kwargs): - json = { - "id": "66b8ef71-86d3-48ef-abc2-51bfa1732b6b", - "creator": "49903ae7-da4a-49bf-a6dc-9dff5d004238", - "body": {}, - "status": "incomplete", - } - return self._get_response("GET", path, 200, json=json) + return self._get_response("GET", path, 200, json=MOCK_REQUEST) @tornado.gen.coroutine def post(self, path, **kwargs): - json = { - "id": "66b8ef71-86d3-48ef-abc2-51bfa1732b6b", - "creator": "49903ae7-da4a-49bf-a6dc-9dff5d004238", - "body": {}, - } - return self._get_response("POST", path, 202, json=json) + return self._get_response("POST", path, 202, json=MOCK_REQUEST) + + +MOCK_VALID_PE_ID = "8675309U" + + +class MockFundzClient(MockApiClient): + + @tornado.gen.coroutine + def get(self, path, **kwargs): + if path.endswith(MOCK_VALID_PE_ID): + return self._get_response("GET", path, 200) + else: + return self._get_response("GET", path, 404) class MockAuthzClient(MockApiClient):