Merge pull request #615 from dod-ccpo/signature

Add in basic implementation of the KO TO signature page
This commit is contained in:
George Drummond 2019-02-12 16:35:59 -05:00 committed by GitHub
commit 90db047c3d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 398 additions and 10 deletions

View File

@ -0,0 +1,34 @@
"""Record signer DOD ID
Revision ID: b3a1a07cf30b
Revises: c98adf9bb431
Create Date: 2019-02-12 10:16:19.349083
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b3a1a07cf30b'
down_revision = 'c98adf9bb431'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('task_orders', sa.Column('level_of_warrant', sa.Numeric(scale=2), nullable=True))
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))
op.add_column('task_orders', sa.Column('unlimited_level_of_warrant', sa.Boolean(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('task_orders', 'unlimited_level_of_warrant')
op.drop_column('task_orders', 'signer_dod_id')
op.drop_column('task_orders', 'signed_at')
op.drop_column('task_orders', 'level_of_warrant')
# ### end Alembic commands ###

View File

@ -222,3 +222,28 @@ class OversightForm(CacheableForm):
class ReviewForm(CacheableForm): class ReviewForm(CacheableForm):
pass pass
class SignatureForm(CacheableForm):
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,16 @@ from enum import Enum
from datetime import date from datetime import date
import pendulum import pendulum
from sqlalchemy import Boolean, Column, Numeric, String, ForeignKey, Date, Integer from sqlalchemy import (
Column,
Numeric,
String,
ForeignKey,
Date,
Integer,
DateTime,
Boolean,
)
from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.types import ARRAY from sqlalchemy.types import ARRAY
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@ -80,6 +89,10 @@ class TaskOrder(Base, mixins.TimestampsMixin):
number = Column(String, unique=True) # Task Order Number number = Column(String, unique=True) # Task Order Number
loa = Column(String) # Line of Accounting (LOA) loa = Column(String) # Line of Accounting (LOA)
custom_clauses = Column(String) # Custom Clauses custom_clauses = Column(String) # Custom Clauses
signer_dod_id = Column(String)
signed_at = Column(DateTime)
level_of_warrant = Column(Numeric(scale=2))
unlimited_level_of_warrant = Column(Boolean)
@hybrid_property @hybrid_property
def csp_estimate(self): def csp_estimate(self):

View File

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

View File

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

View File

@ -0,0 +1,74 @@
from flask import url_for, redirect, 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
from atst.utils.flash import formatted_flash as flash
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}
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,
signer_dod_id=g.current_user.dod_id,
signed_at=datetime.datetime.now(),
**form.data,
)
flash("task_order_signed")
return redirect(
url_for(
"portfolios.view_task_order",
portfolio_id=task_order.portfolio_id,
task_order_id=task_order.id,
)
)
else:
return (
render_template(
"task_orders/signing/signature_requested.html",
task_order_id=task_order_id,
form=form,
),
400,
)

View File

@ -1,6 +1,13 @@
from flask import flash, render_template_string from flask import flash, render_template_string
MESSAGES = { MESSAGES = {
"task_order_signed": {
"title_template": "Task Order Signed",
"message_template": """
<p>Task order has been signed successfully</p>
""",
"category": "success",
},
"new_portfolio_member": { "new_portfolio_member": {
"title_template": "Member added successfully", "title_template": "Member added successfully",
"message_template": """ "message_template": """

View File

@ -0,0 +1,27 @@
import textinput from './text_input'
import checkboxinput from './checkbox_input'
import FormMixin from '../mixins/form'
export default {
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 Vue from 'vue/dist/vue'
import VTooltip from 'v-tooltip' import VTooltip from 'v-tooltip'
import levelofwarrant from './components/levelofwarrant'
import optionsinput from './components/options_input' import optionsinput from './components/options_input'
import multicheckboxinput from './components/multi_checkbox_input' import multicheckboxinput from './components/multi_checkbox_input'
import textinput from './components/text_input' import textinput from './components/text_input'
@ -45,6 +46,7 @@ const app = new Vue({
el: '#app-root', el: '#app-root',
components: { components: {
toggler, toggler,
levelofwarrant,
optionsinput, optionsinput,
multicheckboxinput, multicheckboxinput,
textinput, textinput,

View File

@ -0,0 +1,56 @@
{% 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="{{ request.referrer or url_for("atst.home") }}"
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

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

View File

@ -0,0 +1,145 @@
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 (
url_for(
"portfolios.view_task_order",
portfolio_id=task_order.portfolio_id,
task_order_id=task_order.id,
)
in response.headers["Location"]
)
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 (
url_for(
"portfolios.view_task_order",
portfolio_id=task_order.portfolio_id,
task_order_id=task_order.id,
)
in response.headers["Location"]
)
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

@ -19,7 +19,9 @@ base_public:
login: Log in login: Log in
title_tag: JEDI Cloud title_tag: JEDI Cloud
common: common:
back: Back
save_and_continue: Save & Continue save_and_continue: Save & Continue
sign: Sign
components: components:
modal: modal:
close: Close close: Close
@ -401,6 +403,15 @@ requests:
questions_title_text: Questions related to JEDI Cloud migration 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. rationalization_software_systems_tooltip: Rationalization is the DoD process to determine whether the application should move to the cloud.
task_orders: 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: new:
app_info: app_info:
section_title: "What You're Making" section_title: "What You're Making"