Add in basic implementation of the KO TO signature page

This commit is contained in:
George Drummond 2019-02-05 15:48:27 -05:00
parent 3c7fe2cf24
commit 8689748d10
No known key found for this signature in database
GPG Key ID: 296DD6077123BF17
13 changed files with 373 additions and 10 deletions

View File

@ -0,0 +1,50 @@
"""Record signer DOD ID
Revision ID: 4f2d7a5076f2
Revises: 1f690989e38e
Create Date: 2019-02-07 15:48:11.855697
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4f2d7a5076f2'
down_revision = '1f690989e38e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_index(op.f('ix_audit_events_portfolio_id'), 'audit_events', ['portfolio_id'], unique=False)
op.drop_index('ix_audit_events_workspace_id', table_name='audit_events')
op.create_index(op.f('ix_invitations_portfolio_role_id'), 'invitations', ['portfolio_role_id'], unique=False)
op.drop_index('ix_invitations_workspace_role_id', table_name='invitations')
op.create_index(op.f('ix_portfolio_roles_portfolio_id'), 'portfolio_roles', ['portfolio_id'], unique=False)
op.create_index(op.f('ix_portfolio_roles_user_id'), 'portfolio_roles', ['user_id'], unique=False)
op.create_index('portfolio_role_user_portfolio', 'portfolio_roles', ['user_id', 'portfolio_id'], unique=True)
op.drop_index('ix_workspace_roles_user_id', table_name='portfolio_roles')
op.drop_index('ix_workspace_roles_workspace_id', table_name='portfolio_roles')
op.drop_index('workspace_role_user_workspace', table_name='portfolio_roles')
op.add_column('task_orders', sa.Column('signed_at', sa.DateTime(), nullable=True))
op.add_column('task_orders', sa.Column('signer_dod_id', sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('task_orders', 'signer_dod_id')
op.drop_column('task_orders', 'signed_at')
op.create_index('workspace_role_user_workspace', 'portfolio_roles', ['user_id', 'portfolio_id'], unique=True)
op.create_index('ix_workspace_roles_workspace_id', 'portfolio_roles', ['portfolio_id'], unique=False)
op.create_index('ix_workspace_roles_user_id', 'portfolio_roles', ['user_id'], unique=False)
op.drop_index('portfolio_role_user_portfolio', table_name='portfolio_roles')
op.drop_index(op.f('ix_portfolio_roles_user_id'), table_name='portfolio_roles')
op.drop_index(op.f('ix_portfolio_roles_portfolio_id'), table_name='portfolio_roles')
op.create_index('ix_invitations_workspace_role_id', 'invitations', ['portfolio_role_id'], unique=False)
op.drop_index(op.f('ix_invitations_portfolio_role_id'), table_name='invitations')
op.create_index('ix_audit_events_workspace_id', 'audit_events', ['portfolio_id'], unique=False)
op.drop_index(op.f('ix_audit_events_portfolio_id'), table_name='audit_events')
# ### end Alembic commands ###

View File

@ -222,3 +222,32 @@ class OversightForm(CacheableForm):
class ReviewForm(CacheableForm):
pass
class SignatureForm(CacheableForm):
signer_dod_id = StringField("signer_dod_id")
signed_at = StringField("signed_at")
level_of_warrant = DecimalField(
translate("task_orders.sign.level_of_warrant_label"),
description=translate("task_orders.sign.level_of_warrant_description"),
validators=[
RequiredIf(
lambda form: (
form._fields.get("unlimited_level_of_warrant").data is not True
)
)
],
)
unlimited_level_of_warrant = BooleanField(
translate("task_orders.sign.unlimited_level_of_warrant_description"),
validators=[Optional()],
)
signature = BooleanField(
translate("task_orders.sign.digital_signature_label"),
description=translate("task_orders.sign.digital_signature_description"),
validators=[Required()],
)

View File

@ -2,7 +2,7 @@ from enum import Enum
from datetime import date
import pendulum
from sqlalchemy import Boolean, Column, Numeric, String, ForeignKey, Date, Integer
from sqlalchemy import Boolean, Column, Numeric, String, ForeignKey, Date, Integer, DateTime
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.types import ARRAY
from sqlalchemy.orm import relationship
@ -81,6 +81,9 @@ class TaskOrder(Base, mixins.TimestampsMixin):
loa = Column(String) # Line of Accounting (LOA)
custom_clauses = Column(String) # Custom Clauses
signer_dod_id = Column(String)
signed_at = Column(DateTime)
@hybrid_property
def csp_estimate(self):
return self._csp_estimate

View File

@ -101,11 +101,7 @@ def submit_ko_review(portfolio_id, task_order_id, form=None):
if form.validate():
TaskOrders.update(user=g.current_user, task_order=task_order, **form.data)
return redirect(
url_for(
"portfolios.view_task_order",
portfolio_id=portfolio_id,
task_order_id=task_order_id,
)
url_for("task_orders.signature_requested", task_order_id=task_order_id)
)
else:
return render_template(

View File

@ -5,3 +5,4 @@ task_orders_bp = Blueprint("task_orders", __name__)
from . import new
from . import index
from . import invite
from . import signing

View File

@ -0,0 +1,61 @@
from flask import render_template, g, request as http_request
import datetime
from . import task_orders_bp
from atst.domain.authz import Authorization
from atst.domain.exceptions import NotFoundError
from atst.domain.task_orders import TaskOrders
from atst.forms.task_order import SignatureForm
def find_unsigned_ko_to(task_order_id):
task_order = TaskOrders.get(g.current_user, task_order_id)
Authorization.check_is_ko(g.current_user, task_order)
if task_order.signer_dod_id is not None:
raise NotFoundError("task_order")
return task_order
@task_orders_bp.route("/task_orders/<task_order_id>/digital_signature", methods=["GET"])
def signature_requested(task_order_id):
task_order = find_unsigned_ko_to(task_order_id)
return render_template(
"task_orders/signing/signature_requested.html",
task_order_id=task_order.id,
form=SignatureForm(),
)
@task_orders_bp.route(
"/task_orders/<task_order_id>/digital_signature", methods=["POST"]
)
def record_signature(task_order_id):
task_order = find_unsigned_ko_to(task_order_id)
form_data = {**http_request.form, **http_request.files}
form_data["signer_dod_id"] = g.current_user.dod_id
form_data["signed_at"] = datetime.datetime.now()
if "unlimited_level_of_warrant" in form_data and form_data[
"unlimited_level_of_warrant"
] == ["y"]:
del form_data["level_of_warrant"]
form = SignatureForm(form_data)
if form.validate():
TaskOrders.update(user=g.current_user, task_order=task_order, **form.data)
return render_template("task_orders/signing/success.html"), 201
else:
return (
render_template(
"task_orders/signing/signature_requested.html",
task_order_id=task_order_id,
form=form,
),
400,
)

View File

@ -0,0 +1,29 @@
import Vue from 'vue'
import textinput from './text_input'
import checkboxinput from './checkbox_input'
import FormMixin from '../mixins/form'
export default Vue.component('levelofwarrant', {
mixins: [FormMixin],
components: {
textinput,
checkboxinput,
},
props: {
initialData: {
type: Object,
default: () => ({}),
},
},
data() {
const { unlimited_level_of_warrant = false } = this.initialData
return {
unlimited_level_of_warrant,
}
},
})

View File

@ -6,6 +6,7 @@ import classes from '../styles/atat.scss'
import Vue from 'vue/dist/vue'
import VTooltip from 'v-tooltip'
import levelofwarrant from './components/levelofwarrant'
import optionsinput from './components/options_input'
import multicheckboxinput from './components/multi_checkbox_input'
import textinput from './components/text_input'
@ -45,6 +46,7 @@ const app = new Vue({
el: '#app-root',
components: {
toggler,
levelofwarrant,
optionsinput,
multicheckboxinput,
textinput,

View File

@ -0,0 +1,54 @@
{% extends "base.html" %}
{% from "components/text_input.html" import TextInput %}
{% from "components/checkbox_input.html" import CheckboxInput %}
{% from "components/icon.html" import Icon %}
{% block content %}
<form method="POST" action='{{ url_for("task_orders.record_signature", task_order_id=task_order_id) }}'>
{{ form.csrf_token }}
<div class="row row--pad">
<div class="col col--pad">
<div class="panel">
<div class="panel__heading">
<h1 class="task-order-form__heading subheading">
<div class="h2">{{ "task_orders.sign.task_order_builder_title" | translate }}</div>
{{ "task_orders.sign.title" | translate }}
</h1>
</div>
<div class="panel__content">
<div is="levelofwarrant" inline-template v-bind:initial-data='{{ form.data|tojson }}'>
<div>
<span v-bind:class="{ hide: !unlimited_level_of_warrant }">
{{ TextInput(form.level_of_warrant, validation='dollars', placeholder='$0.00', disabled=True) }}
</span>
<span v-bind:class="{ hide: unlimited_level_of_warrant }">
{{ TextInput(form.level_of_warrant, validation='dollars', placeholder='$0.00') }}
</span>
{{ CheckboxInput(form.unlimited_level_of_warrant) }}
</div>
</div>
{{ CheckboxInput(form.signature) }}
</div>
</div>
<div class="action-group">
<button class="usa-button usa-button-big usa-button-primary">
{{ "common.sign" | translate }}
</button>
<a href="" class="action-group__action icon-link">
{{ Icon('caret_left') }}
<span class="icon icon--x"></span>
{{ "common.back" | translate }}
</a>
</div>
</div>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1 @@
<h1>TO was signed successfully</h1>

View File

@ -284,8 +284,5 @@ def test_submit_completed_ko_review_page(client, user_session, pdf_upload):
assert task_order.pdf
assert response.headers["Location"] == url_for(
"portfolios.view_task_order",
portfolio_id=portfolio.id,
task_order_id=task_order.id,
_external=True,
"task_orders.signature_requested", task_order_id=task_order.id, _external=True
)

View File

@ -0,0 +1,129 @@
from flask import url_for
from atst.domain.task_orders import TaskOrders
from tests.factories import UserFactory, TaskOrderFactory, PortfolioFactory
def create_ko_task_order(user_session, contracting_officer):
portfolio = PortfolioFactory.create(owner=contracting_officer)
user_session(contracting_officer)
return TaskOrderFactory.create(
portfolio=portfolio, contracting_officer=contracting_officer
)
def test_show_signature_requested_not_ko(client, user_session):
contracting_officer = UserFactory.create()
task_order = create_ko_task_order(user_session, contracting_officer)
TaskOrders.update(contracting_officer, task_order, contracting_officer=None)
response = client.get(
url_for("task_orders.signature_requested", task_order_id=task_order.id)
)
assert response.status_code == 404
def test_show_signature_requested(client, user_session):
contracting_officer = UserFactory.create()
task_order = create_ko_task_order(user_session, contracting_officer)
response = client.get(
url_for("task_orders.signature_requested", task_order_id=task_order.id)
)
assert response.status_code == 200
def test_show_signature_requested_already_signed(client, user_session):
contracting_officer = UserFactory.create()
task_order = create_ko_task_order(user_session, contracting_officer)
TaskOrders.update(
contracting_officer, task_order, signer_dod_id=contracting_officer.dod_id
)
response = client.get(
url_for("task_orders.signature_requested", task_order_id=task_order.id)
)
assert response.status_code == 404
def test_signing_task_order_not_ko(client, user_session):
contracting_officer = UserFactory.create()
task_order = create_ko_task_order(user_session, contracting_officer)
TaskOrders.update(contracting_officer, task_order, contracting_officer=None)
response = client.post(
url_for("task_orders.record_signature", task_order_id=task_order.id), data={}
)
assert response.status_code == 404
def test_singing_an_already_signed_task_order(client, user_session):
contracting_officer = UserFactory.create()
task_order = create_ko_task_order(user_session, contracting_officer)
TaskOrders.update(
contracting_officer, task_order, signer_dod_id=contracting_officer.dod_id
)
response = client.post(
url_for("task_orders.record_signature", task_order_id=task_order.id),
data={"signature": "y", "level_of_warrant": "33.33"},
)
assert response.status_code == 404
def test_signing_a_task_order(client, user_session):
contracting_officer = UserFactory.create()
task_order = create_ko_task_order(user_session, contracting_officer)
assert task_order.signed_at is None
assert task_order.signer_dod_id is None
response = client.post(
url_for("task_orders.record_signature", task_order_id=task_order.id),
data={"signature": "y", "level_of_warrant": "33.33"},
)
assert response.status_code == 201
assert task_order.signer_dod_id == contracting_officer.dod_id
assert task_order.signed_at is not None
def test_signing_a_task_order_failure(client, user_session):
contracting_officer = UserFactory.create()
task_order = create_ko_task_order(user_session, contracting_officer)
response = client.post(
url_for("task_orders.record_signature", task_order_id=task_order.id),
data={"level_of_warrant": "33.33"},
)
assert response.status_code == 400
def test_signing_a_task_order_unlimited_level_of_warrant(client, user_session):
contracting_officer = UserFactory.create()
task_order = create_ko_task_order(user_session, contracting_officer)
assert task_order.signed_at is None
assert task_order.signer_dod_id is None
response = client.post(
url_for("task_orders.record_signature", task_order_id=task_order.id),
data={
"signature": "y",
"level_of_warrant": "33.33",
"unlimited_level_of_warrant": "y",
},
)
assert response.status_code == 201
assert task_order.signed_at is not None
assert task_order.signer_dod_id == contracting_officer.dod_id
assert task_order.unlimited_level_of_warrant == True
assert task_order.level_of_warrant == None

View File

@ -20,7 +20,9 @@ base_public:
login: Log in
title_tag: JEDI Cloud
common:
back: Back
save_and_continue: Save & Continue
sign: Sign
components:
modal:
close: Close
@ -397,6 +399,15 @@ requests:
questions_title_text: Questions related to JEDI Cloud migration
rationalization_software_systems_tooltip: Rationalization is the DoD process to determine whether the application should move to the cloud.
task_orders:
sign:
digital_signature_description: I acknowledge that I have read and fully understand the DoD CCPO JEDI agreements and completed all the necessary steps, as detailed in the FAR (Federal Acquisition Regulation).
digital_signature_label: Digital Signature
level_of_warrant_description: This is a dollar value indicating that you have permission to sign any Task Order under this max value.
level_of_warrant_label: Level of Warrant
task_order_builder_title: Task Order Builder
title: Sign Task Order
unlimited_level_of_warrant_description: Unlimited Level of Warrant funds
verify_warrant_level_paragraph: Verify your level of warrant and provide your digital signature to authorize this Task Order.
new:
app_info:
section_title: "What You're Making"