diff --git a/alembic/versions/7d9f070012ae_dd254.py b/alembic/versions/7d9f070012ae_dd254.py new file mode 100644 index 00000000..8e93cb60 --- /dev/null +++ b/alembic/versions/7d9f070012ae_dd254.py @@ -0,0 +1,38 @@ +"""dd254 + +Revision ID: 7d9f070012ae +Revises: b3a1a07cf30b +Create Date: 2019-02-18 08:38:07.076612 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '7d9f070012ae' +down_revision = 'b3a1a07cf30b' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table('dd_254s', + sa.Column('time_created', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('time_updated', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('certifying_official', sa.String(), nullable=True), + sa.Column('co_title', sa.String(), nullable=True), + sa.Column('co_address', sa.String(), nullable=True), + sa.Column('co_phone', sa.String(), nullable=True), + sa.Column('required_distribution', sa.ARRAY(sa.String()), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.add_column('task_orders', sa.Column('dd_254_id', postgresql.UUID(as_uuid=True), nullable=True)) + op.create_foreign_key("task_orders_dd_254s_id", 'task_orders', 'dd_254s', ['dd_254_id'], ['id']) + + +def downgrade(): + op.drop_constraint('task_orders_dd_254s_id', 'task_orders', type_='foreignkey') + op.drop_column('task_orders', 'dd_254_id') + op.drop_table('dd_254s') diff --git a/alembic/versions/fa3ba4049218_full_prefix_for_certifying_official_on_.py b/alembic/versions/fa3ba4049218_full_prefix_for_certifying_official_on_.py new file mode 100644 index 00000000..24971589 --- /dev/null +++ b/alembic/versions/fa3ba4049218_full_prefix_for_certifying_official_on_.py @@ -0,0 +1,27 @@ +"""full prefix for certifying official on dd 254 + +Revision ID: fa3ba4049218 +Revises: 7d9f070012ae +Create Date: 2019-02-20 11:19:39.655438 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'fa3ba4049218' +down_revision = '7d9f070012ae' +branch_labels = None +depends_on = None + + +def upgrade(): + op.alter_column("dd_254s", "co_address", new_column_name="certifying_official_address") + op.alter_column("dd_254s", "co_phone", new_column_name="certifying_official_phone") + op.alter_column("dd_254s", "co_title", new_column_name="certifying_official_title") + +def downgrade(): + op.alter_column("dd_254s", "certifying_official_address", new_column_name="co_address") + op.alter_column("dd_254s", "certifying_official_phone", new_column_name="co_phone") + op.alter_column("dd_254s", "certifying_official_title", new_column_name="co_title") diff --git a/atst/domain/authz.py b/atst/domain/authz.py index de3c2156..f2bcc0de 100644 --- a/atst/domain/authz.py +++ b/atst/domain/authz.py @@ -39,7 +39,13 @@ class Authorization(object): @classmethod def check_is_ko(cls, user, task_order): if task_order.contracting_officer != user: - message = "review Task Order {}".format(task_order.id) + message = "review task order {}".format(task_order.id) + raise UnauthorizedError(user, message) + + @classmethod + def check_is_so(cls, user, task_order): + if task_order.security_officer != user: + message = "review task order {}".format(task_order.id) raise UnauthorizedError(user, message) @classmethod diff --git a/atst/domain/task_orders.py b/atst/domain/task_orders.py index 37bfcc81..6f43aeb3 100644 --- a/atst/domain/task_orders.py +++ b/atst/domain/task_orders.py @@ -4,6 +4,7 @@ from flask import current_app as app from atst.database import db from atst.models.task_order import TaskOrder from atst.models.permissions import Permissions +from atst.models.dd_254 import DD254 from atst.domain.portfolios import Portfolios from atst.domain.authz import Authorization from .exceptions import NotFoundError @@ -171,3 +172,26 @@ class TaskOrders(object): raise TaskOrderError( "{} is not an officer role on task orders".format(officer_type) ) + + @classmethod + def add_dd_254(user, task_order, dd_254_data): + dd_254 = DD254(**dd_254_data) + task_order.dd_254 = dd_254 + + db.session.add(task_order) + db.session.commit() + + +class DD254s: + # TODO: standin implementation until we have a real download, + # sign, and verify process for the DD 254 PDF + @classmethod + def is_complete(cls, dd254): + if dd254 is None: + return False + + for col in DD254.__table__.columns: + if getattr(dd254, col.name) is None: + return False + + return True diff --git a/atst/forms/data.py b/atst/forms/data.py index b7b8469c..23b1d0d4 100644 --- a/atst/forms/data.py +++ b/atst/forms/data.py @@ -213,3 +213,12 @@ TEAM_EXPERIENCE = [ PERIOD_OF_PERFORMANCE_LENGTH = [ (str(x + 1), translate_duration(x + 1)) for x in range(24) ] + +REQUIRED_DISTRIBUTIONS = [ + ("contractor", "Contractor"), + ("subcontractor", "Subcontractor"), + ("cognizant_so", "Cognizant Security Office for Prime and Subcontractor"), + ("overseas", "U.S. Activity Responsible for Overseas Security Administration"), + ("administrative_ko", "Administrative Contracting Officer"), + ("other", "Other as necessary"), +] diff --git a/atst/forms/dd_254.py b/atst/forms/dd_254.py new file mode 100644 index 00000000..5d03f861 --- /dev/null +++ b/atst/forms/dd_254.py @@ -0,0 +1,39 @@ +from wtforms.fields import SelectMultipleField, StringField +from wtforms.fields.html5 import TelField +from wtforms.widgets import ListWidget, CheckboxInput +from wtforms.validators import Required + +from atst.forms.validators import PhoneNumber + +from .forms import CacheableForm +from .data import REQUIRED_DISTRIBUTIONS +from atst.utils.localization import translate + + +class DD254Form(CacheableForm): + certifying_official = StringField( + translate("forms.dd_254.certifying_official.label"), + description=translate("forms.dd_254.certifying_official.description"), + validators=[Required()], + ) + certifying_official_title = StringField( + translate("forms.dd_254.certifying_official_title.label"), + validators=[Required()], + ) + certifying_official_address = StringField( + translate("forms.dd_254.certifying_official_address.label"), + description=translate("forms.dd_254.certifying_official_address.description"), + validators=[Required()], + ) + certifying_official_phone = TelField( + translate("forms.dd_254.certifying_official_phone.label"), + description=translate("forms.dd_254.certifying_official_phone.description"), + validators=[Required(), PhoneNumber()], + ) + required_distribution = SelectMultipleField( + translate("forms.dd_254.required_distribution.label"), + choices=REQUIRED_DISTRIBUTIONS, + default="", + widget=ListWidget(prefix_label=False), + option_widget=CheckboxInput(), + ) diff --git a/atst/models/__init__.py b/atst/models/__init__.py index b62161bf..32f7144a 100644 --- a/atst/models/__init__.py +++ b/atst/models/__init__.py @@ -20,3 +20,4 @@ from .request_internal_comment import RequestInternalComment from .audit_event import AuditEvent from .invitation import Invitation from .task_order import TaskOrder +from .dd_254 import DD254 diff --git a/atst/models/dd_254.py b/atst/models/dd_254.py new file mode 100644 index 00000000..cd107d3e --- /dev/null +++ b/atst/models/dd_254.py @@ -0,0 +1,31 @@ +from sqlalchemy import Column, String +from sqlalchemy.types import ARRAY +from sqlalchemy.orm import relationship + +from atst.models import Base, types, mixins + + +class DD254(Base, mixins.TimestampsMixin): + __tablename__ = "dd_254s" + + id = types.Id() + + certifying_official = Column(String) + certifying_official_title = Column(String) + certifying_official_address = Column(String) + certifying_official_phone = Column(String) + required_distribution = Column(ARRAY(String)) + + task_order = relationship("TaskOrder", uselist=False, backref="task_order") + + def to_dictionary(self): + return { + c.name: getattr(self, c.name) + for c in self.__table__.columns + if c.name not in ["id"] + } + + def __repr__(self): + return "".format( + self.certifying_official, self.task_order.id, self.id + ) diff --git a/atst/models/task_order.py b/atst/models/task_order.py index 0e58ed60..6e30cff3 100644 --- a/atst/models/task_order.py +++ b/atst/models/task_order.py @@ -49,6 +49,9 @@ class TaskOrder(Base, mixins.TimestampsMixin): so_id = Column(ForeignKey("users.id")) security_officer = relationship("User", foreign_keys="TaskOrder.so_id") + dd_254_id = Column(ForeignKey("dd_254s.id")) + dd_254 = relationship("DD254") + scope = Column(String) # Cloud Project Scope defense_component = Column(String) # Department of Defense Component app_migration = Column(String) # App Migration diff --git a/atst/routes/portfolios/task_orders.py b/atst/routes/portfolios/task_orders.py index 64748875..7fa280c9 100644 --- a/atst/routes/portfolios/task_orders.py +++ b/atst/routes/portfolios/task_orders.py @@ -4,13 +4,14 @@ from flask import g, redirect, render_template, url_for, request as http_request from . import portfolios_bp from atst.database import db -from atst.domain.task_orders import TaskOrders +from atst.domain.task_orders import TaskOrders, DD254s from atst.domain.exceptions import NotFoundError from atst.domain.portfolios import Portfolios from atst.domain.authz import Authorization from atst.forms.officers import EditTaskOrderOfficersForm from atst.models.task_order import Status as TaskOrderStatus from atst.forms.ko_review import KOReviewForm +from atst.forms.dd_254 import DD254Form @portfolios_bp.route("/portfolios//task_orders") @@ -60,12 +61,14 @@ def portfolio_funding(portfolio_id): def view_task_order(portfolio_id, task_order_id): portfolio = Portfolios.get(g.current_user, portfolio_id) task_order = TaskOrders.get(g.current_user, task_order_id) - completed = TaskOrders.all_sections_complete(task_order) + to_form_complete = TaskOrders.all_sections_complete(task_order) + dd_254_complete = DD254s.is_complete(task_order.dd_254) return render_template( "portfolios/task_orders/show.html", portfolio=portfolio, task_order=task_order, - all_sections_complete=completed, + to_form_complete=to_form_complete, + dd_254_complete=dd_254_complete, user=g.current_user, ) @@ -154,3 +157,63 @@ def edit_task_order_invitations(portfolio_id, task_order_id): task_order=task_order, form=form, ) + + +def so_review_form(task_order): + if task_order.dd_254: + dd_254 = task_order.dd_254 + form = DD254Form(obj=dd_254) + form.required_distribution.data = dd_254.required_distribution + return form + else: + so = task_order.officer_dictionary("security_officer") + form_data = { + "certifying_official": "{}, {}".format( + so.get("last_name", ""), so.get("first_name", "") + ), + "co_phone": so.get("phone_number", ""), + } + return DD254Form(data=form_data) + + +@portfolios_bp.route("/portfolios//task_order//dd254") +def so_review(portfolio_id, task_order_id): + task_order = TaskOrders.get(g.current_user, task_order_id) + Authorization.check_is_so(g.current_user, task_order) + + form = so_review_form(task_order) + + return render_template( + "portfolios/task_orders/so_review.html", + form=form, + portfolio=task_order.portfolio, + task_order=task_order, + ) + + +@portfolios_bp.route( + "/portfolios//task_order//dd254", methods=["POST"] +) +def submit_so_review(portfolio_id, task_order_id): + task_order = TaskOrders.get(g.current_user, task_order_id) + Authorization.check_is_so(g.current_user, task_order) + + form = DD254Form(http_request.form) + + if form.validate(): + TaskOrders.add_dd_254(task_order, form.data) + # TODO: will redirect to download, sign, upload page + return redirect( + url_for( + "portfolios.view_task_order", + portfolio_id=task_order.portfolio.id, + task_order_id=task_order.id, + ) + ) + else: + return render_template( + "portfolios/task_orders/so_review.html", + form=form, + portfolio=task_order.portfolio, + task_order=task_order, + ) diff --git a/templates/components/multi_checkbox_input.html b/templates/components/multi_checkbox_input.html index a5477dc0..7167ffee 100644 --- a/templates/components/multi_checkbox_input.html +++ b/templates/components/multi_checkbox_input.html @@ -1,31 +1,39 @@ {% from "components/icon.html" import Icon %} {% from "components/tooltip.html" import Tooltip %} -{% macro MultiCheckboxInput(field, other_input_field, tooltip, inline=False) -%} +{% macro MultiCheckboxInput(field, other_input_field=None, tooltip=None, inline=False) -%}
+ {% set validation_icons %} + {{ Icon('alert',classes="icon-validation") }} + {{ Icon('ok',classes="icon-validation") }} + {% endset %} +
{{ field.label | striptags}} {% if tooltip %}{{ Tooltip(tooltip) }}{% endif %} + {% if not field.description %} + {{ validation_icons }} + {% endif %}
{% if field.description %} {{ field.description | safe }} + {{ validation_icons }} {% endif %} - - {{ Icon('alert',classes="icon-validation") }} - {{ Icon('ok',classes="icon-validation") }}
    @@ -38,9 +46,11 @@ -
    - -
    + {% if other_input_field %} +
    + +
    + {% endif %} {% endif %} {% endfor %} diff --git a/templates/portfolios/task_orders/show.html b/templates/portfolios/task_orders/show.html index ce3791bf..105ed2c7 100644 --- a/templates/portfolios/task_orders/show.html +++ b/templates/portfolios/task_orders/show.html @@ -130,13 +130,16 @@ })| safe, button_url=url_for("task_orders.new", screen=1, task_order_id=task_order.id), button_text='Edit', - complete=all_sections_complete) %} + complete=to_form_complete) %} {% endcall %} + {% set is_so = user == task_order.security_officer %} {{ Step( description="task_orders.view.steps.security" | translate({ "security_officer": officer_name(task_order.security_officer) }) | safe, - complete=False) }} + button_url=is_so and url_for("portfolios.so_review", portfolio_id=portfolio.id, task_order_id=task_order.id), + button_text=is_so and 'Edit', + complete=dd_254_complete) }} {% call Step( description="task_orders.view.steps.record" | translate({ "contracting_officer": officer_name(task_order.contracting_officer), @@ -179,7 +182,7 @@ {% else %} {{ DocumentLink( title="Task Order Draft", - link_url=all_sections_complete and url_for('task_orders.download_summary', task_order_id=task_order.id), + link_url=to_form_complete and url_for('task_orders.download_summary', task_order_id=task_order.id), description=description) }} {% endif %}
@@ -203,7 +206,7 @@

Invitations

- {% if all_sections_complete %} + {% if to_form_complete %} {{ "common.manage" | translate }} {{ Icon("edit") }} diff --git a/templates/portfolios/task_orders/so_review.html b/templates/portfolios/task_orders/so_review.html new file mode 100644 index 00000000..357cabce --- /dev/null +++ b/templates/portfolios/task_orders/so_review.html @@ -0,0 +1,43 @@ +{% extends 'portfolios/base.html' %} + +{% from "components/text_input.html" import TextInput %} +{% from "components/multi_checkbox_input.html" import MultiCheckboxInput %} + +{% block content %} + +{% include "fragments/flash.html" %} + +
+
+ +
+

+
{{ "task_orders.so_review.title" | translate }}
+

+
+ + +
+
+ {{ form.csrf_token }} +

{{ "task_orders.so_review.certification" | translate }}

+ {{ TextInput(form.certifying_official) }} + {{ TextInput(form.certifying_official_title) }} + {{ TextInput(form.certifying_official_phone, placeholder='(123) 456-7890', validation='usPhone') }} + {{ TextInput(form.certifying_official_address, paragraph=True) }} + +
+ + {{ MultiCheckboxInput(form.required_distribution) }} + +
+ +
+
+
+
+
+ +{% endblock %} diff --git a/tests/domain/test_task_orders.py b/tests/domain/test_task_orders.py index d62a8634..401d2db9 100644 --- a/tests/domain/test_task_orders.py +++ b/tests/domain/test_task_orders.py @@ -1,6 +1,6 @@ import pytest -from atst.domain.task_orders import TaskOrders, TaskOrderError +from atst.domain.task_orders import TaskOrders, TaskOrderError, DD254s from atst.domain.exceptions import UnauthorizedError from atst.models.attachment import Attachment @@ -9,6 +9,7 @@ from tests.factories import ( UserFactory, PortfolioRoleFactory, PortfolioFactory, + DD254Factory, ) @@ -113,3 +114,11 @@ def test_task_order_access(): "add_officer", [task_order, "contracting_officer", rando.to_dictionary()], ) + + +def test_dd254_complete(): + finished = DD254Factory.create() + unfinished = DD254Factory.create(certifying_official=None) + + assert DD254s.is_complete(finished) + assert not DD254s.is_complete(unfinished) diff --git a/tests/factories.py b/tests/factories.py index 274ed171..fd081e34 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -24,6 +24,7 @@ from atst.domain.roles import Roles, PORTFOLIO_ROLES from atst.models.portfolio_role import PortfolioRole, Status as PortfolioRoleStatus from atst.models.environment_role import EnvironmentRole from atst.models.invitation import Invitation, Status as InvitationStatus +from atst.models.dd_254 import DD254 from atst.domain.invitations import Invitations @@ -427,3 +428,16 @@ class TaskOrderFactory(Base): so_email = factory.Faker("email") so_phone_number = factory.LazyFunction(random_phone_number) so_dod_id = factory.LazyFunction(random_dod_id) + + +class DD254Factory(Base): + class Meta: + model = DD254 + + certifying_official = factory.Faker("name") + certifying_official_title = factory.Faker("job") + certifying_official_address = factory.Faker("address") + certifying_official_phone = factory.LazyFunction(random_phone_number) + required_distribution = factory.LazyFunction( + lambda: [random_choice(data.REQUIRED_DISTRIBUTIONS)] + ) diff --git a/tests/routes/portfolios/test_task_orders.py b/tests/routes/portfolios/test_task_orders.py index fe994bc8..c6ecc97d 100644 --- a/tests/routes/portfolios/test_task_orders.py +++ b/tests/routes/portfolios/test_task_orders.py @@ -12,6 +12,7 @@ from tests.factories import ( PortfolioRoleFactory, TaskOrderFactory, UserFactory, + DD254Factory, random_future_date, random_past_date, ) @@ -314,3 +315,76 @@ def test_submit_completed_ko_review_page(client, user_session, pdf_upload): assert response.headers["Location"] == url_for( "task_orders.signature_requested", task_order_id=task_order.id, _external=True ) + + +def test_so_review_page(app, client, user_session): + portfolio = PortfolioFactory.create() + so = UserFactory.create() + PortfolioRoleFactory.create( + role=Roles.get("officer"), + portfolio=portfolio, + user=so, + status=PortfolioStatus.ACTIVE, + ) + task_order = TaskOrderFactory.create(portfolio=portfolio, security_officer=so) + + user_session(portfolio.owner) + owner_response = client.get( + url_for( + "portfolios.so_review", + portfolio_id=portfolio.id, + task_order_id=task_order.id, + ) + ) + assert owner_response.status_code == 404 + + with captured_templates(app) as templates: + user_session(so) + so_response = app.test_client().get( + url_for( + "portfolios.so_review", + portfolio_id=portfolio.id, + task_order_id=task_order.id, + ) + ) + _, context = templates[0] + form = context["form"] + co_name = form.certifying_official.data + assert so_response.status_code == 200 + assert ( + task_order.so_first_name in co_name and task_order.so_last_name in co_name + ) + + +def test_submit_so_review(app, client, user_session): + portfolio = PortfolioFactory.create() + so = UserFactory.create() + PortfolioRoleFactory.create( + role=Roles.get("officer"), + portfolio=portfolio, + user=so, + status=PortfolioStatus.ACTIVE, + ) + task_order = TaskOrderFactory.create(portfolio=portfolio, security_officer=so) + dd_254_data = DD254Factory.dictionary() + + user_session(so) + response = client.post( + url_for( + "portfolios.submit_so_review", + portfolio_id=portfolio.id, + task_order_id=task_order.id, + ), + data=dd_254_data, + ) + expected_redirect = url_for( + "portfolios.view_task_order", + portfolio_id=portfolio.id, + task_order_id=task_order.id, + _external=True, + ) + assert response.status_code == 302 + assert response.headers["Location"] == expected_redirect + + assert task_order.dd_254 + assert task_order.dd_254.certifying_official == dd_254_data["certifying_official"] diff --git a/translations.yaml b/translations.yaml index 85bc8ef5..5b2ba8f2 100644 --- a/translations.yaml +++ b/translations.yaml @@ -246,6 +246,20 @@ forms: so_invite_label: Invite Security Officer to Task Order Builder skip_invite_description: | An invitation won't actually be sent until you click Done on the Review page. You can skip this for now and invite them later. + dd_254: + certifying_official: + label: Name of Certifying Official + description: (Last, First, Middle Initial) + certifying_official_title: + label: Title + certifying_official_address: + label: Address + description: (Include ZIP Code) + certifying_official_phone: + label: Telephone + description: (Include Area Code) + required_distribution: + label: Required Distribution by the Certifying Official validators: is_number_message: Please enter a valid number. list_item_required_message: Please provide at least one. @@ -510,6 +524,9 @@ task_orders: message: Grant your team access to the cloud by verifying the Task Order info below. review_title: Task Order Builder task_order_information: Task Order Information + so_review: + title: DD-254 Information + certification: Certification portfolios: task_orders: available_budget_help_description: The available budget shown includes the available budget of all active task orders