diff --git a/atst/domain/task_orders.py b/atst/domain/task_orders.py index a433965e..c5d45abd 100644 --- a/atst/domain/task_orders.py +++ b/atst/domain/task_orders.py @@ -1,3 +1,4 @@ +import datetime from flask import current_app as app from atst.database import db @@ -47,6 +48,16 @@ class TaskOrders(BaseDomainClass): return task_order + @classmethod + def sign(cls, task_order, signer_dod_id): + task_order.signer_dod_id = signer_dod_id + task_order.signed_at = datetime.datetime.now() + + db.session.add(task_order) + db.session.commit() + + return task_order + @classmethod def create_clins(cls, task_order_id, clin_list): for clin_data in clin_list: diff --git a/atst/forms/task_order.py b/atst/forms/task_order.py index c418899b..1d622458 100644 --- a/atst/forms/task_order.py +++ b/atst/forms/task_order.py @@ -59,7 +59,6 @@ class TaskOrderForm(BaseForm): class SignatureForm(BaseForm): signature = BooleanField( - translate("task_orders.sign.digital_signature_label"), - description=translate("task_orders.sign.digital_signature_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 5088ce70..2ea5a614 100644 --- a/atst/models/task_order.py +++ b/atst/models/task_order.py @@ -1,3 +1,4 @@ +from datetime import timedelta from enum import Enum from sqlalchemy import Column, DateTime, ForeignKey, String @@ -100,7 +101,8 @@ class TaskOrder(Base, mixins.TimestampsMixin): @property def end_date(self): - return max((c.end_date for c in self.clins), default=None) + default_end_date = self.start_date + timedelta(days=1) + return max((c.end_date for c in self.clins), default=default_end_date) @property def days_to_expiration(self): diff --git a/atst/routes/task_orders/index.py b/atst/routes/task_orders/index.py index 47a0ff56..0746a606 100644 --- a/atst/routes/task_orders/index.py +++ b/atst/routes/task_orders/index.py @@ -1,11 +1,13 @@ -from flask import g, render_template +from flask import g, render_template, url_for, redirect from . import task_orders_bp from atst.domain.authz.decorator import user_can_access_decorator as user_can from atst.domain.portfolios import Portfolios from atst.domain.task_orders import TaskOrders -from atst.models.task_order import Status +from atst.forms.task_order import TaskOrderForm, SignatureForm from atst.models import Permissions +from atst.models.task_order import Status as TaskOrderStatus +from atst.utils.flash import formatted_flash as flash @task_orders_bp.route("/task_orders/") @@ -25,7 +27,28 @@ def view_task_order(task_order_id): @user_can(Permissions.VIEW_TASK_ORDER_DETAILS, message="review task order details") def review_task_order(task_order_id): task_order = TaskOrders.get(task_order_id) - return render_template("portfolios/task_orders/review.html", task_order=task_order) + to_form = TaskOrderForm(number=task_order.number) + signature_form = SignatureForm() + return render_template( + "portfolios/task_orders/review.html", + task_order=task_order, + to_form=to_form, + signature_form=signature_form, + ) + + +@task_orders_bp.route("/task_orders//submit", methods=["POST"]) +@user_can(Permissions.CREATE_TASK_ORDER, "submit task order") +def submit_task_order(task_order_id): + + task_order = TaskOrders.get(task_order_id) + TaskOrders.sign(task_order=task_order, signer_dod_id=g.current_user.dod_id) + + flash("task_order_submitted", task_order=task_order) + + return redirect( + url_for("task_orders.portfolio_funding", portfolio_id=task_order.portfolio.id) + ) @task_orders_bp.route("/portfolios//task_orders") @@ -34,11 +57,11 @@ def portfolio_funding(portfolio_id): portfolio = Portfolios.get(g.current_user, portfolio_id) task_orders = TaskOrders.sort(portfolio.task_orders) label_colors = { - Status.DRAFT: "warning", - Status.ACTIVE: "success", - Status.UPCOMING: "info", - Status.EXPIRED: "error", - Status.UNSIGNED: "purple", + TaskOrderStatus.DRAFT: "warning", + TaskOrderStatus.ACTIVE: "success", + TaskOrderStatus.UPCOMING: "info", + TaskOrderStatus.EXPIRED: "error", + TaskOrderStatus.UNSIGNED: "purple", } return render_template( "portfolios/task_orders/index.html", diff --git a/atst/utils/flash.py b/atst/utils/flash.py index 2b0bf61e..8adf035b 100644 --- a/atst/utils/flash.py +++ b/atst/utils/flash.py @@ -175,7 +175,7 @@ MESSAGES = { "category": "success", }, "task_order_submitted": { - "title_template": "Task Order Form Submitted", + "title_template": "Your Task Order has been uploaded successfully.", "message_template": """ Your task order form for {{ task_order.portfolio_name }} has been submitted. """, diff --git a/js/components/submit_confirmation.js b/js/components/submit_confirmation.js new file mode 100644 index 00000000..fd367a1a --- /dev/null +++ b/js/components/submit_confirmation.js @@ -0,0 +1,21 @@ +import checkboxinput from './checkbox_input' + +export default { + name: 'submit-confirmation', + + components: { + checkboxinput, + }, + + data: function() { + return { + valid: false, + } + }, + + methods: { + toggleValid: function() { + this.valid = !this.valid + }, + }, +} diff --git a/js/index.js b/js/index.js index 2f167883..3a84820e 100644 --- a/js/index.js +++ b/js/index.js @@ -37,6 +37,7 @@ import { isNotInVerticalViewport } from './lib/viewport' import DateSelector from './components/date_selector' import SidenavToggler from './components/sidenav_toggler' import BaseForm from './components/forms/base_form' +import SubmitConfirmation from './components/submit_confirmation' import DeleteConfirmation from './components/delete_confirmation' import NewEnvironment from './components/forms/new_environment' import EnvironmentRole from './components/environment_role' @@ -81,6 +82,7 @@ const app = new Vue({ SidenavToggler, BaseForm, DeleteConfirmation, + SubmitConfirmation, nestedcheckboxinput, NewEnvironment, EnvironmentRole, diff --git a/styles/components/_alerts.scss b/styles/components/_alerts.scss index 6c368667..be326807 100644 --- a/styles/components/_alerts.scss +++ b/styles/components/_alerts.scss @@ -9,6 +9,10 @@ margin-bottom: $gap; } +.usa-alert { + padding-bottom: 2.4rem; +} + @mixin alert { padding: $gap * 2; border-left-width: $gap / 2; diff --git a/styles/components/_modal.scss b/styles/components/_modal.scss index be6e221f..8ae46015 100644 --- a/styles/components/_modal.scss +++ b/styles/components/_modal.scss @@ -8,7 +8,7 @@ body { .modal { position: fixed; - z-index: 6; + z-index: 11; left: 0; right: 0; top: 0; @@ -52,7 +52,7 @@ body { } @include media($medium-screen) { - padding: $gap * 5; + padding: $gap * 2.5 $gap * 5; } h1, @@ -216,4 +216,12 @@ body { } } } + + .usa-button { + min-width: 17rem; + } + + .usa-input .checkbox { + margin-left: 3rem; + } } diff --git a/styles/elements/_inputs.scss b/styles/elements/_inputs.scss index b90e0559..7346d59e 100644 --- a/styles/elements/_inputs.scss +++ b/styles/elements/_inputs.scss @@ -51,6 +51,7 @@ input[type="checkbox"] { + label::before { box-shadow: 0 0 0 2px $state-color; + margin-left: -3rem; } } } @@ -164,10 +165,6 @@ margin-top: 0; margin-bottom: 0; } - - label { - font-weight: $font-bold; - } } select { diff --git a/styles/elements/_typography.scss b/styles/elements/_typography.scss index f45197b6..1ae28d9f 100644 --- a/styles/elements/_typography.scss +++ b/styles/elements/_typography.scss @@ -36,6 +36,11 @@ h6 { * { margin-top: 0; } + + em { + font-style: normal; + font-weight: 400; + } } .h1 { diff --git a/templates/components/submit_confirmation.html b/templates/components/submit_confirmation.html new file mode 100644 index 00000000..023afe5e --- /dev/null +++ b/templates/components/submit_confirmation.html @@ -0,0 +1,31 @@ +{% from "components/alert.html" import Alert %} +{% from "components/checkbox_input.html" import CheckboxInput %} + +{% macro SubmitConfirmation(modal_id, submit_text, submit_action, form, task_order) %} + +
+
+ +
+ {{ CheckboxInput(field=form.signature) }} +
+
+
+
+ {{ form.csrf_token }} + +
+ +
+
+
+{% endmacro %} diff --git a/templates/portfolios/task_orders/index.html b/templates/portfolios/task_orders/index.html index 0e4c52b8..9b561678 100644 --- a/templates/portfolios/task_orders/index.html +++ b/templates/portfolios/task_orders/index.html @@ -68,6 +68,8 @@ Start a new task order {% endcall %} +{% include "fragments/flash.html" %} +
{% if task_orders %} diff --git a/templates/portfolios/task_orders/review.html b/templates/portfolios/task_orders/review.html index 37105715..e52d1704 100644 --- a/templates/portfolios/task_orders/review.html +++ b/templates/portfolios/task_orders/review.html @@ -1,14 +1,29 @@ {% from "components/icon.html" import Icon %} -{% from "components/totals_box.html" import TotalsBox %} +{% from "components/modal.html" import Modal %} {% from "components/semi_collapsible_text.html" import SemiCollapsibleText %} {% from "components/sticky_cta.html" import StickyCTA %} +{% from "components/submit_confirmation.html" import SubmitConfirmation %} +{% from "components/totals_box.html" import TotalsBox %} {% extends 'portfolios/base.html' %} {% block portfolio_content %} + {% set submit_modal_id = "submit-to-1" %} + {% call Modal(name=submit_modal_id) %} + {{ + SubmitConfirmation( + modal_id=submit_modal_id, + submit_text="Confirm & Submit", + submit_action=url_for('task_orders.submit_task_order', task_order_id=task_order.id), + form=signature_form, + task_order=task_order, + ) + }} + {% endcall %} + {% call StickyCTA(text="Review Funding") %} Edit - Submit task order + Submit task order {% endcall %}
diff --git a/tests/routes/task_orders/test_index.py b/tests/routes/task_orders/test_index.py index c1b435fa..d9494ad2 100644 --- a/tests/routes/task_orders/test_index.py +++ b/tests/routes/task_orders/test_index.py @@ -1,20 +1,16 @@ +from datetime import date from flask import url_for import pytest from datetime import timedelta, date from atst.domain.permission_sets import PermissionSets from atst.domain.task_orders import TaskOrders +from atst.models import * from atst.models.portfolio_role import Status as PortfolioStatus +from atst.models.task_order import Status as TaskOrderStatus from atst.utils.localization import translate -from tests.factories import ( - PortfolioFactory, - PortfolioRoleFactory, - TaskOrderFactory, - UserFactory, - random_future_date, - random_past_date, -) +from tests.factories import * from tests.utils import captured_templates @@ -28,6 +24,49 @@ def user(): return UserFactory.create() +@pytest.fixture +def task_order(): + user = UserFactory.create() + portfolio = PortfolioFactory.create(owner=user) + attachment = Attachment(filename="sample_attachment", object_name="sample") + + return TaskOrderFactory.create(creator=user, portfolio=portfolio) + + +def test_review_task_order(client, user_session, task_order): + user_session(task_order.portfolio.owner) + response = client.get( + url_for("task_orders.review_task_order", task_order_id=task_order.id) + ) + assert response.status_code == 200 + + +def test_submit_task_order(client, user_session, task_order): + user_session(task_order.portfolio.owner) + response = client.post( + url_for("task_orders.submit_task_order", task_order_id=task_order.id) + ) + assert response.status_code == 302 + + active_start_date = date.today() - timedelta(days=1) + active_task_order = TaskOrderFactory(portfolio=task_order.portfolio) + CLINFactory(task_order=active_task_order, start_date=active_start_date) + assert active_task_order.status == TaskOrderStatus.UNSIGNED + response = client.post( + url_for("task_orders.submit_task_order", task_order_id=active_task_order.id) + ) + assert active_task_order.status == TaskOrderStatus.ACTIVE + + upcoming_start_date = date.today() + timedelta(days=1) + upcoming_task_order = TaskOrderFactory(portfolio=task_order.portfolio) + CLINFactory(task_order=upcoming_task_order, start_date=upcoming_start_date) + assert upcoming_task_order.status == TaskOrderStatus.UNSIGNED + response = client.post( + url_for("task_orders.submit_task_order", task_order_id=upcoming_task_order.id) + ) + assert upcoming_task_order.status == TaskOrderStatus.UPCOMING + + class TestPortfolioFunding: @pytest.mark.skip(reason="Update later when CLINs are implemented") def test_funded_portfolio(self, app, user_session, portfolio):