From bfcee47db60a7bacdb293b004862f0860f876233 Mon Sep 17 00:00:00 2001 From: dandds Date: Mon, 18 Feb 2019 08:40:35 -0500 Subject: [PATCH 01/11] added dd-254 model --- alembic/versions/7d9f070012ae_dd254.py | 38 ++++++++++++++++++++++++++ atst/models/__init__.py | 1 + atst/models/dd_254.py | 31 +++++++++++++++++++++ atst/models/task_order.py | 3 ++ 4 files changed, 73 insertions(+) create mode 100644 alembic/versions/7d9f070012ae_dd254.py create mode 100644 atst/models/dd_254.py 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/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..8097773e --- /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) + co_title = Column(String) + co_address = Column(String) + co_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 From ad05c448cd2508905ab857f6467f99308af393ce Mon Sep 17 00:00:00 2001 From: dandds Date: Mon, 18 Feb 2019 09:20:23 -0500 Subject: [PATCH 02/11] DD-254 domain class and method for checking if DD-254 is complete --- atst/domain/task_orders.py | 13 +++++++++++++ atst/forms/data.py | 9 +++++++++ tests/domain/test_task_orders.py | 11 ++++++++++- tests/factories.py | 14 ++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/atst/domain/task_orders.py b/atst/domain/task_orders.py index 37bfcc81..e2baecd0 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,15 @@ class TaskOrders(object): raise TaskOrderError( "{} is not an officer role on task orders".format(officer_type) ) + + +class DD254s: + # TODO: standin implementation until we have a real download, + # sign, and verify process for the DD 254 PDF + @classmethod + def complete(cls, dd254): + 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/tests/domain/test_task_orders.py b/tests/domain/test_task_orders.py index d62a8634..d6458da1 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.complete(finished) + assert not DD254s.complete(unfinished) diff --git a/tests/factories.py b/tests/factories.py index 274ed171..079741c3 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") + co_title = factory.Faker("job") + co_address = factory.Faker("address") + co_phone = factory.LazyFunction(random_phone_number) + required_distribution = factory.LazyFunction( + lambda: [random_choice(data.REQUIRED_DISTRIBUTIONS)] + ) From 99ef82e78bee0b369b3a1b73da8299e853253c51 Mon Sep 17 00:00:00 2001 From: dandds Date: Mon, 18 Feb 2019 13:51:01 -0500 Subject: [PATCH 03/11] view SO review screen / DD-254 page --- atst/domain/authz.py | 8 +++- atst/forms/dd_254.py | 38 +++++++++++++++++++ atst/routes/portfolios/task_orders.py | 10 +++++ .../components/multi_checkbox_input.html | 26 +++++++++---- templates/portfolios/task_orders/show.html | 3 ++ .../portfolios/task_orders/so_review.html | 31 +++++++++++++++ tests/routes/portfolios/test_task_orders.py | 32 ++++++++++++++++ translations.yaml | 17 +++++++++ 8 files changed, 156 insertions(+), 9 deletions(-) create mode 100644 atst/forms/dd_254.py create mode 100644 templates/portfolios/task_orders/so_review.html 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/forms/dd_254.py b/atst/forms/dd_254.py new file mode 100644 index 00000000..40db0c2c --- /dev/null +++ b/atst/forms/dd_254.py @@ -0,0 +1,38 @@ +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()], + ) + co_title = StringField( + translate("forms.dd_254.co_title.label"), validators=[Required()] + ) + co_address = StringField( + translate("forms.dd_254.co_address.label"), + description=translate("forms.dd_254.co_address.description"), + validators=[Required()], + ) + co_phone = TelField( + translate("forms.dd_254.co_phone.label"), + description=translate("forms.dd_254.co_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/routes/portfolios/task_orders.py b/atst/routes/portfolios/task_orders.py index 64748875..ab4f78fc 100644 --- a/atst/routes/portfolios/task_orders.py +++ b/atst/routes/portfolios/task_orders.py @@ -11,6 +11,7 @@ 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") @@ -154,3 +155,12 @@ def edit_task_order_invitations(portfolio_id, task_order_id): task_order=task_order, form=form, ) + + +@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) + form = DD254Form() + + Authorization.check_is_so(g.current_user, task_order) + return render_template("portfolios/task_orders/so_review.html", form=form) 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..a8eb70ac 100644 --- a/templates/portfolios/task_orders/show.html +++ b/templates/portfolios/task_orders/show.html @@ -132,10 +132,13 @@ button_text='Edit', complete=all_sections_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, + 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=False) }} {% call Step( description="task_orders.view.steps.record" | translate({ diff --git a/templates/portfolios/task_orders/so_review.html b/templates/portfolios/task_orders/so_review.html new file mode 100644 index 00000000..fb07027e --- /dev/null +++ b/templates/portfolios/task_orders/so_review.html @@ -0,0 +1,31 @@ +{% extends 'portfolios/base.html' %} + +{% from "components/text_input.html" import TextInput %} +{% from "components/multi_checkbox_input.html" import MultiCheckboxInput %} + +{% block content %} + +
    +
    + +
    +

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

    +
    + +
    +

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

    + {{ TextInput(form.certifying_official) }} + {{ TextInput(form.co_title) }} + {{ TextInput(form.co_phone, placeholder='(123) 456-7890', validation='usPhone') }} + {{ TextInput(form.co_address, paragraph=True) }} + +
    + + {{ MultiCheckboxInput(form.required_distribution) }} +
    +
    +
    + +{% endblock %} diff --git a/tests/routes/portfolios/test_task_orders.py b/tests/routes/portfolios/test_task_orders.py index fe994bc8..e0dcbb94 100644 --- a/tests/routes/portfolios/test_task_orders.py +++ b/tests/routes/portfolios/test_task_orders.py @@ -314,3 +314,35 @@ 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_can_view_so_review_page(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 + + user_session(so) + so_response = client.get( + url_for( + "portfolios.so_review", + portfolio_id=portfolio.id, + task_order_id=task_order.id, + ) + ) + assert so_response.status_code == 200 diff --git a/translations.yaml b/translations.yaml index 85bc8ef5..d1cbcd57 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: (First, Last, Middle) + co_title: + label: Title + co_address: + label: Address + description: (Include ZIP Code) + co_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 From 139e31b9a4ca2d0f32beba9f0bc8f946d996b0d0 Mon Sep 17 00:00:00 2001 From: dandds Date: Mon, 18 Feb 2019 16:16:18 -0500 Subject: [PATCH 04/11] display security officer info as defaults for dd 254 form --- atst/routes/portfolios/task_orders.py | 22 ++++++++++++++++-- tests/routes/portfolios/test_task_orders.py | 25 +++++++++++++-------- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/atst/routes/portfolios/task_orders.py b/atst/routes/portfolios/task_orders.py index ab4f78fc..9a8c23a2 100644 --- a/atst/routes/portfolios/task_orders.py +++ b/atst/routes/portfolios/task_orders.py @@ -157,10 +157,28 @@ def edit_task_order_invitations(portfolio_id, task_order_id): ) +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("first_name"), so.get("last_name") + ), + "co_phone": so["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) - form = DD254Form() - 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) diff --git a/tests/routes/portfolios/test_task_orders.py b/tests/routes/portfolios/test_task_orders.py index e0dcbb94..8375d89f 100644 --- a/tests/routes/portfolios/test_task_orders.py +++ b/tests/routes/portfolios/test_task_orders.py @@ -316,7 +316,7 @@ def test_submit_completed_ko_review_page(client, user_session, pdf_upload): ) -def test_so_can_view_so_review_page(client, user_session): +def test_so_review_page(app, client, user_session): portfolio = PortfolioFactory.create() so = UserFactory.create() PortfolioRoleFactory.create( @@ -337,12 +337,19 @@ def test_so_can_view_so_review_page(client, user_session): ) assert owner_response.status_code == 404 - user_session(so) - so_response = client.get( - url_for( - "portfolios.so_review", - portfolio_id=portfolio.id, - task_order_id=task_order.id, + 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 ) - ) - assert so_response.status_code == 200 From 979f700e3650a04b4d428a837ec7b05553183b54 Mon Sep 17 00:00:00 2001 From: dandds Date: Tue, 19 Feb 2019 05:38:47 -0500 Subject: [PATCH 05/11] post data and submit so review --- atst/domain/task_orders.py | 8 +++++ atst/routes/portfolios/task_orders.py | 35 ++++++++++++++++++- .../portfolios/task_orders/so_review.html | 26 ++++++++++---- tests/routes/portfolios/test_task_orders.py | 35 +++++++++++++++++++ 4 files changed, 96 insertions(+), 8 deletions(-) diff --git a/atst/domain/task_orders.py b/atst/domain/task_orders.py index e2baecd0..c5e8e9f9 100644 --- a/atst/domain/task_orders.py +++ b/atst/domain/task_orders.py @@ -173,6 +173,14 @@ class TaskOrders(object): "{} 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, diff --git a/atst/routes/portfolios/task_orders.py b/atst/routes/portfolios/task_orders.py index 9a8c23a2..e83353f3 100644 --- a/atst/routes/portfolios/task_orders.py +++ b/atst/routes/portfolios/task_orders.py @@ -181,4 +181,37 @@ def so_review(portfolio_id, task_order_id): form = so_review_form(task_order) - return render_template("portfolios/task_orders/so_review.html", form=form) + 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/portfolios/task_orders/so_review.html b/templates/portfolios/task_orders/so_review.html index fb07027e..b4ccd365 100644 --- a/templates/portfolios/task_orders/so_review.html +++ b/templates/portfolios/task_orders/so_review.html @@ -5,6 +5,8 @@ {% block content %} +{% include "fragments/flash.html" %} +
    @@ -14,16 +16,26 @@
    +
    -

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

    - {{ TextInput(form.certifying_official) }} - {{ TextInput(form.co_title) }} - {{ TextInput(form.co_phone, placeholder='(123) 456-7890', validation='usPhone') }} - {{ TextInput(form.co_address, paragraph=True) }} +
    + {{ form.csrf_token }} +

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

    + {{ TextInput(form.certifying_official) }} + {{ TextInput(form.co_title) }} + {{ TextInput(form.co_phone, placeholder='(123) 456-7890', validation='usPhone') }} + {{ TextInput(form.co_address, paragraph=True) }} -
    +
    - {{ MultiCheckboxInput(form.required_distribution) }} + {{ MultiCheckboxInput(form.required_distribution) }} + +
    + +
    +
diff --git a/tests/routes/portfolios/test_task_orders.py b/tests/routes/portfolios/test_task_orders.py index 8375d89f..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, ) @@ -353,3 +354,37 @@ def test_so_review_page(app, client, user_session): 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"] From 7665598fe3d0bbedf1264b0dd6004b02486488f0 Mon Sep 17 00:00:00 2001 From: dandds Date: Tue, 19 Feb 2019 06:16:40 -0500 Subject: [PATCH 06/11] update security requirements status to complete when SO review is done --- atst/domain/task_orders.py | 3 +++ atst/routes/portfolios/task_orders.py | 8 +++++--- templates/portfolios/task_orders/show.html | 6 +++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/atst/domain/task_orders.py b/atst/domain/task_orders.py index c5e8e9f9..764cfdaa 100644 --- a/atst/domain/task_orders.py +++ b/atst/domain/task_orders.py @@ -187,6 +187,9 @@ class DD254s: # sign, and verify process for the DD 254 PDF @classmethod def complete(cls, dd254): + if dd254 is None: + return False + for col in DD254.__table__.columns: if getattr(dd254, col.name) is None: return False diff --git a/atst/routes/portfolios/task_orders.py b/atst/routes/portfolios/task_orders.py index e83353f3..f9692c6d 100644 --- a/atst/routes/portfolios/task_orders.py +++ b/atst/routes/portfolios/task_orders.py @@ -4,7 +4,7 @@ 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 @@ -61,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.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, ) diff --git a/templates/portfolios/task_orders/show.html b/templates/portfolios/task_orders/show.html index a8eb70ac..62b5a20f 100644 --- a/templates/portfolios/task_orders/show.html +++ b/templates/portfolios/task_orders/show.html @@ -130,7 +130,7 @@ })| 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( @@ -139,7 +139,7 @@ }) | safe, 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=False) }} + complete=dd_254_complete) }} {% call Step( description="task_orders.view.steps.record" | translate({ "contracting_officer": officer_name(task_order.contracting_officer), @@ -182,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 %} From 47f57351f7c0cacafebed228d29cf42162b0e0ec Mon Sep 17 00:00:00 2001 From: dandds Date: Wed, 20 Feb 2019 11:07:25 -0500 Subject: [PATCH 07/11] fix template variable for rebase --- templates/portfolios/task_orders/show.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/portfolios/task_orders/show.html b/templates/portfolios/task_orders/show.html index 62b5a20f..105ed2c7 100644 --- a/templates/portfolios/task_orders/show.html +++ b/templates/portfolios/task_orders/show.html @@ -206,7 +206,7 @@

Invitations

- {% if all_sections_complete %} + {% if to_form_complete %} {{ "common.manage" | translate }} {{ Icon("edit") }} From 4521b7483faa038ba66f5bda8bf9814a0508a1e9 Mon Sep 17 00:00:00 2001 From: dandds Date: Wed, 20 Feb 2019 11:07:50 -0500 Subject: [PATCH 08/11] better domain method name for checking DD254 completeness --- atst/domain/task_orders.py | 2 +- atst/routes/portfolios/task_orders.py | 2 +- tests/domain/test_task_orders.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/atst/domain/task_orders.py b/atst/domain/task_orders.py index 764cfdaa..6f43aeb3 100644 --- a/atst/domain/task_orders.py +++ b/atst/domain/task_orders.py @@ -186,7 +186,7 @@ class DD254s: # TODO: standin implementation until we have a real download, # sign, and verify process for the DD 254 PDF @classmethod - def complete(cls, dd254): + def is_complete(cls, dd254): if dd254 is None: return False diff --git a/atst/routes/portfolios/task_orders.py b/atst/routes/portfolios/task_orders.py index f9692c6d..9b27771b 100644 --- a/atst/routes/portfolios/task_orders.py +++ b/atst/routes/portfolios/task_orders.py @@ -62,7 +62,7 @@ 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) to_form_complete = TaskOrders.all_sections_complete(task_order) - dd_254_complete = DD254s.complete(task_order.dd_254) + dd_254_complete = DD254s.is_complete(task_order.dd_254) return render_template( "portfolios/task_orders/show.html", portfolio=portfolio, diff --git a/tests/domain/test_task_orders.py b/tests/domain/test_task_orders.py index d6458da1..401d2db9 100644 --- a/tests/domain/test_task_orders.py +++ b/tests/domain/test_task_orders.py @@ -120,5 +120,5 @@ def test_dd254_complete(): finished = DD254Factory.create() unfinished = DD254Factory.create(certifying_official=None) - assert DD254s.complete(finished) - assert not DD254s.complete(unfinished) + assert DD254s.is_complete(finished) + assert not DD254s.is_complete(unfinished) From 41e715a4044ed527a9cd8719c9434e1c48acb486 Mon Sep 17 00:00:00 2001 From: dandds Date: Wed, 20 Feb 2019 11:37:49 -0500 Subject: [PATCH 09/11] use full prefix for certifying official in SO review --- ...full_prefix_for_certifying_official_on_.py | 27 +++++++++++++++++++ atst/forms/dd_254.py | 19 ++++++------- atst/models/dd_254.py | 6 ++--- .../portfolios/task_orders/so_review.html | 6 ++--- tests/factories.py | 6 ++--- translations.yaml | 6 ++--- 6 files changed, 49 insertions(+), 21 deletions(-) create mode 100644 alembic/versions/fa3ba4049218_full_prefix_for_certifying_official_on_.py 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/forms/dd_254.py b/atst/forms/dd_254.py index 40db0c2c..5d03f861 100644 --- a/atst/forms/dd_254.py +++ b/atst/forms/dd_254.py @@ -16,17 +16,18 @@ class DD254Form(CacheableForm): description=translate("forms.dd_254.certifying_official.description"), validators=[Required()], ) - co_title = StringField( - translate("forms.dd_254.co_title.label"), validators=[Required()] - ) - co_address = StringField( - translate("forms.dd_254.co_address.label"), - description=translate("forms.dd_254.co_address.description"), + certifying_official_title = StringField( + translate("forms.dd_254.certifying_official_title.label"), validators=[Required()], ) - co_phone = TelField( - translate("forms.dd_254.co_phone.label"), - description=translate("forms.dd_254.co_phone.description"), + 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( diff --git a/atst/models/dd_254.py b/atst/models/dd_254.py index 8097773e..cd107d3e 100644 --- a/atst/models/dd_254.py +++ b/atst/models/dd_254.py @@ -11,9 +11,9 @@ class DD254(Base, mixins.TimestampsMixin): id = types.Id() certifying_official = Column(String) - co_title = Column(String) - co_address = Column(String) - co_phone = 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") diff --git a/templates/portfolios/task_orders/so_review.html b/templates/portfolios/task_orders/so_review.html index b4ccd365..357cabce 100644 --- a/templates/portfolios/task_orders/so_review.html +++ b/templates/portfolios/task_orders/so_review.html @@ -22,9 +22,9 @@ {{ form.csrf_token }}

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

{{ TextInput(form.certifying_official) }} - {{ TextInput(form.co_title) }} - {{ TextInput(form.co_phone, placeholder='(123) 456-7890', validation='usPhone') }} - {{ TextInput(form.co_address, paragraph=True) }} + {{ TextInput(form.certifying_official_title) }} + {{ TextInput(form.certifying_official_phone, placeholder='(123) 456-7890', validation='usPhone') }} + {{ TextInput(form.certifying_official_address, paragraph=True) }}
diff --git a/tests/factories.py b/tests/factories.py index 079741c3..fd081e34 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -435,9 +435,9 @@ class DD254Factory(Base): model = DD254 certifying_official = factory.Faker("name") - co_title = factory.Faker("job") - co_address = factory.Faker("address") - co_phone = factory.LazyFunction(random_phone_number) + 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/translations.yaml b/translations.yaml index d1cbcd57..4aa35d59 100644 --- a/translations.yaml +++ b/translations.yaml @@ -250,12 +250,12 @@ forms: certifying_official: label: Name of Certifying Official description: (First, Last, Middle) - co_title: + certifying_official_title: label: Title - co_address: + certifying_official_address: label: Address description: (Include ZIP Code) - co_phone: + certifying_official_phone: label: Telephone description: (Include Area Code) required_distribution: From 72f44728824c1cc4dd549532a791c64128bcd38b Mon Sep 17 00:00:00 2001 From: dandds Date: Wed, 20 Feb 2019 11:41:11 -0500 Subject: [PATCH 10/11] provide defaults for SO information on review page --- atst/routes/portfolios/task_orders.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/atst/routes/portfolios/task_orders.py b/atst/routes/portfolios/task_orders.py index 9b27771b..eee63b16 100644 --- a/atst/routes/portfolios/task_orders.py +++ b/atst/routes/portfolios/task_orders.py @@ -169,9 +169,9 @@ def so_review_form(task_order): so = task_order.officer_dictionary("security_officer") form_data = { "certifying_official": "{} {}".format( - so.get("first_name"), so.get("last_name") + so.get("first_name", ""), so.get("last_name", "") ), - "co_phone": so["phone_number"], + "co_phone": so.get("phone_number", ""), } return DD254Form(data=form_data) From b455bb8dcddda8aad51b5a0b2b31518deb01310d Mon Sep 17 00:00:00 2001 From: dandds Date: Wed, 20 Feb 2019 11:48:30 -0500 Subject: [PATCH 11/11] correct name format for certifying official on SO review --- atst/routes/portfolios/task_orders.py | 4 ++-- translations.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/atst/routes/portfolios/task_orders.py b/atst/routes/portfolios/task_orders.py index eee63b16..7fa280c9 100644 --- a/atst/routes/portfolios/task_orders.py +++ b/atst/routes/portfolios/task_orders.py @@ -168,8 +168,8 @@ def so_review_form(task_order): else: so = task_order.officer_dictionary("security_officer") form_data = { - "certifying_official": "{} {}".format( - so.get("first_name", ""), so.get("last_name", "") + "certifying_official": "{}, {}".format( + so.get("last_name", ""), so.get("first_name", "") ), "co_phone": so.get("phone_number", ""), } diff --git a/translations.yaml b/translations.yaml index 4aa35d59..5b2ba8f2 100644 --- a/translations.yaml +++ b/translations.yaml @@ -249,7 +249,7 @@ forms: dd_254: certifying_official: label: Name of Certifying Official - description: (First, Last, Middle) + description: (Last, First, Middle Initial) certifying_official_title: label: Title certifying_official_address: