diff --git a/alembic/versions/4f2d7a5076f2_record_signer_dod_id.py b/alembic/versions/4f2d7a5076f2_record_signer_dod_id.py new file mode 100644 index 00000000..92c09e53 --- /dev/null +++ b/alembic/versions/4f2d7a5076f2_record_signer_dod_id.py @@ -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 ### diff --git a/atst/forms/task_order.py b/atst/forms/task_order.py index 7b5f42e4..e776c3a1 100644 --- a/atst/forms/task_order.py +++ b/atst/forms/task_order.py @@ -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()], + ) diff --git a/atst/models/task_order.py b/atst/models/task_order.py index 4f43b206..69df6b4b 100644 --- a/atst/models/task_order.py +++ b/atst/models/task_order.py @@ -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 diff --git a/atst/routes/portfolios/task_orders.py b/atst/routes/portfolios/task_orders.py index 70c30a33..b65877eb 100644 --- a/atst/routes/portfolios/task_orders.py +++ b/atst/routes/portfolios/task_orders.py @@ -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( 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..b273ad48 --- /dev/null +++ b/atst/routes/task_orders/signing.py @@ -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//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, **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, + ) diff --git a/js/components/levelofwarrant.js b/js/components/levelofwarrant.js new file mode 100644 index 00000000..92a98b3f --- /dev/null +++ b/js/components/levelofwarrant.js @@ -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, + } + }, +}) 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..b80248fb --- /dev/null +++ b/templates/task_orders/signing/signature_requested.html @@ -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.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/templates/task_orders/signing/success.html b/templates/task_orders/signing/success.html new file mode 100644 index 00000000..d7ed04c3 --- /dev/null +++ b/templates/task_orders/signing/success.html @@ -0,0 +1 @@ +

TO was signed successfully

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..590cf280 --- /dev/null +++ b/tests/routes/task_orders/test_sign.py @@ -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 diff --git a/translations.yaml b/translations.yaml index 983db1b9..bdf32516 100644 --- a/translations.yaml +++ b/translations.yaml @@ -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"