Merge branch 'master' into ui-progress-bar

This commit is contained in:
luisgov 2018-07-20 14:49:34 -04:00 committed by GitHub
commit f020115a51
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 625 additions and 285 deletions

View File

@ -58,19 +58,31 @@ def make_app(config, deps, **kwargs):
url( url(
r"/requests/new", r"/requests/new",
RequestNew, 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", name="request_new",
), ),
url( url(
r"/requests/new/([0-9])", r"/requests/new/([0-9])",
RequestNew, 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", name="request_form_new",
), ),
url( url(
r"/requests/new/([0-9])/(\S+)", r"/requests/new/([0-9])/(\S+)",
RequestNew, 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", name="request_form_update",
), ),
url( url(
@ -128,6 +140,10 @@ def make_deps(config):
api_version="v1", api_version="v1",
validate_cert=validate_cert, validate_cert=validate_cert,
), ),
"fundz_client": ApiClient(
config["default"]["FUNDZ_BASE_URL"],
validate_cert=validate_cert,
),
"requests_client": ApiClient( "requests_client": ApiClient(
config["default"]["REQUESTS_QUEUE_BASE_URL"], config["default"]["REQUESTS_QUEUE_BASE_URL"],
api_version="v1", api_version="v1",

View File

@ -1,12 +1,40 @@
import tornado
from tornado.gen import Return
from wtforms.fields.html5 import EmailField from wtforms.fields.html5 import EmailField
from wtforms.fields import StringField, SelectField from wtforms.fields import StringField, SelectField
from wtforms.form import Form
from wtforms.validators import Required, Email from wtforms.validators import Required, Email
from wtforms_tornado import Form
from .fields import NewlineListField 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_id = StringField(
"Task Order Number associated with this request.", validators=[Required()] "Task Order Number associated with this request.", validators=[Required()]
) )

12
atst/forms/forms.py Normal file
View File

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

View File

@ -1,13 +1,13 @@
from wtforms.fields.html5 import EmailField, TelField from wtforms.fields.html5 import EmailField, TelField
from wtforms.fields import RadioField, StringField from wtforms.fields import RadioField, StringField
from wtforms.validators import Required, Email from wtforms.validators import Required, Email
from wtforms_tornado import Form
import pendulum import pendulum
from .fields import DateField from .fields import DateField
from .forms import ValidatedForm
from .validators import DateRange, PhoneNumber, Alphabet from .validators import DateRange, PhoneNumber, Alphabet
class OrgForm(Form): class OrgForm(ValidatedForm):
fname_request = StringField("First Name", validators=[Required(), Alphabet()]) fname_request = StringField("First Name", validators=[Required(), Alphabet()])
lname_request = StringField("Last Name", validators=[Required(), Alphabet()]) lname_request = StringField("Last Name", validators=[Required(), Alphabet()])

View File

@ -1,10 +1,10 @@
from wtforms.fields import StringField from wtforms.fields import StringField
from wtforms.validators import Required, Email, Length from wtforms.validators import Required, Email, Length
from wtforms_tornado import Form from .forms import ValidatedForm
from .validators import IsNumber, Alphabet from .validators import IsNumber, Alphabet
class POCForm(Form): class POCForm(ValidatedForm):
fname_poc = StringField("POC First Name", validators=[Required(), Alphabet()]) fname_poc = StringField("POC First Name", validators=[Required(), Alphabet()])
lname_poc = StringField("POC Last Name", validators=[Required(), Alphabet()]) lname_poc = StringField("POC Last Name", validators=[Required(), Alphabet()])

View File

@ -1,13 +1,13 @@
from wtforms.fields.html5 import IntegerField from wtforms.fields.html5 import IntegerField
from wtforms.fields import RadioField, StringField, TextAreaField from wtforms.fields import RadioField, StringField, TextAreaField
from wtforms.validators import NumberRange, InputRequired from wtforms.validators import NumberRange, InputRequired
from wtforms_tornado import Form
from .fields import DateField from .fields import DateField
from .forms import ValidatedForm
from .validators import DateRange from .validators import DateRange
import pendulum import pendulum
class RequestForm(Form): class RequestForm(ValidatedForm):
# Details of Use: Overall Request Details # Details of Use: Overall Request Details
dollar_value = IntegerField( dollar_value = IntegerField(

View File

@ -1,6 +1,7 @@
from wtforms.fields import BooleanField 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.") reviewed = BooleanField("I have reviewed this data and it is correct.")

View File

@ -15,17 +15,26 @@ class BaseHandler(tornado.web.RequestHandler):
@tornado.gen.coroutine @tornado.gen.coroutine
def login(self, user): 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) session_id = self.sessions.start_session(user)
self.set_secure_cookie("atat", session_id) self.set_secure_cookie("atat", session_id)
return self.redirect("/home") return self.redirect("/home")
@tornado.gen.coroutine @tornado.gen.coroutine
def _get_user_permissions(self, user_id): def _get_user_permissions(self, user_id):
response = yield self.authz_client.post( response = yield self.authz_client.get(
"/users", json={"id": user_id, "atat_role": "ccpo"} "/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): def get_current_user(self):
cookie = self.get_secure_cookie("atat") cookie = self.get_secure_cookie("atat")

View File

@ -2,8 +2,41 @@ import tornado.gen
from atst.handler import BaseHandler 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): class Dev(BaseHandler):
def initialize(self, action, sessions, authz_client): def initialize(self, action, sessions, authz_client):
self.action = action self.action = action
self.sessions = sessions self.sessions = sessions
@ -11,9 +44,14 @@ class Dev(BaseHandler):
@tornado.gen.coroutine @tornado.gen.coroutine
def get(self): def get(self):
user = { role = self.get_argument("role", "ccpo")
"id": "164497f6-c1ea-4f42-a5ef-101da278c012", user = _DEV_USERS[role]
"first_name": "Test", yield self._set_user_permissions(user["id"], role)
"last_name": "User",
}
yield self.login(user) 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

View File

@ -10,9 +10,17 @@ from atst.forms.financial import FinancialForm
class RequestNew(BaseHandler): class RequestNew(BaseHandler):
def initialize(self, page, requests_client): def initialize(self, page, requests_client, fundz_client):
self.page = page self.page = page
self.requests_client = requests_client 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.web.authenticated
@tornado.gen.coroutine @tornado.gen.coroutine
@ -20,22 +28,19 @@ class RequestNew(BaseHandler):
self.check_xsrf_cookie() self.check_xsrf_cookie()
screen = int(screen) screen = int(screen)
post_data = self.request.arguments post_data = self.request.arguments
current_user = self.get_current_user()
existing_request = yield self.get_existing_request(request_id)
jedi_flow = JEDIRequestFlow( 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,
) )
if jedi_flow.validate(): rerender_args = dict(
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), jedi_flow.request_id
)
self.redirect(where)
else:
self.set_status(response.code)
else:
self.render(
"requests/screen-%d.html.to" % int(screen),
f=jedi_flow.form, f=jedi_flow.form,
data=post_data, data=post_data,
page=self.page, page=self.page,
@ -45,6 +50,31 @@ class RequestNew(BaseHandler):
request_id=jedi_flow.request_id, request_id=jedi_flow.request_id,
) )
if jedi_flow.validate():
response = yield jedi_flow.create_or_update_request()
if response.ok:
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),
**rerender_args
)
@tornado.web.authenticated @tornado.web.authenticated
@tornado.gen.coroutine @tornado.gen.coroutine
def get(self, screen=1, request_id=None): def get(self, screen=1, request_id=None):
@ -60,7 +90,7 @@ class RequestNew(BaseHandler):
request = response.json request = response.json
jedi_flow = JEDIRequestFlow( 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( self.render(
@ -80,12 +110,16 @@ class JEDIRequestFlow(object):
def __init__( def __init__(
self, self,
requests_client, requests_client,
fundz_client,
current_step, current_step,
request=None, request=None,
post_data=None, post_data=None,
request_id=None, request_id=None,
current_user=None,
existing_request=None,
): ):
self.requests_client = requests_client self.requests_client = requests_client
self.fundz_client = fundz_client
self.current_step = current_step self.current_step = current_step
self.request = request self.request = request
@ -96,6 +130,9 @@ class JEDIRequestFlow(object):
self.request_id = request_id self.request_id = request_id
self.form = self._form() self.form = self._form()
self.current_user = current_user
self.existing_request = existing_request
def _form(self): def _form(self):
if self.is_post: if self.is_post:
return self.form_class()(self.post_data) return self.form_class()(self.post_data)
@ -107,6 +144,14 @@ class JEDIRequestFlow(object):
def validate(self): def validate(self):
return self.form.validate() 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 @property
def current_screen(self): def current_screen(self):
return self.screens[self.current_step - 1] return self.screens[self.current_step - 1]
@ -185,9 +230,9 @@ class JEDIRequestFlow(object):
] ]
@tornado.gen.coroutine @tornado.gen.coroutine
def create_or_update_request(self, user): def create_or_update_request(self):
request_data = { request_data = {
"creator_id": user["id"], "creator_id": self.current_user["id"],
"request": {self.form_section: self.form.data}, "request": {self.form_section: self.form.data},
} }
if self.request_id: if self.request_id:

View File

@ -5,3 +5,12 @@ class Icon(UIModule):
with open('static/icons/%s.svg' % name) as svg: with open('static/icons/%s.svg' % name) as svg:
return self.render_string( return self.render_string(
"components/icon.html.to", svg=svg.read(), name=name, classes=classes) "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)

View File

@ -4,6 +4,7 @@ ENVIRONMENT = dev
DEBUG = true DEBUG = true
AUTHZ_BASE_URL = http://localhost:8002 AUTHZ_BASE_URL = http://localhost:8002
AUTHNID_BASE_URL= https://localhost:8001 AUTHNID_BASE_URL= https://localhost:8001
FUNDZ_BASE_URL= http://localhost:8004
COOKIE_SECRET = some-secret-please-replace COOKIE_SECRET = some-secret-please-replace
SECRET = change_me_into_something_secret SECRET = change_me_into_something_secret
CAC_URL = https://localhost:8001 CAC_URL = https://localhost:8001

View File

@ -1,7 +1,6 @@
@import 'core/variables'; @import 'core/variables';
@import '../node_modules/uswds/src/stylesheets/uswds'; @import '../node_modules/uswds/src/stylesheets/uswds';
@import 'core/base';
@import 'core/grid'; @import 'core/grid';
@import 'core/util'; @import 'core/util';
@ -12,7 +11,11 @@
@import 'elements/block_lists'; @import 'elements/block_lists';
@import 'elements/tables'; @import 'elements/tables';
@import 'elements/icons'; @import 'elements/icons';
@import 'elements/sidenav';
@import 'components/layout';
@import 'components/topbar';
@import 'components/global_navigation';
@import 'components/site_action'; @import 'components/site_action';
@import 'components/empty_state'; @import 'components/empty_state';

View File

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

View File

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

View File

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

View File

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

View File

@ -9,12 +9,9 @@
// We are implementing a simple flexbox row/column system // We are implementing a simple flexbox row/column system
@mixin grid-row { @mixin grid-row {
@include media($medium-screen) {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: nowrap;
max-width: $site-max-width;
}
} }
@mixin grid-pad { @mixin grid-pad {

View File

@ -1,3 +1,25 @@
.nowrap { .nowrap {
white-space: 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;
}

View File

@ -3,9 +3,11 @@
* =================================================== * ===================================================
*/ */
$gap: .8rem; // 8px at 10px $em-base $gap: 0.8rem; // 8px at 10px $em-base
$topbar-height: 4.8rem; $topbar-height: 4.8rem;
$icon-bar-width: 4.0rem;
$icon-size-small: 2.4rem; $icon-size-small: 2.4rem;
$hover-transition-time: 0.2s;
/* /*
* USWDS Variables * USWDS Variables

View File

@ -1,17 +1,19 @@
@mixin icon { @mixin icon {
display: inline-block; display: inline-flex;
vertical-align: bottom;
> svg { > svg {
width: 100%; width: 100%;
height: 100%; height: 100%;
* {
transition: fill $hover-transition-time;
}
} }
} }
@mixin icon-size($size) { @mixin icon-size($size) {
$icon-size: $size * .1rem; $icon-size: $size * .1rem;
width: $icon-size; width: $icon-size;
height: $icon-size; height: auto;
margin: $icon-size / 4; 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 { .icon {
@include icon; @include icon;
@include icon-size(16); @include icon-size(16);
@include icon-style-default; @include icon-style-default;
&.icon--tiny {
@include icon-size(10);
}
} }

View File

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

View File

@ -1,15 +1,13 @@
.sidenav { .sidenav {
@include grid-pad; ul {
@include panel-margin; list-style: none;
width: 100%; margin: 0;
flex-shrink: 0; padding: 0;
@include media($large-screen) { li {
width: 21rem; margin: 0;
display: block;
} }
@include media($xlarge-screen) {
width: 30rem;
} }
.sidenav__link { .sidenav__link {
@ -18,10 +16,10 @@
padding: $gap ($gap * 2); padding: $gap ($gap * 2);
color: $color-black; color: $color-black;
text-decoration: none; text-decoration: none;
white-space: nowrap;
&:hover { .sidenav__link-icon {
background-color: $color-white; margin-left: - ($gap * .5);
color: $color-primary;
} }
&.sidenav__link--disabled { &.sidenav__link--disabled {
@ -35,60 +33,42 @@
color: $color-primary; color: $color-primary;
box-shadow: inset .4rem 0 0 0 $color-primary; box-shadow: inset .4rem 0 0 0 $color-primary;
.sidenav__link-icon {
@include icon-style-active;
}
+ ul { + ul {
background-color: $color-white; background-color: $color-white;
.sidenav__link { .sidenav__link {
&--active { &--active {
@include h5; @include h5;
box-shadow: none; color: $color-primary;
} }
} }
} }
} }
+ ul { + ul {
padding-bottom: $gap / 2;
li { li {
.sidenav__link { .sidenav__link {
@include h5;
padding: ($gap * .75) ($gap * 3); padding: ($gap * .75) ($gap * 3);
border: 0; border: 0;
@include h5;
font-weight: normal; font-weight: normal;
} }
&:last-child {
.sidenav__link {
padding-bottom: $gap * 1.5;
}
}
}
}
}
ul {
list-style: none;
margin: 0;
padding: 0;
li {
margin: 0;
display: block;
} }
} }
> ul { &:hover {
@include panel-margin; color: $color-primary;
&:last-child { .sidenav__link-icon {
margin: 0; @include icon-style-active;
} }
> li {
&:last-child {
> .sidenav__link {
border-bottom: 1px solid $color-black;
}
}
} }
} }
} }

View File

@ -4,6 +4,11 @@
* @source https://github.com/uswds/uswds/blob/develop/src/stylesheets/elements/_typography.scss * @source https://github.com/uswds/uswds/blob/develop/src/stylesheets/elements/_typography.scss
*/ */
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
font-family: $font-sans; font-family: $font-sans;
@ -24,12 +29,12 @@ h2 {
} }
a, a,
a > span { a:hover {
transition: transition:
background 0.2s, background 0.2s,
border 0.2s, border 0.2s,
box-shadow 0.2s, box-shadow 0.2s,
color 0.2s, color 0.2s;
} }
a:visited { a:visited {

View File

@ -1,3 +0,0 @@
section {
margin-bottom: 10rem;
}

View File

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

View File

@ -1,3 +1,7 @@
{# TODO: set this context elsewhere #}
{# set context='workspace' #}
{% set context='global' %}
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
@ -10,18 +14,19 @@
<link rel="icon" type="image/x-icon" href="/static/img/favicon.ico"> <link rel="icon" type="image/x-icon" href="/static/img/favicon.ico">
</head> </head>
<body> <body>
{% include 'navigation/topbar.html.to' %}
{% include 'header.html.to' %} <div class='global-layout'>
{% include 'navigation/global_navigation.html.to' %}
<div class='panel-container'> <div class='global-panel-container'>
{% block sidenav %}{% end %} {% block sidenav %}{% end %}
{% block content %} {% block content %}
these are not the droids you are looking for these are not the droids you are looking for
{% end %} {% end %}
</div> </div>
</div>
{% include 'footer.html.to' %} {% include 'footer.html.to' %}
</body> </body>

View File

@ -5,7 +5,7 @@
</a> </a>
<a href="/" class="topbar__link topbar__link--secondary"> <a href="/" class="topbar__link topbar__link--secondary">
<span>Tech Lead</span> <span>{{ current_user.get("atat_role") }}</span>
</a> </a>
</nav> </nav>
</header> </header>

View File

@ -1,9 +1,5 @@
{% extends "base.html.to" %} {% extends "base.html.to" %}
{% block sidenav %}
{% include 'nav-side.html.to' %}
{% end %}
{% block content %} {% block content %}
<main class="usa-section usa-content"> <main class="usa-section usa-content">

View File

@ -0,0 +1,9 @@
<li>
<a class="sidenav__link {% if active %}sidenav__link--active{% end %}" href="{{href}}" title="{{label}}">
{% if icon %}
{% module Icon(icon, classes="sidenav__link-icon") %}
{% end %}
<span class="sidenav__link-label">{{label}}</span>
</a>
</li>

View File

@ -0,0 +1,6 @@
<div class="global-navigation sidenav global-navigation__context--{{context}}">
<ul>
{% module SidenavItem("Requests", href="/requests", icon="document", active=matchesPath('/requests')) %}
{% module SidenavItem("Workspaces", href="/workspaces", icon="cloud", active=matchesPath('/workspaces')) %}
</ul>
</div>

View File

@ -0,0 +1,19 @@
<header class="topbar">
<nav class="topbar__navigation">
<a href="/home" class="topbar__link topbar__link--shield" title="JEDI Home">
{% module Icon('shield', classes='topbar__link-icon') %}
</a>
<div class="topbar__context topbar__context--{{context}}">
<a href="/" class="topbar__link">
<span class="topbar__link-label">JEDI</span>
{% module Icon('caret_down', classes='topbar__link-icon icon--tiny') %}
</a>
<a href="/" class="topbar__link">
<span class="topbar__link-label">Sam Seeceepio</span>
{% module Icon('avatar', classes='topbar__link-icon') %}
</a>
</div>
</nav>
</header>

View File

@ -1,9 +1,5 @@
{% extends "base.html.to" %} {% extends "base.html.to" %}
{% block sidenav %}
{% include 'nav-side.html.to' %}
{% end %}
{% block content %} {% block content %}

View File

@ -1,12 +1,8 @@
{% extends "base.html.to" %} {% extends "base.html.to" %}
{% block content %} {% block content %}
<div class='col'>
<main class="usa-section usa-content usa-width-one-whole"> <table>
<h1>Workspaces</h1>
<table class="usa-table-borderless" width="100%">
<thead> <thead>
<tr> <tr>
<th scope="col" width="50%">Workspace Name</th> <th scope="col" width="50%">Workspace Name</th>
@ -29,8 +25,6 @@
{% end %} {% end %}
</tbody> </tbody>
</table> </table>
</div>
</main>
{% end %} {% end %}

View File

@ -1,7 +1,7 @@
import pytest import pytest
from atst.app import make_app, make_deps, make_config 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 from atst.sessions import DictSessions
@ -11,6 +11,7 @@ def app():
"authz_client": MockAuthzClient("authz"), "authz_client": MockAuthzClient("authz"),
"requests_client": MockRequestsClient("requests"), "requests_client": MockRequestsClient("requests"),
"authnid_client": MockApiClient("authnid"), "authnid_client": MockApiClient("authnid"),
"fundz_client": MockFundzClient("fundz"),
"sessions": DictSessions(), "sessions": DictSessions(),
} }

View File

@ -1,5 +1,8 @@
import re import re
import pytest import pytest
import tornado
import urllib
from tests.mocks import MOCK_REQUEST, MOCK_VALID_PE_ID
ERROR_CLASS = "usa-input-error-message" ERROR_CLASS = "usa-input-error-message"
MOCK_USER = { MOCK_USER = {
@ -47,3 +50,82 @@ def test_submit_valid_request_form(monkeypatch, http_client, base_url):
body="meaning=42", body="meaning=42",
) )
assert "/requests/new/2" in response.effective_url 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"

View File

@ -5,6 +5,7 @@ from atst.api_client import ApiClient
class MockApiClient(ApiClient): class MockApiClient(ApiClient):
def __init__(self, service): def __init__(self, service):
self.service = service self.service = service
@ -42,31 +43,43 @@ class MockApiClient(ApiClient):
return response return response
class MockRequestsClient(MockApiClient): MOCK_REQUEST = {
@tornado.gen.coroutine
def get(self, path, **kwargs):
json = {
"id": "66b8ef71-86d3-48ef-abc2-51bfa1732b6b", "id": "66b8ef71-86d3-48ef-abc2-51bfa1732b6b",
"creator": "49903ae7-da4a-49bf-a6dc-9dff5d004238", "creator": "49903ae7-da4a-49bf-a6dc-9dff5d004238",
"body": {}, "body": {
"status": "incomplete", "financial_verification": {
"pe_id": "0203752A",
},
},
"status": "incomplete"
} }
return self._get_response("GET", path, 200, json=json)
class MockRequestsClient(MockApiClient):
@tornado.gen.coroutine
def get(self, path, **kwargs):
return self._get_response("GET", path, 200, json=MOCK_REQUEST)
@tornado.gen.coroutine @tornado.gen.coroutine
def post(self, path, **kwargs): def post(self, path, **kwargs):
json = { return self._get_response("POST", path, 202, json=MOCK_REQUEST)
"id": "66b8ef71-86d3-48ef-abc2-51bfa1732b6b",
"creator": "49903ae7-da4a-49bf-a6dc-9dff5d004238",
"body": {}, MOCK_VALID_PE_ID = "8675309U"
}
return self._get_response("POST", path, 202, json=json)
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): class MockAuthzClient(MockApiClient):
@tornado.gen.coroutine _json = {
def post(self, path, **kwargs):
json = {
"atat_permissions": [ "atat_permissions": [
"view_original_jedi_request", "view_original_jedi_request",
"review_and_approve_jedi_workspace_request", "review_and_approve_jedi_workspace_request",
@ -104,4 +117,11 @@ class MockAuthzClient(MockApiClient):
"username": None, "username": None,
"workspace_roles": [], "workspace_roles": [],
} }
return self._get_response("POST", path, 200, json=json)
@tornado.gen.coroutine
def post(self, path, **kwargs):
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)