From 8689748d10d714091e22ae0d852694d1959eee02 Mon Sep 17 00:00:00 2001 From: George Drummond Date: Tue, 5 Feb 2019 15:48:27 -0500 Subject: [PATCH 01/11] Add in basic implementation of the KO TO signature page --- .../4f2d7a5076f2_record_signer_dod_id.py | 50 +++++++ atst/forms/task_order.py | 29 ++++ atst/models/task_order.py | 5 +- atst/routes/portfolios/task_orders.py | 6 +- atst/routes/task_orders/__init__.py | 1 + atst/routes/task_orders/signing.py | 61 +++++++++ js/components/levelofwarrant.js | 29 ++++ js/index.js | 2 + .../signing/signature_requested.html | 54 ++++++++ templates/task_orders/signing/success.html | 1 + tests/routes/portfolios/test_task_orders.py | 5 +- tests/routes/task_orders/test_sign.py | 129 ++++++++++++++++++ translations.yaml | 11 ++ 13 files changed, 373 insertions(+), 10 deletions(-) create mode 100644 alembic/versions/4f2d7a5076f2_record_signer_dod_id.py create mode 100644 atst/routes/task_orders/signing.py create mode 100644 js/components/levelofwarrant.js create mode 100644 templates/task_orders/signing/signature_requested.html create mode 100644 templates/task_orders/signing/success.html create mode 100644 tests/routes/task_orders/test_sign.py 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" From 58ff83ace7b85fa4731ecd5e6167a32904ec93a5 Mon Sep 17 00:00:00 2001 From: George Drummond Date: Mon, 11 Feb 2019 10:06:13 -0500 Subject: [PATCH 02/11] Clean up http_form.request --- atst/routes/task_orders/signing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/atst/routes/task_orders/signing.py b/atst/routes/task_orders/signing.py index b273ad48..fe8c440e 100644 --- a/atst/routes/task_orders/signing.py +++ b/atst/routes/task_orders/signing.py @@ -36,7 +36,7 @@ def signature_requested(task_order_id): 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 = {**http_request.form} form_data["signer_dod_id"] = g.current_user.dod_id form_data["signed_at"] = datetime.datetime.now() From 04fea2539448c3b4d979c1cd85c000f4c42ad46a Mon Sep 17 00:00:00 2001 From: George Drummond Date: Mon, 11 Feb 2019 10:11:22 -0500 Subject: [PATCH 03/11] Pass signer_dod_id and signed_at directly to the update method --- atst/forms/task_order.py | 4 ---- atst/routes/task_orders/signing.py | 10 +++++++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/atst/forms/task_order.py b/atst/forms/task_order.py index e776c3a1..b731e2bd 100644 --- a/atst/forms/task_order.py +++ b/atst/forms/task_order.py @@ -225,10 +225,6 @@ class ReviewForm(CacheableForm): 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"), diff --git a/atst/routes/task_orders/signing.py b/atst/routes/task_orders/signing.py index fe8c440e..3c0458cd 100644 --- a/atst/routes/task_orders/signing.py +++ b/atst/routes/task_orders/signing.py @@ -37,8 +37,6 @@ def record_signature(task_order_id): task_order = find_unsigned_ko_to(task_order_id) form_data = {**http_request.form} - 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" @@ -48,7 +46,13 @@ def record_signature(task_order_id): form = SignatureForm(form_data) 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, + signer_dod_id=g.current_user.dod_id, + signed_at=datetime.datetime.now(), + **form.data, + ) return render_template("task_orders/signing/success.html"), 201 else: return ( From 15e75faa3900308bd1b8a0c40073a3ce09cbae63 Mon Sep 17 00:00:00 2001 From: George Drummond Date: Mon, 11 Feb 2019 10:37:03 -0500 Subject: [PATCH 04/11] Add in columns and fix migration --- .../4f2d7a5076f2_record_signer_dod_id.py | 50 ------------------- .../de5276fad9ef_record_signer_dod_id.py | 34 +++++++++++++ atst/models/task_order.py | 3 +- templates/portfolios/blank_slate.html | 19 +++++++ 4 files changed, 55 insertions(+), 51 deletions(-) delete mode 100644 alembic/versions/4f2d7a5076f2_record_signer_dod_id.py create mode 100644 alembic/versions/de5276fad9ef_record_signer_dod_id.py create mode 100644 templates/portfolios/blank_slate.html diff --git a/alembic/versions/4f2d7a5076f2_record_signer_dod_id.py b/alembic/versions/4f2d7a5076f2_record_signer_dod_id.py deleted file mode 100644 index 92c09e53..00000000 --- a/alembic/versions/4f2d7a5076f2_record_signer_dod_id.py +++ /dev/null @@ -1,50 +0,0 @@ -"""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/alembic/versions/de5276fad9ef_record_signer_dod_id.py b/alembic/versions/de5276fad9ef_record_signer_dod_id.py new file mode 100644 index 00000000..3da67c4d --- /dev/null +++ b/alembic/versions/de5276fad9ef_record_signer_dod_id.py @@ -0,0 +1,34 @@ +"""Record signer DOD ID + +Revision ID: de5276fad9ef +Revises: 1f690989e38e +Create Date: 2019-02-11 10:51:51.419346 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'de5276fad9ef' +down_revision = '1f690989e38e' +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/models/task_order.py b/atst/models/task_order.py index 69df6b4b..a28d9782 100644 --- a/atst/models/task_order.py +++ b/atst/models/task_order.py @@ -80,9 +80,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/templates/portfolios/blank_slate.html b/templates/portfolios/blank_slate.html new file mode 100644 index 00000000..3b45b92c --- /dev/null +++ b/templates/portfolios/blank_slate.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} + +{% from "components/empty_state.html" import EmptyState %} +{% from "components/tooltip.html" import Tooltip %} + +{% block global_sidenav %} +{% endblock %} + +{% block content %} + {{ + EmptyState( + action_href=url_for("task_orders.new", screen=1), + action_label=("portfolios.index.empty.start_button" | translate), + icon="cloud", + message=("portfolios.index.empty.title" | translate), + ) + }} +{% endblock %} + From c40543d16a573a5b032d1b34396b57770925c00d Mon Sep 17 00:00:00 2001 From: George Drummond Date: Mon, 11 Feb 2019 10:55:53 -0500 Subject: [PATCH 05/11] Fix formatting --- atst/models/task_order.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/atst/models/task_order.py b/atst/models/task_order.py index a28d9782..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, DateTime +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 From 9902e31d4218396b4b2d476ece90670bba0bfbc7 Mon Sep 17 00:00:00 2001 From: George Drummond Date: Mon, 11 Feb 2019 10:56:05 -0500 Subject: [PATCH 06/11] Add in "Back" link location --- templates/task_orders/signing/signature_requested.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/templates/task_orders/signing/signature_requested.html b/templates/task_orders/signing/signature_requested.html index b80248fb..d7ff9cf5 100644 --- a/templates/task_orders/signing/signature_requested.html +++ b/templates/task_orders/signing/signature_requested.html @@ -41,7 +41,9 @@ {{ "common.sign" | translate }} - + {{ Icon('caret_left') }} {{ "common.back" | translate }} From 9715528f62f0b6c93aa49551a4928ed91ae53a99 Mon Sep 17 00:00:00 2001 From: George Drummond Date: Mon, 11 Feb 2019 11:38:30 -0500 Subject: [PATCH 07/11] Redirect on successful save --- atst/routes/task_orders/signing.py | 11 +++++++++-- tests/routes/task_orders/test_sign.py | 20 ++++++++++++++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/atst/routes/task_orders/signing.py b/atst/routes/task_orders/signing.py index 3c0458cd..595c184b 100644 --- a/atst/routes/task_orders/signing.py +++ b/atst/routes/task_orders/signing.py @@ -1,4 +1,4 @@ -from flask import render_template, g, request as http_request +from flask import url_for, redirect, render_template, g, request as http_request import datetime @@ -53,7 +53,14 @@ def record_signature(task_order_id): signed_at=datetime.datetime.now(), **form.data, ) - return render_template("task_orders/signing/success.html"), 201 + + return redirect( + url_for( + "portfolios.view_task_order", + portfolio_id=task_order.portfolio_id, + task_order_id=task_order.id, + ) + ) else: return ( render_template( diff --git a/tests/routes/task_orders/test_sign.py b/tests/routes/task_orders/test_sign.py index 590cf280..0196c4dd 100644 --- a/tests/routes/task_orders/test_sign.py +++ b/tests/routes/task_orders/test_sign.py @@ -89,7 +89,15 @@ def test_signing_a_task_order(client, user_session): data={"signature": "y", "level_of_warrant": "33.33"}, ) - assert response.status_code == 201 + 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 @@ -122,7 +130,15 @@ def test_signing_a_task_order_unlimited_level_of_warrant(client, user_session): }, ) - assert response.status_code == 201 + 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 From 23d9c5f0e0851fc1533257d3038b4312bec428f5 Mon Sep 17 00:00:00 2001 From: George Drummond Date: Mon, 11 Feb 2019 11:38:54 -0500 Subject: [PATCH 08/11] Delete placeholder template --- templates/task_orders/signing/success.html | 1 - 1 file changed, 1 deletion(-) delete mode 100644 templates/task_orders/signing/success.html diff --git a/templates/task_orders/signing/success.html b/templates/task_orders/signing/success.html deleted file mode 100644 index d7ed04c3..00000000 --- a/templates/task_orders/signing/success.html +++ /dev/null @@ -1 +0,0 @@ -

TO was signed successfully

From eb0d83b6c36820f899648df8149b5c8fa9a59d1b Mon Sep 17 00:00:00 2001 From: George Drummond Date: Mon, 11 Feb 2019 12:12:05 -0500 Subject: [PATCH 09/11] Flash message --- atst/routes/task_orders/signing.py | 2 ++ atst/utils/flash.py | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/atst/routes/task_orders/signing.py b/atst/routes/task_orders/signing.py index 595c184b..61993928 100644 --- a/atst/routes/task_orders/signing.py +++ b/atst/routes/task_orders/signing.py @@ -7,6 +7,7 @@ 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): @@ -54,6 +55,7 @@ def record_signature(task_order_id): **form.data, ) + flash("task_order_signed") return redirect( url_for( "portfolios.view_task_order", 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": """ From f82031ec1929189e7729b00405a7d9455b8f3a75 Mon Sep 17 00:00:00 2001 From: George Drummond Date: Tue, 12 Feb 2019 10:00:43 -0500 Subject: [PATCH 10/11] Fix VUE error --- js/components/levelofwarrant.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/js/components/levelofwarrant.js b/js/components/levelofwarrant.js index 92a98b3f..d46e489c 100644 --- a/js/components/levelofwarrant.js +++ b/js/components/levelofwarrant.js @@ -1,10 +1,8 @@ -import Vue from 'vue' - import textinput from './text_input' import checkboxinput from './checkbox_input' import FormMixin from '../mixins/form' -export default Vue.component('levelofwarrant', { +export default { mixins: [FormMixin], components: { @@ -26,4 +24,4 @@ export default Vue.component('levelofwarrant', { unlimited_level_of_warrant, } }, -}) +} From f4612f516583f11d9fdce4af9002b959d9dd1bce Mon Sep 17 00:00:00 2001 From: George Drummond Date: Tue, 12 Feb 2019 10:16:52 -0500 Subject: [PATCH 11/11] Fix migration --- ..._dod_id.py => b3a1a07cf30b_record_signer_dod_id.py} | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) rename alembic/versions/{de5276fad9ef_record_signer_dod_id.py => b3a1a07cf30b_record_signer_dod_id.py} (87%) diff --git a/alembic/versions/de5276fad9ef_record_signer_dod_id.py b/alembic/versions/b3a1a07cf30b_record_signer_dod_id.py similarity index 87% rename from alembic/versions/de5276fad9ef_record_signer_dod_id.py rename to alembic/versions/b3a1a07cf30b_record_signer_dod_id.py index 3da67c4d..8fd1930d 100644 --- a/alembic/versions/de5276fad9ef_record_signer_dod_id.py +++ b/alembic/versions/b3a1a07cf30b_record_signer_dod_id.py @@ -1,8 +1,8 @@ """Record signer DOD ID -Revision ID: de5276fad9ef -Revises: 1f690989e38e -Create Date: 2019-02-11 10:51:51.419346 +Revision ID: b3a1a07cf30b +Revises: c98adf9bb431 +Create Date: 2019-02-12 10:16:19.349083 """ from alembic import op @@ -10,8 +10,8 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = 'de5276fad9ef' -down_revision = '1f690989e38e' +revision = 'b3a1a07cf30b' +down_revision = 'c98adf9bb431' branch_labels = None depends_on = None