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 @@
+
+
+ {% module SidenavItem("Requests", href="/requests", icon="document", active=matchesPath('/requests')) %}
+ {% module SidenavItem("Workspaces", href="/workspaces", icon="cloud", active=matchesPath('/workspaces')) %}
+
+
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
-
-
+
+
Workspace Name
@@ -29,8 +25,6 @@
{% end %}
-
-
-
+
{% 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)