Merge pull request #661 from dod-ccpo/dd-254-form

DD-254 form
This commit is contained in:
dandds 2019-02-20 16:04:23 -05:00 committed by GitHub
commit 6c4869ff8e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 428 additions and 17 deletions

View File

@ -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')

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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"),
]

39
atst/forms/dd_254.py Normal file
View File

@ -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(),
)

View File

@ -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

31
atst/models/dd_254.py Normal file
View File

@ -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 "<DD254(certifying_official='{}', task_order='{}', id='{}')>".format(
self.certifying_official, self.task_order.id, self.id
)

View File

@ -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

View File

@ -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/<portfolio_id>/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/<portfolio_id>/task_order/<task_order_id>/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/<portfolio_id>/task_order/<task_order_id>/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,
)

View File

@ -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) -%}
<multicheckboxinput
v-cloak
name='{{ field.name }}'
inline-template
{% if field.errors %}v-bind:initial-errors='{{ field.errors | list }}'{% endif %}
{% if field.data and field.data != "None" %}v-bind:initial-value="{{ field.data }}"{% endif %}
{% if other_input_field.data and other_input_field.data != "None" %}initial-other-value="{{ other_input_field.data }}"{% endif %}
{% if other_input_field and other_input_field.data and other_input_field.data != "None" %}
initial-other-value="{{ other_input_field.data }}"
{% endif %}
key='{{ field.name }}'>
<div
v-bind:class="['usa-input', { 'usa-input--error': showError, 'usa-input--success': showValid }]">
{% set validation_icons %}
<span v-show='showError'>{{ Icon('alert',classes="icon-validation") }}</span>
<span v-show='showValid'>{{ Icon('ok',classes="icon-validation") }}</span>
{% endset %}
<fieldset v-on:change="onInput" class="usa-input__choices {% if inline %}usa-input__choices--inline{% endif %}">
<legend>
<div class="usa-input__title">
{{ field.label | striptags}}
{% if tooltip %}{{ Tooltip(tooltip) }}{% endif %}
{% if not field.description %}
{{ validation_icons }}
{% endif %}
</div>
{% if field.description %}
<span class='usa-input__help'>{{ field.description | safe }}</span>
{{ validation_icons }}
{% endif %}
<span v-show='showError'>{{ Icon('alert',classes="icon-validation") }}</span>
<span v-show='showValid'>{{ Icon('ok',classes="icon-validation") }}</span>
</legend>
<ul>
@ -38,9 +46,11 @@
<input @click="otherToggle" type='checkbox' name='{{ field.name }}' id='{{ field.name }}-{{ loop.index0 }}' value='other' v-model="selections"/>
<label for='{{ field.name }}-{{ loop.index0 }}'>{{ choice[1] | safe }}</label>
<div v-show="otherChecked">
<input type='text' name='{{ other_input_field.name}}' id='{{ field.name }}-other' v-model:value="otherText" aria-expanded='false' />
</div>
{% if other_input_field %}
<div v-show="otherChecked">
<input type='text' name='{{ other_input_field.name}}' id='{{ field.name }}-other' v-model:value="otherText" aria-expanded='false' />
</div>
{% endif %}
{% endif %}
</li>
{% endfor %}

View File

@ -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 %}
</div>
@ -203,7 +206,7 @@
<div class="panel__content">
<div class="task-order-invitations__heading row">
<h3>Invitations</h3>
{% if all_sections_complete %}
{% if to_form_complete %}
<a href="{{ url_for('portfolios.task_order_invitations', portfolio_id=portfolio.id, task_order_id=task_order.id) }}" class="icon-link">
<span>{{ "common.manage" | translate }}</span>
{{ Icon("edit") }}

View File

@ -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" %}
<div class="col">
<div class="panel">
<div class="panel__heading">
<h1 class="subheading">
<div class="h2">{{ "task_orders.so_review.title" | translate }}</div>
</h1>
</div>
<div class="panel__content">
<form method="POST" action='{{ url_for("portfolios.submit_so_review", portfolio_id=portfolio.id, task_order_id=task_order.id) }}'>
{{ form.csrf_token }}
<h3 class="subheading">{{ "task_orders.so_review.certification" | translate }}</h3>
{{ 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) }}
<hr>
{{ MultiCheckboxInput(form.required_distribution) }}
<div class="action-group">
<button class="usa-button usa-button-big usa-button-primary">
Continue
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -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)

View File

@ -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)]
)

View File

@ -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"]

View File

@ -246,6 +246,20 @@ forms:
so_invite_label: Invite Security Officer to Task Order Builder
skip_invite_description: |
<i>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.</i>
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