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/handler.py b/atst/handler.py index 5543a43a..3766aa95 100644 --- a/atst/handler.py +++ b/atst/handler.py @@ -15,17 +15,26 @@ class BaseHandler(tornado.web.RequestHandler): @tornado.gen.coroutine def login(self, user): - user["atat_permissions"] = yield self._get_user_permissions(user["id"]) + user_permissions = yield self._get_user_permissions(user["id"]) + user["atat_permissions"] = user_permissions["atat_permissions"] + user["atat_role"] = user_permissions["atat_role"] session_id = self.sessions.start_session(user) self.set_secure_cookie("atat", session_id) return self.redirect("/home") @tornado.gen.coroutine def _get_user_permissions(self, user_id): - response = yield self.authz_client.post( - "/users", json={"id": user_id, "atat_role": "ccpo"} + response = yield self.authz_client.get( + "/users/{}".format(user_id), raise_error=False ) - return response.json["atat_permissions"] + if response.code == 404: + response = yield self.authz_client.post( + "/users", json={"id": user_id, "atat_role": "developer"} + ) + return response.json + + else: + return response.json def get_current_user(self): cookie = self.get_secure_cookie("atat") diff --git a/atst/handlers/dev.py b/atst/handlers/dev.py index 38c71419..71fdb8e8 100644 --- a/atst/handlers/dev.py +++ b/atst/handlers/dev.py @@ -2,8 +2,41 @@ import tornado.gen from atst.handler import BaseHandler +_DEV_USERS = { + "ccpo": { + "id": "164497f6-c1ea-4f42-a5ef-101da278c012", + "first_name": "Sam", + "last_name": "CCPO", + }, + "owner": { + "id": "cce17030-4109-4719-b958-ed109dbb87c8", + "first_name": "Olivia", + "last_name": "Owner", + }, + "admin": { + "id": "66ebf7b8-cbf0-4ed8-a102-5f105330df75", + "first_name": "Andreas", + "last_name": "Admin", + }, + "developer": { + "id": "7707b9f2-5945-49ae-967a-be65baa88baf", + "first_name": "Dominick", + "last_name": "Developer", + }, + "billing_auditor": { + "id": "6978ac0c-442a-46aa-a0c3-ff17b5ec2a8c", + "first_name": "Billie", + "last_name": "The Billing Auditor", + }, + "security_auditor": { + "id": "596fd001-bb1d-4adf-87d8-fa2312e882de", + "first_name": "Sawyer", + "last_name": "The Security Auditor", + }, +} class Dev(BaseHandler): + def initialize(self, action, sessions, authz_client): self.action = action self.sessions = sessions @@ -11,9 +44,14 @@ class Dev(BaseHandler): @tornado.gen.coroutine def get(self): - user = { - "id": "164497f6-c1ea-4f42-a5ef-101da278c012", - "first_name": "Test", - "last_name": "User", - } + role = self.get_argument("role", "ccpo") + user = _DEV_USERS[role] + yield self._set_user_permissions(user["id"], role) yield self.login(user) + + @tornado.gen.coroutine + def _set_user_permissions(self, user_id, role): + response = yield self.authz_client.post( + "/users", json={"id": user_id, "atat_role": role} + ) + return response.json 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/atst/ui_modules.py b/atst/ui_modules.py index ec197bcd..77671339 100644 --- a/atst/ui_modules.py +++ b/atst/ui_modules.py @@ -5,3 +5,12 @@ class Icon(UIModule): with open('static/icons/%s.svg' % name) as svg: return self.render_string( "components/icon.html.to", svg=svg.read(), name=name, classes=classes) + +class SidenavItem(UIModule): + def render(self, label, href, active=False, icon=None): + return self.render_string( + "navigation/_sidenav_item.html.to", + label=label, + href=href, + active=active, + icon=icon) 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/scss/atat.scss b/scss/atat.scss index b8c90c19..d62a89b9 100644 --- a/scss/atat.scss +++ b/scss/atat.scss @@ -1,7 +1,6 @@ @import 'core/variables'; @import '../node_modules/uswds/src/stylesheets/uswds'; -@import 'core/base'; @import 'core/grid'; @import 'core/util'; @@ -12,7 +11,11 @@ @import 'elements/block_lists'; @import 'elements/tables'; @import 'elements/icons'; +@import 'elements/sidenav'; +@import 'components/layout'; +@import 'components/topbar'; +@import 'components/global_navigation'; @import 'components/site_action'; @import 'components/empty_state'; diff --git a/scss/components/_global_navigation.scss b/scss/components/_global_navigation.scss new file mode 100644 index 00000000..203fba9f --- /dev/null +++ b/scss/components/_global_navigation.scss @@ -0,0 +1,30 @@ +.global-navigation { + background-color: $color-white; + + .sidenav__link { + padding-right: $gap; + + @include media($medium-screen) { + padding-right: $gap * 2; + } + } + + .sidenav__link-label { + @include hide; + + @include media($medium-screen) { + @include unhide; + padding-left: $gap; + } + } + + &.global-navigation__context--workspace { + .sidenav__link { + padding-right: $gap; + } + + .sidenav__link-label { + @include hide; + } + } +} diff --git a/scss/components/_layout.scss b/scss/components/_layout.scss new file mode 100644 index 00000000..ee8308af --- /dev/null +++ b/scss/components/_layout.scss @@ -0,0 +1,31 @@ +body { + background-color: $color-gray-lightest; + display: flex; + flex-direction: column; + justify-content: flex-start; + min-height: 100vh; + + > footer { + margin-top: auto; + } +} + +.global-layout { + display: flex; + flex-wrap: nowrap; + flex-grow: 1; + + .global-navigation { + margin-top: -1px; + } + + .global-panel-container { + margin: $gap; + max-width: $site-max-width; + overflow: auto; + + @include media($medium-screen) { + margin: $gap * 2; + } + } +} diff --git a/scss/components/_topbar.scss b/scss/components/_topbar.scss new file mode 100644 index 00000000..e56a95fa --- /dev/null +++ b/scss/components/_topbar.scss @@ -0,0 +1,71 @@ +.topbar { + background-color: $color-white; + border-bottom: 1px solid $color-black; + + .topbar__navigation { + display: flex; + flex-direction: row; + align-items: stretch; + justify-content: space-between; + + .topbar__link { + color: $color-black; + display: inline-flex; + align-items: center; + height: $topbar-height; + padding: 0 ($gap * 2); + text-decoration: none; + + .topbar__link-label { + @include h5; + } + + .topbar__link-icon { + margin-left: $gap; + } + + &.topbar__link--shield { + width: $icon-bar-width; + justify-content: center; + padding: 0; + + .topbar__link-icon { + margin: 0; + } + } + + &:hover { + background-color: $color-primary-darker; + color: $color-white; + + .topbar__link-icon { + @include icon-style-inverted; + } + } + } + + .topbar__context { + display: flex; + flex-grow: 1; + flex-direction: row; + align-items: stretch; + justify-content: space-between; + + &.topbar__context--workspace { + background-color: $color-primary; + + .topbar__link { + color: $color-white; + + .topbar__link-icon { + @include icon-style-inverted; + } + + &:hover { + background-color: $color-primary-darker; + } + } + } + } + } +} diff --git a/scss/core/_base.scss b/scss/core/_base.scss deleted file mode 100644 index 57513b32..00000000 --- a/scss/core/_base.scss +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Base Styles - * @source https://github.com/uswds/uswds/blob/develop/src/stylesheets/core/_base.scss - */ - -body { - background-color: $color-gray-lightest; - display: flex; - flex-direction: column; - justify-content: flex-start; - min-height: 100vh; - - > footer { - margin-top: auto; - } -} diff --git a/scss/core/_grid.scss b/scss/core/_grid.scss index a18f6b44..b24327c3 100644 --- a/scss/core/_grid.scss +++ b/scss/core/_grid.scss @@ -9,12 +9,9 @@ // We are implementing a simple flexbox row/column system @mixin grid-row { - @include media($medium-screen) { - display: flex; - flex-direction: row; - flex-wrap: wrap; - max-width: $site-max-width; - } + display: flex; + flex-direction: row; + flex-wrap: nowrap; } @mixin grid-pad { diff --git a/scss/core/_util.scss b/scss/core/_util.scss index 101ddcf4..505b4ed5 100644 --- a/scss/core/_util.scss +++ b/scss/core/_util.scss @@ -1,3 +1,25 @@ .nowrap { white-space: nowrap; } + +@mixin hide { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; +} + +@mixin unhide { + border: unset; + clip: unset; + height: unset; + margin: unset; + overflow: unset; + padding: unset; + position: unset; + width: unset; +} diff --git a/scss/core/_variables.scss b/scss/core/_variables.scss index 266065e8..4380ac69 100644 --- a/scss/core/_variables.scss +++ b/scss/core/_variables.scss @@ -3,9 +3,11 @@ * =================================================== */ -$gap: .8rem; // 8px at 10px $em-base -$topbar-height: 4.8rem; -$icon-size-small: 2.4rem; +$gap: 0.8rem; // 8px at 10px $em-base +$topbar-height: 4.8rem; +$icon-bar-width: 4.0rem; +$icon-size-small: 2.4rem; +$hover-transition-time: 0.2s; /* * USWDS Variables diff --git a/scss/elements/_icons.scss b/scss/elements/_icons.scss index 95f7fb46..0379154d 100644 --- a/scss/elements/_icons.scss +++ b/scss/elements/_icons.scss @@ -1,17 +1,19 @@ @mixin icon { - display: inline-block; - vertical-align: bottom; + display: inline-flex; > svg { width: 100%; height: 100%; + * { + transition: fill $hover-transition-time; + } } } @mixin icon-size($size) { $icon-size: $size * .1rem; width: $icon-size; - height: $icon-size; + height: auto; margin: $icon-size / 4; } @@ -21,8 +23,24 @@ } } +@mixin icon-style-active { + > svg * { + fill: $color-primary; + } +} + +@mixin icon-style-inverted { + > svg * { + fill: $color-white; + } +} + .icon { @include icon; @include icon-size(16); @include icon-style-default; + + &.icon--tiny { + @include icon-size(10); + } } diff --git a/scss/elements/_panels.scss b/scss/elements/_panels.scss index f52914b2..5b0c1220 100644 --- a/scss/elements/_panels.scss +++ b/scss/elements/_panels.scss @@ -44,36 +44,3 @@ } } } - -/* - * Panel Container - * Grid container for panel blocks - */ - - .panel-container { - @include grid-row; - @include grid-pad; - @include margin(($site-margins-mobile * 2) null); - - @include media($medium-screen) { - @include margin(($site-margins * 2) null); - } - - @include media($large-screen) { - flex-wrap: nowrap; - } - - > .col { - @include grid-pad; - } - - h1 { - margin-bottom: 0; - } - - h2 { - color: $color-gray; - } - - -} diff --git a/scss/sections/_sidenav.scss b/scss/elements/_sidenav.scss similarity index 63% rename from scss/sections/_sidenav.scss rename to scss/elements/_sidenav.scss index 4415c3ae..7fa92cf6 100644 --- a/scss/sections/_sidenav.scss +++ b/scss/elements/_sidenav.scss @@ -1,15 +1,13 @@ .sidenav { - @include grid-pad; - @include panel-margin; - width: 100%; - flex-shrink: 0; + ul { + list-style: none; + margin: 0; + padding: 0; - @include media($large-screen) { - width: 21rem; - } - - @include media($xlarge-screen) { - width: 30rem; + li { + margin: 0; + display: block; + } } .sidenav__link { @@ -18,10 +16,10 @@ padding: $gap ($gap * 2); color: $color-black; text-decoration: none; + white-space: nowrap; - &:hover { - background-color: $color-white; - color: $color-primary; + .sidenav__link-icon { + margin-left: - ($gap * .5); } &.sidenav__link--disabled { @@ -35,60 +33,42 @@ color: $color-primary; box-shadow: inset .4rem 0 0 0 $color-primary; + .sidenav__link-icon { + @include icon-style-active; + } + + ul { background-color: $color-white; .sidenav__link { &--active { @include h5; - box-shadow: none; + color: $color-primary; } } } } + ul { + padding-bottom: $gap / 2; + li { .sidenav__link { + @include h5; padding: ($gap * .75) ($gap * 3); border: 0; - @include h5; font-weight: normal; } - - &:last-child { - .sidenav__link { - padding-bottom: $gap * 1.5; - } - } } } - } - ul { - list-style: none; - margin: 0; - padding: 0; + &:hover { + color: $color-primary; - li { - margin: 0; - display: block; - } - } - - > ul { - @include panel-margin; - - &:last-child { - margin: 0; - } - - > li { - &:last-child { - > .sidenav__link { - border-bottom: 1px solid $color-black; - } + .sidenav__link-icon { + @include icon-style-active; } + } } } diff --git a/scss/elements/_typography.scss b/scss/elements/_typography.scss index ea945766..e720402c 100644 --- a/scss/elements/_typography.scss +++ b/scss/elements/_typography.scss @@ -4,7 +4,12 @@ * @source https://github.com/uswds/uswds/blob/develop/src/stylesheets/elements/_typography.scss */ - h1, h2, h3, h4, h5, h6 { +* { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +h1, h2, h3, h4, h5, h6 { font-family: $font-sans; .usa-button { @@ -24,12 +29,12 @@ h2 { } a, -a > span { +a:hover { transition: background 0.2s, border 0.2s, box-shadow 0.2s, - color 0.2s, + color 0.2s; } a:visited { @@ -39,4 +44,4 @@ a:visited { dt { display: inline; font-weight: bold; -} \ No newline at end of file +} diff --git a/scss/sections/_main.scss b/scss/sections/_main.scss deleted file mode 100644 index 063b70e7..00000000 --- a/scss/sections/_main.scss +++ /dev/null @@ -1,3 +0,0 @@ -section { - margin-bottom: 10rem; -} diff --git a/scss/sections/_topbar.scss b/scss/sections/_topbar.scss deleted file mode 100644 index 37114a6c..00000000 --- a/scss/sections/_topbar.scss +++ /dev/null @@ -1,54 +0,0 @@ -.topbar { - background-color: $color-white; - height: $topbar-height; - border-bottom: 1px solid $color-black; - - .topbar__navigation { - display: flex; - flex-direction: row; - align-items: stretch; - justify-content: flex-end; - - > .topbar__link { - @include h5; - color: $color-primary; - display: inline-block; - height: $topbar-height; - line-height: $topbar-height; - padding: 0 ($gap * 2); - text-decoration: none; - - > span { - display: inline-block; - height: $topbar-height; - line-height: $topbar-height; - } - - &.topbar__link--primary { - margin-right: auto; - > span { - @include nav-border; - } - - &:hover { - color: $color-white; - background-color: $color-primary-darkest; - } - } - - &.topbar__link--secondary { - font-weight: normal; - > span { - @include nav-border; - border-bottom-width: 0; - } - - &:hover { - > span { - @include nav-border; - } - } - } - } - } -} diff --git a/templates/base.html.to b/templates/base.html.to index f271513d..f3412da9 100644 --- a/templates/base.html.to +++ b/templates/base.html.to @@ -1,3 +1,7 @@ +{# TODO: set this context elsewhere #} +{# set context='workspace' #} +{% set context='global' %} + @@ -10,19 +14,20 @@ + {% include 'navigation/topbar.html.to' %} - {% include 'header.html.to' %} +
+ {% include 'navigation/global_navigation.html.to' %} -
- {% block sidenav %}{% end %} +
+ {% block sidenav %}{% end %} - {% block content %} - these are not the droids you are looking for - {% end %} + {% block content %} + these are not the droids you are looking for + {% end %} +
- - {% include 'footer.html.to' %} diff --git a/templates/header.html.to b/templates/header.html.to index 48ec82e8..35feff12 100644 --- a/templates/header.html.to +++ b/templates/header.html.to @@ -5,7 +5,7 @@ - Tech Lead + {{ current_user.get("atat_role") }} diff --git a/templates/home.html.to b/templates/home.html.to index e5f7c8f7..eb789f28 100644 --- a/templates/home.html.to +++ b/templates/home.html.to @@ -1,9 +1,5 @@ {% extends "base.html.to" %} -{% block sidenav %} -{% include 'nav-side.html.to' %} -{% end %} - {% block content %}
diff --git a/templates/navigation/_sidenav_item.html.to b/templates/navigation/_sidenav_item.html.to new file mode 100644 index 00000000..a41b7726 --- /dev/null +++ b/templates/navigation/_sidenav_item.html.to @@ -0,0 +1,9 @@ +
  • + + {% if icon %} + {% module Icon(icon, classes="sidenav__link-icon") %} + {% end %} + + {{label}} + +
  • diff --git a/templates/navigation/global_navigation.html.to b/templates/navigation/global_navigation.html.to new file mode 100644 index 00000000..742ddaf7 --- /dev/null +++ b/templates/navigation/global_navigation.html.to @@ -0,0 +1,6 @@ + diff --git a/templates/navigation/topbar.html.to b/templates/navigation/topbar.html.to new file mode 100644 index 00000000..9c6ef28c --- /dev/null +++ b/templates/navigation/topbar.html.to @@ -0,0 +1,19 @@ +
    + +
    diff --git a/templates/requests.html.to b/templates/requests.html.to index ff1835ce..653c01b3 100644 --- a/templates/requests.html.to +++ b/templates/requests.html.to @@ -1,9 +1,5 @@ {% extends "base.html.to" %} -{% block sidenav %} -{% include 'nav-side.html.to' %} -{% end %} - {% block content %} diff --git a/templates/workspaces.html.to b/templates/workspaces.html.to index c768708c..9f87a99a 100644 --- a/templates/workspaces.html.to +++ b/templates/workspaces.html.to @@ -1,12 +1,8 @@ {% extends "base.html.to" %} {% block content %} - -
    - -

    Workspaces

    - - +
    +
    @@ -29,8 +25,6 @@ {% end %}
    Workspace Name
    - -
    - +
    {% end %} 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 b916d677..6cd3540c 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -5,6 +5,7 @@ from atst.api_client import ApiClient class MockApiClient(ApiClient): + def __init__(self, service): self.service = service @@ -42,66 +43,85 @@ 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): + _json = { + "atat_permissions": [ + "view_original_jedi_request", + "review_and_approve_jedi_workspace_request", + "modify_atat_role_permissions", + "create_csp_role", + "delete_csp_role", + "deactivate_csp_role", + "modify_csp_role_permissions", + "view_usage_report", + "view_usage_dollars", + "add_and_assign_csp_roles", + "remove_csp_roles", + "request_new_csp_role", + "assign_and_unassign_atat_role", + "view_assigned_atat_role_configurations", + "view_assigned_csp_role_configurations", + "deactivate_workspace", + "view_atat_permissions", + "transfer_ownership_of_workspace", + "add_application_in_workspace", + "delete_application_in_workspace", + "deactivate_application_in_workspace", + "view_application_in_workspace", + "rename_application_in_workspace", + "add_environment_in_application", + "delete_environment_in_application", + "deactivate_environment_in_application", + "view_environment_in_application", + "rename_environment_in_application", + "add_tag_to_workspace", + "remove_tag_from_workspace", + ], + "atat_role": "ccpo", + "id": "164497f6-c1ea-4f42-a5ef-101da278c012", + "username": None, + "workspace_roles": [], + } + @tornado.gen.coroutine def post(self, path, **kwargs): - json = { - "atat_permissions": [ - "view_original_jedi_request", - "review_and_approve_jedi_workspace_request", - "modify_atat_role_permissions", - "create_csp_role", - "delete_csp_role", - "deactivate_csp_role", - "modify_csp_role_permissions", - "view_usage_report", - "view_usage_dollars", - "add_and_assign_csp_roles", - "remove_csp_roles", - "request_new_csp_role", - "assign_and_unassign_atat_role", - "view_assigned_atat_role_configurations", - "view_assigned_csp_role_configurations", - "deactivate_workspace", - "view_atat_permissions", - "transfer_ownership_of_workspace", - "add_application_in_workspace", - "delete_application_in_workspace", - "deactivate_application_in_workspace", - "view_application_in_workspace", - "rename_application_in_workspace", - "add_environment_in_application", - "delete_environment_in_application", - "deactivate_environment_in_application", - "view_environment_in_application", - "rename_environment_in_application", - "add_tag_to_workspace", - "remove_tag_from_workspace", - ], - "atat_role": "ccpo", - "id": "164497f6-c1ea-4f42-a5ef-101da278c012", - "username": None, - "workspace_roles": [], - } - return self._get_response("POST", path, 200, json=json) + return self._get_response("POST", path, 200, json=self._json) + + @tornado.gen.coroutine + def get(self, path, **kwargs): + return self._get_response("POST", path, 200, json=self._json)