commit
6c4869ff8e
38
alembic/versions/7d9f070012ae_dd254.py
Normal file
38
alembic/versions/7d9f070012ae_dd254.py
Normal 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')
|
@ -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")
|
@ -39,7 +39,13 @@ class Authorization(object):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def check_is_ko(cls, user, task_order):
|
def check_is_ko(cls, user, task_order):
|
||||||
if task_order.contracting_officer != user:
|
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)
|
raise UnauthorizedError(user, message)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -4,6 +4,7 @@ from flask import current_app as app
|
|||||||
from atst.database import db
|
from atst.database import db
|
||||||
from atst.models.task_order import TaskOrder
|
from atst.models.task_order import TaskOrder
|
||||||
from atst.models.permissions import Permissions
|
from atst.models.permissions import Permissions
|
||||||
|
from atst.models.dd_254 import DD254
|
||||||
from atst.domain.portfolios import Portfolios
|
from atst.domain.portfolios import Portfolios
|
||||||
from atst.domain.authz import Authorization
|
from atst.domain.authz import Authorization
|
||||||
from .exceptions import NotFoundError
|
from .exceptions import NotFoundError
|
||||||
@ -171,3 +172,26 @@ class TaskOrders(object):
|
|||||||
raise TaskOrderError(
|
raise TaskOrderError(
|
||||||
"{} is not an officer role on task orders".format(officer_type)
|
"{} 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
|
||||||
|
@ -213,3 +213,12 @@ TEAM_EXPERIENCE = [
|
|||||||
PERIOD_OF_PERFORMANCE_LENGTH = [
|
PERIOD_OF_PERFORMANCE_LENGTH = [
|
||||||
(str(x + 1), translate_duration(x + 1)) for x in range(24)
|
(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
39
atst/forms/dd_254.py
Normal 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(),
|
||||||
|
)
|
@ -20,3 +20,4 @@ from .request_internal_comment import RequestInternalComment
|
|||||||
from .audit_event import AuditEvent
|
from .audit_event import AuditEvent
|
||||||
from .invitation import Invitation
|
from .invitation import Invitation
|
||||||
from .task_order import TaskOrder
|
from .task_order import TaskOrder
|
||||||
|
from .dd_254 import DD254
|
||||||
|
31
atst/models/dd_254.py
Normal file
31
atst/models/dd_254.py
Normal 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
|
||||||
|
)
|
@ -49,6 +49,9 @@ class TaskOrder(Base, mixins.TimestampsMixin):
|
|||||||
so_id = Column(ForeignKey("users.id"))
|
so_id = Column(ForeignKey("users.id"))
|
||||||
security_officer = relationship("User", foreign_keys="TaskOrder.so_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
|
scope = Column(String) # Cloud Project Scope
|
||||||
defense_component = Column(String) # Department of Defense Component
|
defense_component = Column(String) # Department of Defense Component
|
||||||
app_migration = Column(String) # App Migration
|
app_migration = Column(String) # App Migration
|
||||||
|
@ -4,13 +4,14 @@ from flask import g, redirect, render_template, url_for, request as http_request
|
|||||||
|
|
||||||
from . import portfolios_bp
|
from . import portfolios_bp
|
||||||
from atst.database import db
|
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.exceptions import NotFoundError
|
||||||
from atst.domain.portfolios import Portfolios
|
from atst.domain.portfolios import Portfolios
|
||||||
from atst.domain.authz import Authorization
|
from atst.domain.authz import Authorization
|
||||||
from atst.forms.officers import EditTaskOrderOfficersForm
|
from atst.forms.officers import EditTaskOrderOfficersForm
|
||||||
from atst.models.task_order import Status as TaskOrderStatus
|
from atst.models.task_order import Status as TaskOrderStatus
|
||||||
from atst.forms.ko_review import KOReviewForm
|
from atst.forms.ko_review import KOReviewForm
|
||||||
|
from atst.forms.dd_254 import DD254Form
|
||||||
|
|
||||||
|
|
||||||
@portfolios_bp.route("/portfolios/<portfolio_id>/task_orders")
|
@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):
|
def view_task_order(portfolio_id, task_order_id):
|
||||||
portfolio = Portfolios.get(g.current_user, portfolio_id)
|
portfolio = Portfolios.get(g.current_user, portfolio_id)
|
||||||
task_order = TaskOrders.get(g.current_user, task_order_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(
|
return render_template(
|
||||||
"portfolios/task_orders/show.html",
|
"portfolios/task_orders/show.html",
|
||||||
portfolio=portfolio,
|
portfolio=portfolio,
|
||||||
task_order=task_order,
|
task_order=task_order,
|
||||||
all_sections_complete=completed,
|
to_form_complete=to_form_complete,
|
||||||
|
dd_254_complete=dd_254_complete,
|
||||||
user=g.current_user,
|
user=g.current_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -154,3 +157,63 @@ def edit_task_order_invitations(portfolio_id, task_order_id):
|
|||||||
task_order=task_order,
|
task_order=task_order,
|
||||||
form=form,
|
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,
|
||||||
|
)
|
||||||
|
@ -1,31 +1,39 @@
|
|||||||
{% from "components/icon.html" import Icon %}
|
{% from "components/icon.html" import Icon %}
|
||||||
{% from "components/tooltip.html" import Tooltip %}
|
{% 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
|
<multicheckboxinput
|
||||||
v-cloak
|
v-cloak
|
||||||
name='{{ field.name }}'
|
name='{{ field.name }}'
|
||||||
inline-template
|
inline-template
|
||||||
{% if field.errors %}v-bind:initial-errors='{{ field.errors | list }}'{% endif %}
|
{% 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 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 }}'>
|
key='{{ field.name }}'>
|
||||||
<div
|
<div
|
||||||
v-bind:class="['usa-input', { 'usa-input--error': showError, 'usa-input--success': showValid }]">
|
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 %}">
|
<fieldset v-on:change="onInput" class="usa-input__choices {% if inline %}usa-input__choices--inline{% endif %}">
|
||||||
<legend>
|
<legend>
|
||||||
<div class="usa-input__title">
|
<div class="usa-input__title">
|
||||||
{{ field.label | striptags}}
|
{{ field.label | striptags}}
|
||||||
{% if tooltip %}{{ Tooltip(tooltip) }}{% endif %}
|
{% if tooltip %}{{ Tooltip(tooltip) }}{% endif %}
|
||||||
|
{% if not field.description %}
|
||||||
|
{{ validation_icons }}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if field.description %}
|
{% if field.description %}
|
||||||
<span class='usa-input__help'>{{ field.description | safe }}</span>
|
<span class='usa-input__help'>{{ field.description | safe }}</span>
|
||||||
|
{{ validation_icons }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<span v-show='showError'>{{ Icon('alert',classes="icon-validation") }}</span>
|
|
||||||
<span v-show='showValid'>{{ Icon('ok',classes="icon-validation") }}</span>
|
|
||||||
</legend>
|
</legend>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
@ -38,9 +46,11 @@
|
|||||||
<input @click="otherToggle" type='checkbox' name='{{ field.name }}' id='{{ field.name }}-{{ loop.index0 }}' value='other' v-model="selections"/>
|
<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>
|
<label for='{{ field.name }}-{{ loop.index0 }}'>{{ choice[1] | safe }}</label>
|
||||||
|
|
||||||
<div v-show="otherChecked">
|
{% if other_input_field %}
|
||||||
<input type='text' name='{{ other_input_field.name}}' id='{{ field.name }}-other' v-model:value="otherText" aria-expanded='false' />
|
<div v-show="otherChecked">
|
||||||
</div>
|
<input type='text' name='{{ other_input_field.name}}' id='{{ field.name }}-other' v-model:value="otherText" aria-expanded='false' />
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -130,13 +130,16 @@
|
|||||||
})| safe,
|
})| safe,
|
||||||
button_url=url_for("task_orders.new", screen=1, task_order_id=task_order.id),
|
button_url=url_for("task_orders.new", screen=1, task_order_id=task_order.id),
|
||||||
button_text='Edit',
|
button_text='Edit',
|
||||||
complete=all_sections_complete) %}
|
complete=to_form_complete) %}
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
{% set is_so = user == task_order.security_officer %}
|
||||||
{{ Step(
|
{{ Step(
|
||||||
description="task_orders.view.steps.security" | translate({
|
description="task_orders.view.steps.security" | translate({
|
||||||
"security_officer": officer_name(task_order.security_officer)
|
"security_officer": officer_name(task_order.security_officer)
|
||||||
}) | safe,
|
}) | 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(
|
{% call Step(
|
||||||
description="task_orders.view.steps.record" | translate({
|
description="task_orders.view.steps.record" | translate({
|
||||||
"contracting_officer": officer_name(task_order.contracting_officer),
|
"contracting_officer": officer_name(task_order.contracting_officer),
|
||||||
@ -179,7 +182,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
{{ DocumentLink(
|
{{ DocumentLink(
|
||||||
title="Task Order Draft",
|
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) }}
|
description=description) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@ -203,7 +206,7 @@
|
|||||||
<div class="panel__content">
|
<div class="panel__content">
|
||||||
<div class="task-order-invitations__heading row">
|
<div class="task-order-invitations__heading row">
|
||||||
<h3>Invitations</h3>
|
<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">
|
<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>
|
<span>{{ "common.manage" | translate }}</span>
|
||||||
{{ Icon("edit") }}
|
{{ Icon("edit") }}
|
||||||
|
43
templates/portfolios/task_orders/so_review.html
Normal file
43
templates/portfolios/task_orders/so_review.html
Normal 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 %}
|
@ -1,6 +1,6 @@
|
|||||||
import pytest
|
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.domain.exceptions import UnauthorizedError
|
||||||
from atst.models.attachment import Attachment
|
from atst.models.attachment import Attachment
|
||||||
|
|
||||||
@ -9,6 +9,7 @@ from tests.factories import (
|
|||||||
UserFactory,
|
UserFactory,
|
||||||
PortfolioRoleFactory,
|
PortfolioRoleFactory,
|
||||||
PortfolioFactory,
|
PortfolioFactory,
|
||||||
|
DD254Factory,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -113,3 +114,11 @@ def test_task_order_access():
|
|||||||
"add_officer",
|
"add_officer",
|
||||||
[task_order, "contracting_officer", rando.to_dictionary()],
|
[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)
|
||||||
|
@ -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.portfolio_role import PortfolioRole, Status as PortfolioRoleStatus
|
||||||
from atst.models.environment_role import EnvironmentRole
|
from atst.models.environment_role import EnvironmentRole
|
||||||
from atst.models.invitation import Invitation, Status as InvitationStatus
|
from atst.models.invitation import Invitation, Status as InvitationStatus
|
||||||
|
from atst.models.dd_254 import DD254
|
||||||
from atst.domain.invitations import Invitations
|
from atst.domain.invitations import Invitations
|
||||||
|
|
||||||
|
|
||||||
@ -427,3 +428,16 @@ class TaskOrderFactory(Base):
|
|||||||
so_email = factory.Faker("email")
|
so_email = factory.Faker("email")
|
||||||
so_phone_number = factory.LazyFunction(random_phone_number)
|
so_phone_number = factory.LazyFunction(random_phone_number)
|
||||||
so_dod_id = factory.LazyFunction(random_dod_id)
|
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)]
|
||||||
|
)
|
||||||
|
@ -12,6 +12,7 @@ from tests.factories import (
|
|||||||
PortfolioRoleFactory,
|
PortfolioRoleFactory,
|
||||||
TaskOrderFactory,
|
TaskOrderFactory,
|
||||||
UserFactory,
|
UserFactory,
|
||||||
|
DD254Factory,
|
||||||
random_future_date,
|
random_future_date,
|
||||||
random_past_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(
|
assert response.headers["Location"] == url_for(
|
||||||
"task_orders.signature_requested", task_order_id=task_order.id, _external=True
|
"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"]
|
||||||
|
@ -246,6 +246,20 @@ forms:
|
|||||||
so_invite_label: Invite Security Officer to Task Order Builder
|
so_invite_label: Invite Security Officer to Task Order Builder
|
||||||
skip_invite_description: |
|
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>
|
<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:
|
validators:
|
||||||
is_number_message: Please enter a valid number.
|
is_number_message: Please enter a valid number.
|
||||||
list_item_required_message: Please provide at least one.
|
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.
|
message: Grant your team access to the cloud by verifying the Task Order info below.
|
||||||
review_title: Task Order Builder
|
review_title: Task Order Builder
|
||||||
task_order_information: Task Order Information
|
task_order_information: Task Order Information
|
||||||
|
so_review:
|
||||||
|
title: DD-254 Information
|
||||||
|
certification: Certification
|
||||||
portfolios:
|
portfolios:
|
||||||
task_orders:
|
task_orders:
|
||||||
available_budget_help_description: The available budget shown includes the available budget of all active task orders
|
available_budget_help_description: The available budget shown includes the available budget of all active task orders
|
||||||
|
Loading…
x
Reference in New Issue
Block a user