diff --git a/alembic/versions/b3a1a07cf30b_record_signer_dod_id.py b/alembic/versions/b3a1a07cf30b_record_signer_dod_id.py new file mode 100644 index 00000000..8fd1930d --- /dev/null +++ b/alembic/versions/b3a1a07cf30b_record_signer_dod_id.py @@ -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 ### diff --git a/atst/forms/task_order.py b/atst/forms/task_order.py index 7b5f42e4..b731e2bd 100644 --- a/atst/forms/task_order.py +++ b/atst/forms/task_order.py @@ -222,3 +222,28 @@ class OversightForm(CacheableForm): class ReviewForm(CacheableForm): 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()], + ) diff --git a/atst/models/task_order.py b/atst/models/task_order.py index 4f43b206..4e338735 100644 --- a/atst/models/task_order.py +++ b/atst/models/task_order.py @@ -2,7 +2,16 @@ from enum import Enum from datetime import date 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.types import ARRAY from sqlalchemy.orm import relationship @@ -80,6 +89,10 @@ class TaskOrder(Base, mixins.TimestampsMixin): number = Column(String, unique=True) # Task Order Number loa = Column(String) # Line of Accounting (LOA) 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 def csp_estimate(self): diff --git a/atst/routes/portfolios/task_orders.py b/atst/routes/portfolios/task_orders.py index eba80695..44edd888 100644 --- a/atst/routes/portfolios/task_orders.py +++ b/atst/routes/portfolios/task_orders.py @@ -92,11 +92,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( diff --git a/atst/routes/task_orders/__init__.py b/atst/routes/task_orders/__init__.py index 15395177..e09d7c91 100644 --- a/atst/routes/task_orders/__init__.py +++ b/atst/routes/task_orders/__init__.py @@ -5,3 +5,4 @@ task_orders_bp = Blueprint("task_orders", __name__) from . import new from . import index from . import invite +from . import signing diff --git a/atst/routes/task_orders/signing.py b/atst/routes/task_orders/signing.py new file mode 100644 index 00000000..61993928 --- /dev/null +++ b/atst/routes/task_orders/signing.py @@ -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//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//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, + ) diff --git a/atst/utils/flash.py b/atst/utils/flash.py index b0100dd0..5f2dfafd 100644 --- a/atst/utils/flash.py +++ b/atst/utils/flash.py @@ -1,6 +1,13 @@ from flask import flash, render_template_string MESSAGES = { + "task_order_signed": { + "title_template": "Task Order Signed", + "message_template": """ +

Task order has been signed successfully

+ """, + "category": "success", + }, "new_portfolio_member": { "title_template": "Member added successfully", "message_template": """ diff --git a/js/components/levelofwarrant.js b/js/components/levelofwarrant.js new file mode 100644 index 00000000..d46e489c --- /dev/null +++ b/js/components/levelofwarrant.js @@ -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, + } + }, +} diff --git a/js/index.js b/js/index.js index 7495c51c..fbdd5814 100644 --- a/js/index.js +++ b/js/index.js @@ -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, diff --git a/templates/task_orders/signing/signature_requested.html b/templates/task_orders/signing/signature_requested.html new file mode 100644 index 00000000..d7ff9cf5 --- /dev/null +++ b/templates/task_orders/signing/signature_requested.html @@ -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.csrf_token }} +
+
+
+
+

+
{{ "task_orders.sign.task_order_builder_title" | translate }}
+ {{ "task_orders.sign.title" | translate }} +

+
+ +
+
+
+ + {{ TextInput(form.level_of_warrant, validation='dollars', placeholder='$0.00', disabled=True) }} + + + + {{ TextInput(form.level_of_warrant, validation='dollars', placeholder='$0.00') }} + + + + {{ CheckboxInput(form.unlimited_level_of_warrant) }} +
+
+ + {{ CheckboxInput(form.signature) }} +
+
+
+ + + + {{ Icon('caret_left') }} + + {{ "common.back" | translate }} + +
+
+
+
+{% endblock %} + diff --git a/tests/routes/portfolios/test_task_orders.py b/tests/routes/portfolios/test_task_orders.py index 5a66abd9..c8c86543 100644 --- a/tests/routes/portfolios/test_task_orders.py +++ b/tests/routes/portfolios/test_task_orders.py @@ -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 ) diff --git a/tests/routes/task_orders/test_sign.py b/tests/routes/task_orders/test_sign.py new file mode 100644 index 00000000..0196c4dd --- /dev/null +++ b/tests/routes/task_orders/test_sign.py @@ -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 diff --git a/translations.yaml b/translations.yaml index 183a6237..7c8ee7ed 100644 --- a/translations.yaml +++ b/translations.yaml @@ -19,7 +19,9 @@ base_public: login: Log in title_tag: JEDI Cloud common: + back: Back save_and_continue: Save & Continue + sign: Sign components: modal: close: Close @@ -401,6 +403,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"