Merge branch 'master' into ie/fix-toggler

This commit is contained in:
andrewdds 2018-09-17 15:19:48 -04:00 committed by GitHub
commit de04fc39b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 465 additions and 264 deletions

View File

@ -7,7 +7,10 @@ from .validators import Alphabet, PhoneNumber
class CCPOReviewForm(ValidatedForm): class CCPOReviewForm(ValidatedForm):
comment = TextAreaField("Comments (optional)") comment = TextAreaField(
"Instructions or comments",
description="Provide instructions or notes for additional information that is necessary to approve the request here. The requestor may then re-submit the updated request or initiate contact outside of AT-AT if further discussion is required. <strong>This message will be shared with the person making the JEDI request.</strong>.",
)
fname_mao = StringField( fname_mao = StringField(
"First Name (optional)", validators=[Optional(), Alphabet()] "First Name (optional)", validators=[Optional(), Alphabet()]
) )

View File

@ -5,4 +5,8 @@ from .forms import ValidatedForm
class InternalCommentForm(ValidatedForm): class InternalCommentForm(ValidatedForm):
text = TextAreaField(validators=[Optional()]) text = TextAreaField(
"CCPO Internal Notes",
description="You may add additional comments and notes for internal CCPO reference and follow-up here.",
validators=[Optional()],
)

View File

@ -194,3 +194,13 @@ class Request(Base):
@property @property
def is_approved(self): def is_approved(self):
return self.status == RequestStatus.APPROVED return self.status == RequestStatus.APPROVED
@property
def review_comment(self):
if (
self.status == RequestStatus.CHANGES_REQUESTED
or self.status == RequestStatus.CHANGES_REQUESTED_TO_FINVER
):
review = self.latest_status.review
if review:
return review.comment

View File

@ -8,7 +8,7 @@ class RequestReview(Base):
__tablename__ = "request_reviews" __tablename__ = "request_reviews"
id = Column(BigInteger, primary_key=True) id = Column(BigInteger, primary_key=True)
status = relationship("RequestStatusEvent", back_populates="review") status = relationship("RequestStatusEvent", uselist=False, back_populates="review")
user_id = Column(ForeignKey("users.id"), nullable=False) user_id = Column(ForeignKey("users.id"), nullable=False)
reviewer = relationship("User") reviewer = relationship("User")

View File

@ -48,6 +48,8 @@ class RequestStatusEvent(Base):
def log_name(self): def log_name(self):
if self.new_status == RequestStatus.CHANGES_REQUESTED: if self.new_status == RequestStatus.CHANGES_REQUESTED:
return "Denied" return "Denied"
if self.new_status == RequestStatus.CHANGES_REQUESTED_TO_FINVER:
return "Denied"
elif self.new_status == RequestStatus.PENDING_FINANCIAL_VERIFICATION: elif self.new_status == RequestStatus.PENDING_FINANCIAL_VERIFICATION:
return "Accepted" return "Accepted"
else: else:

View File

@ -49,10 +49,9 @@ def login_redirect():
return redirect(url_for(".home")) return redirect(url_for(".home"))
def _is_valid_certificate(request): @bp.route("/logout")
cert = request.environ.get("HTTP_X_SSL_CLIENT_CERT") def logout():
if cert: if session.get("user_id"):
result = app.crl_validator.validate(cert.encode()) del (session["user_id"])
return result
else: return redirect(url_for(".home"))
return False

View File

@ -12,7 +12,6 @@ from . import requests_bp
from atst.domain.requests import Requests from atst.domain.requests import Requests
from atst.domain.exceptions import NotFoundError from atst.domain.exceptions import NotFoundError
from atst.forms.ccpo_review import CCPOReviewForm from atst.forms.ccpo_review import CCPOReviewForm
from atst.forms.internal_comment import InternalCommentForm
def map_ccpo_authorizing(user): def map_ccpo_authorizing(user):
@ -26,8 +25,6 @@ def render_approval(request, form=None):
if pending_final_approval and request.task_order: if pending_final_approval and request.task_order:
data["task_order"] = request.task_order.to_dictionary() data["task_order"] = request.task_order.to_dictionary()
internal_comment_form = InternalCommentForm(text=request.internal_comments_text)
if not form: if not form:
mo_data = map_ccpo_authorizing(g.current_user) mo_data = map_ccpo_authorizing(g.current_user)
form = CCPOReviewForm(data=mo_data) form = CCPOReviewForm(data=mo_data)
@ -35,13 +32,12 @@ def render_approval(request, form=None):
return render_template( return render_template(
"requests/approval.html", "requests/approval.html",
data=data, data=data,
status_events=reversed(request.status_events), reviews=list(reversed(request.reviews)),
request=request, request=request,
current_status=request.status.value, current_status=request.status.value,
pending_review=pending_review, pending_review=pending_review,
financial_review=pending_final_approval, financial_review=pending_final_approval,
f=form or CCPOReviewForm(), f=form or CCPOReviewForm(),
internal_comment_form=internal_comment_form,
) )
@ -58,7 +54,7 @@ def submit_approval(request_id):
form = CCPOReviewForm(http_request.form) form = CCPOReviewForm(http_request.form)
if form.validate(): if form.validate():
if http_request.form.get("approved"): if http_request.form.get("review") == "approving":
Requests.advance(g.current_user, request, form.data) Requests.advance(g.current_user, request, form.data)
else: else:
Requests.request_changes(g.current_user, request, form.data) Requests.request_changes(g.current_user, request, form.data)
@ -84,13 +80,3 @@ def task_order_pdf_download(request_id):
else: else:
raise NotFoundError("task_order pdf") raise NotFoundError("task_order pdf")
@requests_bp.route("/requests/internal_comments/<string:request_id>", methods=["POST"])
def create_internal_comment(request_id):
form = InternalCommentForm(http_request.form)
if form.validate():
request = Requests.get(g.current_user, request_id)
Requests.update_internal_comments(g.current_user, request, form.data["text"])
return redirect(url_for("requests.approval", request_id=request_id))

View File

@ -38,7 +38,8 @@ def financial_verification(request_id=None):
return render_template( return render_template(
"requests/financial_verification.html", "requests/financial_verification.html",
f=form, f=form,
request_id=request_id, request=request,
review_comment=request.review_comment,
extended=is_extended(request), extended=is_extended(request),
) )
@ -49,7 +50,7 @@ def update_financial_verification(request_id):
existing_request = Requests.get(g.current_user, request_id) existing_request = Requests.get(g.current_user, request_id)
form = financial_form(existing_request, post_data) form = financial_form(existing_request, post_data)
rerender_args = dict( rerender_args = dict(
request_id=request_id, f=form, extended=is_extended(existing_request) request=existing_request, f=form, extended=is_extended(existing_request)
) )
if form.validate(): if form.validate():

View File

@ -62,6 +62,7 @@ def requests_form_update(screen=1, request_id=None):
next_screen=screen + 1, next_screen=screen + 1,
request_id=request_id, request_id=request_id,
jedi_request=jedi_flow.request, jedi_request=jedi_flow.request,
review_comment=request.review_comment,
can_submit=jedi_flow.can_submit, can_submit=jedi_flow.can_submit,
) )
@ -132,6 +133,10 @@ def view_request_details(request_id=None):
or request.is_approved or request.is_approved
or request.is_pending_financial_verification_changes or request.is_pending_financial_verification_changes
) )
requires_fv_action = (
request.is_pending_financial_verification
or request.is_pending_financial_verification_changes
)
data = request.body data = request.body
if financial_review and request.task_order: if financial_review and request.task_order:
@ -140,10 +145,6 @@ def view_request_details(request_id=None):
return render_template( return render_template(
"requests/details.html", "requests/details.html",
data=data, data=data,
request_id=request.id, request=request,
status=request.status_displayname, requires_fv_action=requires_fv_action,
pending_review=request.is_pending_ccpo_action,
financial_verification=request.is_pending_financial_verification
or request.is_pending_financial_verification_changes,
financial_review=financial_review,
) )

View File

@ -0,0 +1,28 @@
import textinput from '../text_input'
export default {
name: 'ccpo-approval',
components: {
textinput
},
data: function () {
return {
approving: false,
denying: false
}
},
methods: {
setReview: function (e) {
if (e.target.value === 'approving') {
this.approving = true
this.denying = false
} else {
this.approving = false
this.denying = true
}
},
}
}

View File

@ -0,0 +1,21 @@
import { format } from 'date-fns'
export default {
name: 'local-datetime',
props: {
timestamp: String,
format: {
type: String,
default: 'MMM D YYYY H:mm'
}
},
computed: {
displayTime: function () {
return format(this.timestamp, this.format)
}
},
template: '<time v-bind:datetime="timestamp">{{ this.displayTime }}</time>'
}

View File

@ -19,7 +19,8 @@ export default {
default: () => '' default: () => ''
}, },
initialErrors: Array, initialErrors: Array,
paragraph: String paragraph: String,
noMaxWidth: String
}, },
data: function () { data: function () {

View File

@ -17,6 +17,8 @@ import Modal from './mixins/modal'
import selector from './components/selector' import selector from './components/selector'
import BudgetChart from './components/charts/budget_chart' import BudgetChart from './components/charts/budget_chart'
import SpendTable from './components/tables/spend_table' import SpendTable from './components/tables/spend_table'
import CcpoApproval from './components/forms/ccpo_approval'
import LocalDatetime from './components/local_datetime'
Vue.use(VTooltip) Vue.use(VTooltip)
@ -35,7 +37,9 @@ const app = new Vue({
NewProject, NewProject,
selector, selector,
BudgetChart, BudgetChart,
SpendTable SpendTable,
CcpoApproval,
LocalDatetime
}, },
mounted: function() { mounted: function() {
const modalOpen = document.querySelector("#modalOpen") const modalOpen = document.querySelector("#modalOpen")

View File

@ -9,6 +9,10 @@
border-left-width: $gap / 2; border-left-width: $gap / 2;
border-left-style: solid; border-left-style: solid;
@include panel-margin; @include panel-margin;
@include media($medium-screen) {
padding: $gap * 4;
}
} }
@mixin alert-level($level) { @mixin alert-level($level) {
@ -53,6 +57,10 @@
.alert__title { .alert__title {
@include h3; @include h3;
margin-top: 0; margin-top: 0;
&:last-child {
margin-bottom: 0;
}
} }
.alert__content { .alert__content {

View File

@ -5,6 +5,10 @@
margin-top: $gap * 4; margin-top: $gap * 4;
margin-bottom: $gap * 4; margin-bottom: $gap * 4;
&--tight {
margin-top: $gap * 2;
}
.usa-button, .usa-button,
a { a {
margin: 0 0 0 ($gap * 2); margin: 0 0 0 ($gap * 2);

View File

@ -246,6 +246,19 @@
} }
} }
&.no-max-width {
padding-right: $gap * 3;
input, textarea, select, label {
max-width: none;
}
.icon-validation {
left: auto;
right: - $gap * 4;
}
}
&.usa-input--error { &.usa-input--error {
@include input-state('error'); @include input-state('error');
} }

View File

@ -15,6 +15,7 @@
margin: 0 $gap; margin: 0 $gap;
padding: 0 $gap; padding: 0 $gap;
border-radius: $gap / 2; border-radius: $gap / 2;
white-space: nowrap;
&.label--info { &.label--info {
background-color: $color-primary; background-color: $color-primary;

View File

@ -63,6 +63,7 @@
.panel__heading { .panel__heading {
padding: $gap * 2; padding: $gap * 2;
@include media($medium-screen) { @include media($medium-screen) {
padding: $gap * 4; padding: $gap * 4;
} }
@ -71,6 +72,10 @@
padding: $gap*2; padding: $gap*2;
} }
&--divider {
border-bottom: 1px solid $color-gray-light;
}
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
margin: 0; margin: 0;
display: inline-block; display: inline-block;
@ -90,15 +95,16 @@
justify-content: space-between; justify-content: space-between;
} }
} }
hr {
border: 0;
border-bottom: 1px dashed $color-gray-light;
margin: ($gap * 4) ($site-margins*-4);
}
} }
.panel__actions { .panel__actions {
@include panel-actions; @include panel-actions;
} }
hr {
border: 0;
border-bottom: 1px dashed $color-gray-light;
margin: 0px $site-margins*-4;
}

View File

@ -32,6 +32,12 @@
} }
} }
.request-approval__review {
.action-group {
margin-bottom: $gap * 6;
}
}
.approval-log { .approval-log {
ol { ol {
list-style: none; list-style: none;
@ -43,7 +49,7 @@
border-top: 1px dashed $color-gray-light; border-top: 1px dashed $color-gray-light;
&:first-child { &:first-child {
border-top-style: solid; border-top: none;
} }
@include media($medium-screen) { @include media($medium-screen) {
@ -94,4 +100,10 @@
} }
} }
} }
.internal-notes {
textarea {
resize: vertical;
}
}
} }

View File

@ -1,24 +1,35 @@
{% from "components/icon.html" import Icon %} {% from "components/icon.html" import Icon %}
{% from "components/tooltip.html" import Tooltip %} {% from "components/tooltip.html" import Tooltip %}
{% macro TextInput(field, tooltip='', placeholder='', validation='anything', paragraph=False, initial_value='') -%} {% macro TextInput(
field,
label=field.label | striptags,
description=field.description,
tooltip='',
placeholder='',
validation='anything',
paragraph=False,
initial_value='',
noMaxWidth=False) -%}
<textinput <textinput
name='{{ field.name }}' name='{{ field.name }}'
validation='{{ validation }}' validation='{{ validation }}'
{% if paragraph %}paragraph='true'{% endif %} {% if paragraph %}paragraph='true'{% endif %}
{% if noMaxWidth %}no-max-width='true'{% endif %}
{% if initial_value or field.data %}initial-value='{{ initial_value or field.data }}'{% endif %} {% if initial_value or field.data %}initial-value='{{ initial_value or field.data }}'{% endif %}
{% if field.errors %}v-bind:initial-errors='{{ field.errors }}'{% endif %} {% if field.errors %}v-bind:initial-errors='{{ field.errors }}'{% endif %}
key='{{ field.name }}' key='{{ field.name }}'
inline-template> inline-template>
<div <div
v-bind:class="['usa-input usa-input--validation--' + validation, { 'usa-input--error': showError, 'usa-input--success': showValid, 'usa-input--validation--paragraph': paragraph }]"> v-bind:class="['usa-input usa-input--validation--' + validation, { 'usa-input--error': showError, 'usa-input--success': showValid, 'usa-input--validation--paragraph': paragraph, 'no-max-width': noMaxWidth }]">
<label for={{field.name}}> <label for={{field.name}}>
<div class="usa-input__title">{{ field.label | striptags }} {% if tooltip %}{{ Tooltip(tooltip) }}{% endif %}</div> <div class="usa-input__title">{{ label }} {% if tooltip %}{{ Tooltip(tooltip) }}{% endif %}</div>
{% if field.description %} {% if field.description %}
<span class='usa-input__help'>{{ field.description | safe }}</span> <span class='usa-input__help'>{{ description | safe }}</span>
{% endif %} {% endif %}
<span v-show='showError'>{{ Icon('alert',classes="icon-validation") }}</span> <span v-show='showError'>{{ Icon('alert',classes="icon-validation") }}</span>

View File

@ -17,7 +17,7 @@
{{ Icon('avatar', classes='topbar__link-icon') }} {{ Icon('avatar', classes='topbar__link-icon') }}
</a> </a>
<a href="/" class="topbar__link" title='Log out of JEDI Cloud'> <a href="{{ url_for('atst.logout') }}" class="topbar__link" title='Log out of JEDI Cloud'>
{{ Icon('logout', classes='topbar__link-icon') }} {{ Icon('logout', classes='topbar__link-icon') }}
</a> </a>
</div> </div>

View File

@ -6,6 +6,10 @@
{% include 'requests/menu.html' %} {% include 'requests/menu.html' %}
{% if review_comment %}
{% include 'requests/comment.html' %}
{% endif %}
{% block form_action %} {% block form_action %}
{% if request_id %} {% if request_id %}
<form method='POST' action="{{ url_for('requests.requests_form_update', screen=current, request_id=request_id) }}" autocomplete="off"> <form method='POST' action="{{ url_for('requests.requests_form_update', screen=current, request_id=request_id) }}" autocomplete="off">

View File

@ -6,8 +6,9 @@
<div> <div>
<dt>{{ title | safe }}</dt> <dt>{{ title | safe }}</dt>
<dd> <dd>
{% if data[section] and data[section][item_name] %} {% set value = data.get(section, {}).get(item_name) %}
{{ data[section][item_name] | findFilter(filter, filter_args) }} {% if value is not none %}
{{ value | findFilter(filter, filter_args) }}
{% else %} {% else %}
{{ RequiredLabel() }} {{ RequiredLabel() }}
{% endif %} {% endif %}
@ -23,8 +24,6 @@
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
<hr>
<h2> <h2>
Details of Use Details of Use
{% if editable %} {% if editable %}

View File

@ -16,8 +16,9 @@
{% endif %} {% endif %}
<section class='panel'> <section class='panel'>
<header class='panel__heading request-approval__heading'> <header class='panel__heading panel__heading--divider request-approval__heading'>
<h1 class='h2'>Request #{{ request.id }}</h1> <h1 class='h2'>Request #{{ request.id }}
</h1>
<span class='label label--info'>{{ current_status }}</span> <span class='label label--info'>{{ current_status }}</span>
</header> </header>
@ -32,38 +33,58 @@
</section> </section>
{% if pending_review %} {% if pending_review %}
<section class='request-approval__review'>
<form method="POST" action="{{ url_for("requests.submit_approval", request_id=request.id) }}" autocomplete="off"> <form method="POST" action="{{ url_for("requests.submit_approval", request_id=request.id) }}" autocomplete="off">
{{ f.csrf_token }} {{ f.csrf_token }}
<section class='panel'>
<header class='panel__heading'>
<h2 class='h3'>Approval Notes</h2>
</header>
<ccpo-approval inline-template>
<div>
<div class='panel'>
<div class='panel__content'> <div class='panel__content'>
<h2>Review this Request</h2>
<div class="form__sub-fields"> <div class='usa-input'>
<fieldset class='usa-input__choices usa-input__choices--inline'>
<h3>Instructions for the Requestor</h3> <input v-on:change='setReview' type='radio' name='review' id='review-approving' value='approving'/>
<label for='review-approving'>Ready for approval</label>
Provide instructions or notes for additional information that is necessary to approve the request here. The requestor may then re-submit the updated request or initiate contact outside of AT-AT if further discussion is required. <b>These notes will be visible to the person making the JEDI Cloud request</b>.
{{ TextInput(f.comment, paragraph=True, placeholder="Add notes or comments explaining what changes are being requested or why further discussion is needed about this request.") }}
<input v-on:change='setReview' type='radio' name='review' id='review-denying' value='denying'/>
<label for='review-denying'>Request revisions</label>
</fieldset>
</div> </div>
<div class="form-row"> <div v-if='approving || denying' class='form__sub-fields' v-cloak>
<div class="form-col"> <h3>Message to Requestor <span class='subtitle'>(optional)</span></h3>
<h3>Authorizing Officials</h3> <div v-if='approving' key='approving' v-cloak>
<p>This section is not visible to the person making the request. It is only viewable by CCPO staff.</p> {{ TextInput(
<p>Provide the name of the key officials for both parties that have authorized this request to move forward.</p> f.comment,
label='Approval comments or notes',
description='Provide any comments or notes regarding the approval of this request. <strong>This message will be shared with the person making the JEDI request.</strong>.',
paragraph=True,
noMaxWidth=True
) }}
</div>
<div v-else key='denying' v-cloak>
{{ TextInput(
f.comment,
label='Revision instructions or notes',
paragraph=True,
noMaxWidth=True
) }}
</div> </div>
</div> </div>
<h4 class="h3">Mission Authorizing Official</h4>
<div v-if='approving' class='form__sub-fields' v-cloak>
<h3>Authorizing Officials <span class='subtitle'>(optional)</span></h3>
<p>Provide the name of the key officials for both parties that have authorized this request to move forward. <strong>This section is not visible to the person making the request. It is only viewable by CCPO staff.</strong></p>
<hr />
<h4>Mission Authorizing Official</h4>
<div class='form-row'> <div class='form-row'>
<div class='form-col form-col--half'> <div class='form-col form-col--half'>
{{ TextInput(f.fname_mao, placeholder="First name of mission authorizing official") }} {{ TextInput(f.fname_mao, placeholder="First name of mission authorizing official") }}
</div> </div>
@ -71,27 +92,23 @@
<div class='form-col form-col--half'> <div class='form-col form-col--half'>
{{ TextInput(f.lname_mao, placeholder="Last name of mission authorizing official") }} {{ TextInput(f.lname_mao, placeholder="Last name of mission authorizing official") }}
</div> </div>
</div> </div>
<div class='form-row'> <div class='form-row'>
<div class='form-col form-col--half'> <div class='form-col form-col--half'>
{{ TextInput(f.email_mao, placeholder="name@mail.mil") }} {{ TextInput(f.email_mao, placeholder="name@mail.mil", validation='email') }}
</div> </div>
<div class='form-col form-col--half'> <div class='form-col form-col--half'>
{{ TextInput(f.phone_mao, placeholder="(123) 456-7890", validation='usPhone') }} {{ TextInput(f.phone_mao, placeholder="(123) 456-7890", validation='usPhone') }}
</div> </div>
</div> </div>
<hr />
<h4 class="h3">CCPO Authorizing Official</h4> <h4>CCPO Authorizing Official</h4>
<div class='form-row'> <div class='form-row'>
<div class='form-col form-col--half'> <div class='form-col form-col--half'>
{{ TextInput(f.fname_ccpo, placeholder="First name of CCPO authorizing official") }} {{ TextInput(f.fname_ccpo, placeholder="First name of CCPO authorizing official") }}
</div> </div>
@ -99,86 +116,79 @@
<div class='form-col form-col--half'> <div class='form-col form-col--half'>
{{ TextInput(f.lname_ccpo, placeholder="Last name of CCPO authorizing official") }} {{ TextInput(f.lname_ccpo, placeholder="Last name of CCPO authorizing official") }}
</div> </div>
</div>
</div>
</div>
</div> </div>
<div v-if='approving || denying' class='action-group' v-cloak>
<button v-if='approving' type="submit" name="approved" class='usa-button usa-button-big'>Approve Request</button>
</section> <button v-if='denying' type="submit" name="denied" class='usa-button usa-button-big'>Request Revisions</button>
<section class='action-group'>
<input type="submit" name="approved" class='usa-button usa-button-big' value='Approve Request'>
<input type="submit" name="denied" class='usa-button usa-button-big usa-button-secondary' value='Mark as Changes Requested'>
<a href='{{ url_for("requests.requests_index") }}' class='icon-link'> <a href='{{ url_for("requests.requests_index") }}' class='icon-link'>
{{ Icon('x') }} {{ Icon('x') }}
<span>Cancel</span> <span>Cancel</span>
</a> </a>
</section> </div>
</div>
</ccpo-approval>
</form> </form>
</section>
{% endif %} {% endif %}
<section class='panel'> <section>
<h4 class="h3">CCPO Internal Notes</h4> <div class='panel'>
<p>You may add additional comments and notes for internal CCPO reference and follow-up here.</p> <header class='panel__heading panel__heading--divider'>
<div class='form-row'> <h2 class='h3 request-approval__columns__heading'>CCPO Activity Log</h2>
<div class='form-col'>
<form method="POST" action="{{ url_for('requests.create_internal_comment', request_id=request.id) }}">
{{ internal_comment_form.csrf_token }}
{{ TextInput(internal_comment_form.text, paragraph=True) }}
<button type="submit">Leave comment</button>
</form>
</div>
</div>
</div>
</section>
<section class='panel'>
<header class='panel__heading'>
<h2 class='h3 request-approval__columns__heading'>Approval Log</h2>
</header> </header>
<div>
<div class='approval-log'>
<ol>
{% for status_event in status_events %} <div class='approval-log'>
{% if status_event.review %} {% if reviews %}
<ol>
{% for review in reviews %}
<li> <li>
<article class='approval-log__log-item'> <article class='approval-log__log-item'>
<div> <div>
<h3 class='approval-log__log-item__header'>{{ status_event.log_name }} by {{ status_event.review.full_name_reviewer }}</h3> {{ review.log_name }}
{% if status_event.review.comment %} <h3 class='approval-log__log-item__header'>{{ review.status.log_name }} by {{ review.full_name_reviewer }}</h3>
<p>{{ status_event.review.comment }}</p> {% if review.comment %}
<p>{{ review.comment }}</p>
{% endif %} {% endif %}
<div class='approval-log__behalfs'> <div class='approval-log__behalfs'>
{% if status_event.review.lname_mao %} {% if review.lname_mao %}
<div class='approval-log__behalf'> <div class='approval-log__behalf'>
<h3 class='approval-log__log-item__header'>Mission Owner approval on behalf of:</h3> <h3 class='approval-log__log-item__header'>Mission Owner approval on behalf of:</h3>
<span>{{ status_event.review.full_name_mao }}</span> <span>{{ review.full_name_mao }}</span>
<span>{{ status_event.review.email_mao }}</span> <span>{{ review.email_mao }}</span>
<span>{{ status_event.review.phone_mao }}</span> <span>{{ review.phone_mao }}</span>
</div> </div>
{% endif %} {% endif %}
{% if status_event.review.lname_ccpo %} {% if review.lname_ccpo %}
<div class='approval-log__behalf'> <div class='approval-log__behalf'>
<h3 class='approval-log__log-item__header'>CCPO approval on behalf of:</h3> <h3 class='approval-log__log-item__header'>CCPO approval on behalf of:</h3>
<span>{{ status_event.review.full_name_ccpo }}</span> <span>{{ review.full_name_ccpo }}</span>
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% set timestamp=status_event.time_created | formattedDate("%Y-%m-%d %H:%M:%S %Z") %} {% set timestamp=review.status.time_created | formattedDate("%Y-%m-%d %H:%M:%S %Z") %}
<footer class='approval-log__log-item__timestamp'><time datetime='{{ timestamp }}'>{{ timestamp }}</time></footer> <footer class='approval-log__log-item__timestamp'>
<local-datetime timestamp='{{ timestamp }}'></local-datetime>
</footer>
</article> </article>
</li> </li>
{% endif %}
{% endfor %} {% endfor %}
</ol> </ol>
{% else %}
<div class='panel__content'>
<p class='h4'>No CCPO approvals or request changes have been recorded yet.</p>
</div>
{% endif %}
</div> </div>
</div> </div>
</section> </section>
</article> </article>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,9 @@
{% from "components/alert.html" import Alert %}
{% call Alert('Changes Requested', level='warning') %}
<p>CCPO has requested changes to your submission with the following notes:
<br>
{{ review_comment }}
<br>
Please contact info@jedi.cloud or 123-123-4567 for further discussion.</p>
{% endcall %}

View File

@ -5,21 +5,28 @@
{% block content %} {% block content %}
<div class="col"> <div class="col">
{% if financial_verification %} {% if request.is_pending_ccpo_acceptance %}
{% include 'requests/review_menu.html' %}
{% endif %} {{ Alert('Request submitted. Approval pending.', fragment="fragments/pending_ccpo_acceptance_alert.html") }}
{% elif request.is_pending_ccpo_approval %}
{% call Alert('Pending CCPO Approval') %}
<p>The CCPO will review and respond to your Financial Verification submission in 3 business days. You will be notified via email or phone.</p>
<p>Once the financial verification is approved you will be invited to create your JEDI Workspace and set-up your projects. Click here for more details.</p>
{% endcall %}
{% elif requires_fv_action %}
{{ Alert('Pending Financial Verification', fragment="fragments/pending_financial_verification.html") }}
{% include 'requests/review_menu.html' %}
{% if pending_review %}
{{ Alert('Your request is being reviewed',
message="<p>You cannot edit your submitted request while it is under review. Your request will be reviewed within 3 business days.</p>",
level='warning'
) }}
{% endif %} {% endif %}
<div class="panel"> <div class="panel">
<div class="panel__heading"> <div class="panel__heading">
<h1>Request Details</h1><br/> <h1>Request Details</h1><br/>
<h2>#{{ request_id }} <span class="label label--info">{{ status }}</span></h2> <h2>#{{ request.id }} <span class="label label--info">{{ request.status_displayname }}</span></h2>
</div> </div>
<div class="panel__content"> <div class="panel__content">

View File

@ -8,6 +8,14 @@
{% include 'requests/review_menu.html' %} {% include 'requests/review_menu.html' %}
{% if request.is_pending_financial_verification %}
{{ Alert('Pending Financial Verification', fragment="fragments/pending_financial_verification.html") }}
{% endif %}
{% if review_comment %}
{% include 'requests/comment.html' %}
{% endif %}
<financial inline-template v-bind:initial-data='{{ f.data|mixedContentToJson }}'> <financial inline-template v-bind:initial-data='{{ f.data|mixedContentToJson }}'>
<div class="col"> <div class="col">
@ -19,21 +27,18 @@
{% endif %} {% endif %}
{% if f.is_missing_task_order_number %} {% if f.is_missing_task_order_number %}
{% set extended_url = url_for('requests.financial_verification', request_id=request_id, extended=True) %} {% set extended_url = url_for('requests.financial_verification', request_id=request.id, extended=True) %}
{{ Alert('Task Order not found in EDA', {% call Alert('Task Order not found in EDA', level='warning') %}
message="We could not find your Task Order in our system of record, EDA. We could not find your Task Order in our system of record, EDA. Please confirm that you have entered it correctly.<br>
Please confirm that you have entered it correctly.<br> <a class="usa-button" href="{{ extended_url }}">Enter Task Order information manually</a>
<a class=\"usa-button\" href=\"%s\">Enter Task Order information manually</a> {% endcall %}
"|format(extended_url),
level='warning'
) }}
{% endif %} {% endif %}
{% block form_action %} {% block form_action %}
{% if extended %} {% if extended %}
<form method='POST' action="{{ url_for('requests.financial_verification', request_id=request_id, extended=True) }}" autocomplete="off" enctype="multipart/form-data"> <form method='POST' action="{{ url_for('requests.financial_verification', request_id=request.id, extended=True) }}" autocomplete="off" enctype="multipart/form-data">
{% else %} {% else %}
<form method='POST' action="{{ url_for('requests.financial_verification', request_id=request_id) }}" autocomplete="off"> <form method='POST' action="{{ url_for('requests.financial_verification', request_id=request.id) }}" autocomplete="off">
{% endif %} {% endif %}
{% endblock %} {% endblock %}
@ -52,7 +57,7 @@
<div class="panel__heading"> <div class="panel__heading">
<h1>Financial Verification</h1> <h1>Financial Verification</h1>
<div class="subtitle" id="financial-verification"><h2>Order #{{ request_id }}</h2></div> <div class="subtitle" id="financial-verification"><h2>Order #{{ request.id }}</h2></div>
</div> </div>
<div class="panel__content"> <div class="panel__content">

View File

@ -45,18 +45,6 @@
{% else %} {% else %}
{% if pending_financial_verification %}
{{ Alert('Pending Financial Verification', fragment="fragments/pending_financial_verification.html") }}
{% endif %}
{% if pending_ccpo_approval %}
{{ Alert('Request submitted. Approval pending.', fragment="fragments/pending_ccpo_approval_alert.html") }}
{% endif %}
{% if extended_view %} {% if extended_view %}
<div class="row kpi"> <div class="row kpi">
<div class="kpi__item col col--grow"> <div class="kpi__item col col--grow">

View File

@ -1,5 +1,5 @@
{% set pending_url=url_for('requests.view_request_details', request_id=request_id) %} {% set pending_url=url_for('requests.view_request_details', request_id=request.id) %}
{% set financial_url=url_for('requests.financial_verification', request_id=request_id) %} {% set financial_url=url_for('requests.financial_verification', request_id=request.id) %}
<div class="progress-menu progress-menu--four"> <div class="progress-menu progress-menu--four">
<ul> <ul>
<li class="progress-menu__item progress-menu__item--complete"> <li class="progress-menu__item progress-menu__item--complete">

View File

@ -92,3 +92,26 @@ def test_reviews():
RequestStatusEventFactory.create(revision=request.latest_revision), RequestStatusEventFactory.create(revision=request.latest_revision),
] ]
assert len(request.reviews) == 2 assert len(request.reviews) == 2
def test_review_comment():
request = RequestFactory.create()
ccpo = UserFactory.from_atat_role("ccpo")
request.status_events = [
RequestStatusEventFactory.create(
revision=request.latest_revision,
new_status=RequestStatus.CHANGES_REQUESTED,
review=RequestReviewFactory.create(reviewer=ccpo, comment="do better"),
)
]
assert request.review_comment == "do better"
request.status_events = [
RequestStatusEventFactory.create(
revision=request.latest_revision,
new_status=RequestStatus.APPROVED,
review=RequestReviewFactory.create(reviewer=ccpo, comment="much better"),
)
]
assert not request.review_comment

View File

@ -3,9 +3,16 @@ import pytest
from flask import url_for from flask import url_for
from atst.eda_client import MockEDAClient from atst.eda_client import MockEDAClient
from atst.models.request_status_event import RequestStatus
from tests.mocks import MOCK_REQUEST, MOCK_USER from tests.mocks import MOCK_REQUEST, MOCK_USER
from tests.factories import PENumberFactory, RequestFactory, UserFactory from tests.factories import (
PENumberFactory,
RequestFactory,
UserFactory,
RequestStatusEventFactory,
RequestReviewFactory,
)
class TestPENumberInForm: class TestPENumberInForm:
@ -148,3 +155,21 @@ class TestPENumberInForm:
response = self.submit_data(client, user, data, extended=True) response = self.submit_data(client, user, data, extended=True)
assert response.status_code == 200 assert response.status_code == 200
def test_displays_ccpo_review_comment(user_session, client):
creator = UserFactory.create()
ccpo = UserFactory.from_atat_role("ccpo")
user_session(creator)
request = RequestFactory.create(creator=creator)
review_comment = "add all of the correct info, instead of the incorrect info"
request.status_events = [
RequestStatusEventFactory.create(
revision=request.latest_revision,
new_status=RequestStatus.CHANGES_REQUESTED_TO_FINVER,
review=RequestReviewFactory.create(reviewer=ccpo, comment=review_comment),
)
]
response = client.get("/requests/verify/{}".format(request.id))
body = response.data.decode()
assert review_comment in body

View File

@ -87,7 +87,7 @@ def test_can_submit_request_approval(client, user_session):
status=RequestStatus.PENDING_CCPO_ACCEPTANCE status=RequestStatus.PENDING_CCPO_ACCEPTANCE
) )
review_data = RequestReviewFactory.dictionary() review_data = RequestReviewFactory.dictionary()
review_data["approved"] = True review_data["review"] = "approving"
response = client.post( response = client.post(
url_for("requests.submit_approval", request_id=request.id), data=review_data url_for("requests.submit_approval", request_id=request.id), data=review_data
) )
@ -102,7 +102,7 @@ def test_can_submit_request_denial(client, user_session):
status=RequestStatus.PENDING_CCPO_ACCEPTANCE status=RequestStatus.PENDING_CCPO_ACCEPTANCE
) )
review_data = RequestReviewFactory.dictionary() review_data = RequestReviewFactory.dictionary()
review_data["denied"] = True review_data["review"] = "denying"
response = client.post( response = client.post(
url_for("requests.submit_approval", request_id=request.id), data=review_data url_for("requests.submit_approval", request_id=request.id), data=review_data
) )

View File

@ -1,5 +1,12 @@
import re import re
from tests.factories import RequestFactory, UserFactory, RequestRevisionFactory from tests.factories import (
RequestFactory,
UserFactory,
RequestRevisionFactory,
RequestStatusEventFactory,
RequestReviewFactory,
)
from atst.models.request_status_event import RequestStatus
from atst.domain.roles import Roles from atst.domain.roles import Roles
from atst.domain.requests import Requests from atst.domain.requests import Requests
from urllib.parse import urlencode from urllib.parse import urlencode
@ -213,3 +220,21 @@ def test_can_review_data(user_session, client):
# assert a sampling of the request data is on the review page # assert a sampling of the request data is on the review page
assert request.body["primary_poc"]["fname_poc"] in body assert request.body["primary_poc"]["fname_poc"] in body
assert request.body["information_about_you"]["email_request"] in body assert request.body["information_about_you"]["email_request"] in body
def test_displays_ccpo_review_comment(user_session, client):
creator = UserFactory.create()
ccpo = UserFactory.from_atat_role("ccpo")
user_session(creator)
request = RequestFactory.create(creator=creator)
review_comment = "add all of the correct info, instead of the incorrect info"
request.status_events = [
RequestStatusEventFactory.create(
revision=request.latest_revision,
new_status=RequestStatus.CHANGES_REQUESTED,
review=RequestReviewFactory.create(reviewer=ccpo, comment=review_comment),
)
]
response = client.get("/requests/new/1/{}".format(request.id))
body = response.data.decode()
assert review_comment in body

View File

@ -1,3 +1,5 @@
from urllib.parse import urlparse
import pytest import pytest
from flask import session, url_for from flask import session, url_for
from .mocks import DOD_SDN_INFO, DOD_SDN, FIXTURE_EMAIL_ADDRESS from .mocks import DOD_SDN_INFO, DOD_SDN, FIXTURE_EMAIL_ADDRESS
@ -14,6 +16,17 @@ def _fetch_user_info(c, t):
return MOCK_USER return MOCK_USER
def _login(client, verify="SUCCESS", sdn=DOD_SDN, cert=""):
return client.get(
url_for("atst.login_redirect"),
environ_base={
"HTTP_X_SSL_CLIENT_VERIFY": verify,
"HTTP_X_SSL_CLIENT_S_DN": sdn,
"HTTP_X_SSL_CLIENT_CERT": cert,
},
)
def test_successful_login_redirect_non_ccpo(client, monkeypatch): def test_successful_login_redirect_non_ccpo(client, monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
"atst.domain.authnid.AuthenticationContext.authenticate", lambda *args: True "atst.domain.authnid.AuthenticationContext.authenticate", lambda *args: True
@ -23,14 +36,7 @@ def test_successful_login_redirect_non_ccpo(client, monkeypatch):
lambda *args: UserFactory.create(), lambda *args: UserFactory.create(),
) )
resp = client.get( resp = _login(client)
"/login-redirect",
environ_base={
"HTTP_X_SSL_CLIENT_VERIFY": "SUCCESS",
"HTTP_X_SSL_CLIENT_S_DN": "",
"HTTP_X_SSL_CLIENT_CERT": "",
},
)
assert resp.status_code == 302 assert resp.status_code == 302
assert "home" in resp.headers["Location"] assert "home" in resp.headers["Location"]
@ -47,14 +53,7 @@ def test_successful_login_redirect_ccpo(client, monkeypatch):
lambda *args: UserFactory.create(atat_role=role), lambda *args: UserFactory.create(atat_role=role),
) )
resp = client.get( resp = _login(client)
"/login-redirect",
environ_base={
"HTTP_X_SSL_CLIENT_VERIFY": "SUCCESS",
"HTTP_X_SSL_CLIENT_S_DN": "",
"HTTP_X_SSL_CLIENT_CERT": "",
},
)
assert resp.status_code == 302 assert resp.status_code == 302
assert "home" in resp.headers["Location"] assert "home" in resp.headers["Location"]
@ -62,7 +61,7 @@ def test_successful_login_redirect_ccpo(client, monkeypatch):
def test_unsuccessful_login_redirect(client, monkeypatch): def test_unsuccessful_login_redirect(client, monkeypatch):
resp = client.get("/login-redirect") resp = client.get(url_for("atst.login_redirect"))
assert resp.status_code == 401 assert resp.status_code == 401
assert "user_id" not in session assert "user_id" not in session
@ -99,26 +98,12 @@ def test_crl_validation_on_login(client):
bad_cert = open("ssl/client-certs/bad-atat.mil.crt").read() bad_cert = open("ssl/client-certs/bad-atat.mil.crt").read()
# bad cert is on the test CRL # bad cert is on the test CRL
resp = client.get( resp = _login(client, cert=bad_cert)
"/login-redirect",
environ_base={
"HTTP_X_SSL_CLIENT_VERIFY": "SUCCESS",
"HTTP_X_SSL_CLIENT_S_DN": DOD_SDN,
"HTTP_X_SSL_CLIENT_CERT": bad_cert,
},
)
assert resp.status_code == 401 assert resp.status_code == 401
assert "user_id" not in session assert "user_id" not in session
# good cert is not on the test CRL, passes # good cert is not on the test CRL, passes
resp = client.get( resp = _login(client, cert=good_cert)
"/login-redirect",
environ_base={
"HTTP_X_SSL_CLIENT_VERIFY": "SUCCESS",
"HTTP_X_SSL_CLIENT_S_DN": DOD_SDN,
"HTTP_X_SSL_CLIENT_CERT": good_cert,
},
)
assert session["user_id"] assert session["user_id"]
@ -132,14 +117,7 @@ def test_creates_new_user_on_login(monkeypatch, client):
with pytest.raises(NotFoundError): with pytest.raises(NotFoundError):
Users.get_by_dod_id(DOD_SDN_INFO["dod_id"]) Users.get_by_dod_id(DOD_SDN_INFO["dod_id"])
resp = client.get( resp = _login(client, cert=cert_file)
"/login-redirect",
environ_base={
"HTTP_X_SSL_CLIENT_VERIFY": "SUCCESS",
"HTTP_X_SSL_CLIENT_S_DN": DOD_SDN,
"HTTP_X_SSL_CLIENT_CERT": cert_file,
},
)
user = Users.get_by_dod_id(DOD_SDN_INFO["dod_id"]) user = Users.get_by_dod_id(DOD_SDN_INFO["dod_id"])
assert user.first_name == DOD_SDN_INFO["first_name"] assert user.first_name == DOD_SDN_INFO["first_name"]
@ -148,23 +126,36 @@ def test_creates_new_user_on_login(monkeypatch, client):
def test_creates_new_user_without_email_on_login(monkeypatch, client): def test_creates_new_user_without_email_on_login(monkeypatch, client):
monkeypatch.setattr("atst.routes._is_valid_certificate", lambda *args: True)
cert_file = open("ssl/client-certs/atat.mil.crt").read() cert_file = open("ssl/client-certs/atat.mil.crt").read()
# ensure user does not exist # ensure user does not exist
with pytest.raises(NotFoundError): with pytest.raises(NotFoundError):
Users.get_by_dod_id(DOD_SDN_INFO["dod_id"]) Users.get_by_dod_id(DOD_SDN_INFO["dod_id"])
resp = client.get( resp = _login(client, cert=cert_file)
"/login-redirect",
environ_base={
"HTTP_X_SSL_CLIENT_VERIFY": "SUCCESS",
"HTTP_X_SSL_CLIENT_S_DN": DOD_SDN,
"HTTP_X_SSL_CLIENT_CERT": cert_file,
},
)
user = Users.get_by_dod_id(DOD_SDN_INFO["dod_id"]) user = Users.get_by_dod_id(DOD_SDN_INFO["dod_id"])
assert user.first_name == DOD_SDN_INFO["first_name"] assert user.first_name == DOD_SDN_INFO["first_name"]
assert user.last_name == DOD_SDN_INFO["last_name"] assert user.last_name == DOD_SDN_INFO["last_name"]
assert user.email == None assert user.email == None
def test_logout(app, client, monkeypatch):
monkeypatch.setattr(
"atst.domain.authnid.AuthenticationContext.authenticate", lambda s: True
)
monkeypatch.setattr(
"atst.domain.authnid.AuthenticationContext.get_user",
lambda s: UserFactory.create(),
)
# create a real session
resp = _login(client)
resp_success = client.get(url_for("requests.requests_index"))
# verify session is valid
assert resp_success.status_code == 200
client.get(url_for("atst.logout"))
resp_failure = client.get(url_for("requests.requests_index"))
# verify that logging out has cleared the session
assert resp_failure.status_code == 302
destination = urlparse(resp_failure.headers["Location"]).path
assert destination == url_for("atst.root")