Manually enter task order information #158436096
This commit is contained in:
dandds 2018-08-23 14:33:51 -04:00 committed by GitHub
commit 917ca5b9a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 299 additions and 53 deletions

View File

@ -0,0 +1,30 @@
"""add request -> task order relationship
Revision ID: 0845b2f0f401
Revises: 875e4b8a05fc
Create Date: 2018-08-22 09:58:43.770718
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0845b2f0f401'
down_revision = '875e4b8a05fc'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('requests', sa.Column('task_order_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'requests', 'task_order', ['task_order_id'], ['id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'requests', type_='foreignkey')
op.drop_column('requests', 'task_order_id')
# ### end Alembic commands ###

View File

@ -0,0 +1,44 @@
"""add additional task order fields
Revision ID: 875e4b8a05fc
Revises: f36f130622b9
Create Date: 2018-08-21 15:52:46.636928
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '875e4b8a05fc'
down_revision = 'f36f130622b9'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('task_order', sa.Column('clin_0001', sa.Integer(), nullable=True))
op.add_column('task_order', sa.Column('clin_0003', sa.Integer(), nullable=True))
op.add_column('task_order', sa.Column('clin_1001', sa.Integer(), nullable=True))
op.add_column('task_order', sa.Column('clin_1003', sa.Integer(), nullable=True))
op.add_column('task_order', sa.Column('clin_2001', sa.Integer(), nullable=True))
op.add_column('task_order', sa.Column('clin_2003', sa.Integer(), nullable=True))
op.add_column('task_order', sa.Column('funding_type', sa.String(), nullable=True))
op.add_column('task_order', sa.Column('funding_type_other', sa.String(), nullable=True))
op.add_column('task_order', sa.Column('source', sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('task_order', 'source')
op.drop_column('task_order', 'funding_type_other')
op.drop_column('task_order', 'funding_type')
op.drop_column('task_order', 'clin_2003')
op.drop_column('task_order', 'clin_2001')
op.drop_column('task_order', 'clin_1003')
op.drop_column('task_order', 'clin_1001')
op.drop_column('task_order', 'clin_0003')
op.drop_column('task_order', 'clin_0001')
# ### end Alembic commands ###

View File

@ -8,6 +8,7 @@ from atst.models.request import Request
from atst.models.request_status_event import RequestStatusEvent, RequestStatus from atst.models.request_status_event import RequestStatusEvent, RequestStatus
from atst.domain.workspaces import Workspaces from atst.domain.workspaces import Workspaces
from atst.database import db from atst.database import db
from atst.domain.task_orders import TaskOrders
from .exceptions import NotFoundError from .exceptions import NotFoundError
@ -52,6 +53,7 @@ class Requests(object):
and_(Request.id == request_id, Request.creator == creator) and_(Request.id == request_id, Request.creator == creator)
) )
).scalar() ).scalar()
except exc.DataError: except exc.DataError:
return False return False
@ -71,10 +73,9 @@ class Requests(object):
filters.append(Request.creator == creator) filters.append(Request.creator == creator)
requests = ( requests = (
db.session.query(Request) db.session.query(Request).filter(*filters).order_by(
.filter(*filters) Request.time_created.desc()
.order_by(Request.time_created.desc()) ).all()
.all()
) )
return requests return requests
@ -95,27 +96,39 @@ class Requests(object):
@classmethod @classmethod
def update(cls, request_id, request_delta): def update(cls, request_id, request_delta):
request = Requests._get_with_lock(request_id)
if not request:
return
request = Requests._merge_body(request, request_delta)
db.session.add(request)
db.session.commit()
return request
@classmethod
def _get_with_lock(cls, request_id):
try: try:
# Query for request matching id, acquiring a row-level write lock. # Query for request matching id, acquiring a row-level write lock.
# https://www.postgresql.org/docs/10/static/sql-select.html#SQL-FOR-UPDATE-SHARE # https://www.postgresql.org/docs/10/static/sql-select.html#SQL-FOR-UPDATE-SHARE
request = ( return (
db.session.query(Request) db.session.query(Request).filter_by(id=request_id).with_for_update(
.filter_by(id=request_id) of=Request
.with_for_update(of=Request) ).one()
.one()
) )
except NoResultFound: except NoResultFound:
return return
@classmethod
def _merge_body(cls, request, request_delta):
request.body = deep_merge(request_delta, request.body) request.body = deep_merge(request_delta, request.body)
# Without this, sqlalchemy won't notice the change to request.body, # Without this, sqlalchemy won't notice the change to request.body,
# since it doesn't track dictionary mutations by default. # since it doesn't track dictionary mutations by default.
flag_modified(request, "body") flag_modified(request, "body")
db.session.add(request)
db.session.commit()
return request return request
@classmethod @classmethod
@ -139,8 +152,10 @@ class Requests(object):
return { return {
RequestStatus.STARTED: "mission_owner", RequestStatus.STARTED: "mission_owner",
RequestStatus.PENDING_FINANCIAL_VERIFICATION: "mission_owner", RequestStatus.PENDING_FINANCIAL_VERIFICATION: "mission_owner",
RequestStatus.PENDING_CCPO_APPROVAL: "ccpo" RequestStatus.PENDING_CCPO_APPROVAL: "ccpo",
}.get(request.status) }.get(
request.status
)
@classmethod @classmethod
def should_auto_approve(cls, request): def should_auto_approve(cls, request):
@ -152,16 +167,13 @@ class Requests(object):
return dollar_value < cls.AUTO_APPROVE_THRESHOLD return dollar_value < cls.AUTO_APPROVE_THRESHOLD
_VALID_SUBMISSION_STATUSES = [ _VALID_SUBMISSION_STATUSES = [
RequestStatus.STARTED, RequestStatus.STARTED, RequestStatus.CHANGES_REQUESTED
RequestStatus.CHANGES_REQUESTED,
] ]
@classmethod @classmethod
def should_allow_submission(cls, request): def should_allow_submission(cls, request):
all_request_sections = [ all_request_sections = [
"details_of_use", "details_of_use", "information_about_you", "primary_poc"
"information_about_you",
"primary_poc",
] ]
existing_request_sections = request.body.keys() existing_request_sections = request.body.keys()
return request.status in Requests._VALID_SUBMISSION_STATUSES and all( return request.status in Requests._VALID_SUBMISSION_STATUSES and all(
@ -201,11 +213,13 @@ WHERE requests_with_status.status = :status
@classmethod @classmethod
def in_progress_count(cls): def in_progress_count(cls):
return sum([ return sum(
[
Requests.status_count(RequestStatus.STARTED), Requests.status_count(RequestStatus.STARTED),
Requests.status_count(RequestStatus.PENDING_FINANCIAL_VERIFICATION), Requests.status_count(RequestStatus.PENDING_FINANCIAL_VERIFICATION),
Requests.status_count(RequestStatus.CHANGES_REQUESTED), Requests.status_count(RequestStatus.CHANGES_REQUESTED),
]) ]
)
@classmethod @classmethod
def pending_ccpo_count(cls): def pending_ccpo_count(cls):
@ -215,3 +229,43 @@ WHERE requests_with_status.status = :status
def completed_count(cls): def completed_count(cls):
return Requests.status_count(RequestStatus.APPROVED) return Requests.status_count(RequestStatus.APPROVED)
@classmethod
def update_financial_verification(cls, request_id, financial_data):
request = Requests._get_with_lock(request_id)
if not request:
return
request_data = financial_data.copy()
task_order_data = {
k: request_data.pop(k)
for (k, v) in financial_data.items()
if k in TaskOrders.TASK_ORDER_DATA
}
task_order_number = request_data.pop("task_order_number")
task_order = TaskOrders.get_or_create_task_order(
task_order_number, task_order_data
)
if task_order:
request.task_order = task_order
request = Requests._merge_body(
request, {"financial_verification": request_data}
)
db.session.add(request)
db.session.commit()
return request
@classmethod
def submit_financial_verification(cls, request_id):
request = Requests._get_with_lock(request_id)
if not request:
return
Requests.set_status(request, RequestStatus.PENDING_CCPO_APPROVAL)
db.session.add(request)
db.session.commit()

View File

@ -2,11 +2,12 @@ from sqlalchemy.orm.exc import NoResultFound
from flask import current_app as app 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, Source
from .exceptions import NotFoundError from .exceptions import NotFoundError
class TaskOrders(object): class TaskOrders(object):
TASK_ORDER_DATA = [col.name for col in TaskOrder.__table__.c if col.name != "id"]
@classmethod @classmethod
def get(cls, order_number): def get(cls, order_number):
@ -26,13 +27,15 @@ class TaskOrders(object):
def _get_from_eda(cls, order_number): def _get_from_eda(cls, order_number):
to_data = TaskOrders._client().get_contract(order_number, status="y") to_data = TaskOrders._client().get_contract(order_number, status="y")
if to_data: if to_data:
return TaskOrders.create(to_data["contract_no"]) # TODO: we need to determine exactly what we're getting and storing from the EDA client
return TaskOrders.create(number=to_data["contract_no"], source=Source.EDA)
else: else:
raise NotFoundError("task_order") raise NotFoundError("task_order")
@classmethod @classmethod
def create(cls, order_number): def create(cls, **kwargs):
task_order = TaskOrder(number=order_number) task_order = TaskOrder(**kwargs)
db.session.add(task_order) db.session.add(task_order)
db.session.commit() db.session.commit()
@ -42,3 +45,14 @@ class TaskOrders(object):
@classmethod @classmethod
def _client(cls): def _client(cls):
return app.eda_client return app.eda_client
@classmethod
def get_or_create_task_order(cls, number, task_order_data=None):
try:
return TaskOrders.get(number)
except NotFoundError:
if task_order_data:
return TaskOrders.create(
**task_order_data, number=number, source=Source.MANUAL
)

View File

@ -73,6 +73,9 @@ class MockEDAClient(EDAClientBase):
MOCK_CONTRACT_NUMBER = "DCA10096D0052" MOCK_CONTRACT_NUMBER = "DCA10096D0052"
# TODO: It seems likely that this will have to supply CLIN data form the
# EDA returnclinXML API call, in addition to the basic task order data
# below. See the EDA docs.
def get_contract(self, contract_number, status): def get_contract(self, contract_number, status):
if contract_number == self.MOCK_CONTRACT_NUMBER and status == "y": if contract_number == self.MOCK_CONTRACT_NUMBER and status == "y":
return { return {

View File

@ -1,7 +1,7 @@
import re import re
from wtforms.fields.html5 import EmailField from wtforms.fields.html5 import EmailField
from wtforms.fields import StringField from wtforms.fields import StringField
from wtforms.validators import Required, Email, Regexp, ValidationError from wtforms.validators import Required, Email, Regexp
from atst.domain.exceptions import NotFoundError from atst.domain.exceptions import NotFoundError
from atst.domain.pe_numbers import PENumbers from atst.domain.pe_numbers import PENumbers
@ -58,6 +58,21 @@ def validate_pe_id(field, existing_request):
return True return True
def validate_task_order_number(field):
try:
TaskOrders.get(field.data)
except NotFoundError:
field.errors += ("Task Order number not found",)
return False
return True
def number_to_int(num):
if num:
return int(num)
class BaseFinancialForm(ValidatedForm): class BaseFinancialForm(ValidatedForm):
def reset(self): def reset(self):
""" """
@ -119,11 +134,10 @@ class BaseFinancialForm(ValidatedForm):
class FinancialForm(BaseFinancialForm): class FinancialForm(BaseFinancialForm):
def validate_task_order_number(form, field): def perform_extra_validation(self, existing_request):
try: previous_valid = super().perform_extra_validation(existing_request)
TaskOrders.get(field.data) task_order_valid = validate_task_order_number(self.task_order_number)
except NotFoundError: return previous_valid and task_order_valid
raise ValidationError("Task Order number not found")
@property @property
def is_missing_task_order_number(self): def is_missing_task_order_number(self):
@ -154,35 +168,41 @@ class ExtendedFinancialForm(BaseFinancialForm):
clin_0001 = StringField( clin_0001 = StringField(
"<dl><dt>CLIN 0001</dt> - <dd>Unclassified IaaS and PaaS Amount</dd></dl>", "<dl><dt>CLIN 0001</dt> - <dd>Unclassified IaaS and PaaS Amount</dd></dl>",
validators=[Required()], validators=[Required()],
description="Review your task order document, the amounts for each CLIN must match exactly here" description="Review your task order document, the amounts for each CLIN must match exactly here",
filters=[number_to_int]
) )
clin_0003 = StringField( clin_0003 = StringField(
"<dl><dt>CLIN 0003</dt> - <dd>Unclassified Cloud Support Package</dd></dl>", "<dl><dt>CLIN 0003</dt> - <dd>Unclassified Cloud Support Package</dd></dl>",
validators=[Required()], validators=[Required()],
description="Review your task order document, the amounts for each CLIN must match exactly here" description="Review your task order document, the amounts for each CLIN must match exactly here",
filters=[number_to_int]
) )
clin_1001 = StringField( clin_1001 = StringField(
"<dl><dt>CLIN 1001</dt> - <dd>Unclassified IaaS and PaaS Amount <br> OPTION PERIOD 1</dd></dl>", "<dl><dt>CLIN 1001</dt> - <dd>Unclassified IaaS and PaaS Amount <br> OPTION PERIOD 1</dd></dl>",
validators=[Required()], validators=[Required()],
description="Review your task order document, the amounts for each CLIN must match exactly here" description="Review your task order document, the amounts for each CLIN must match exactly here",
filters=[number_to_int]
) )
clin_1003 = StringField( clin_1003 = StringField(
"<dl><dt>CLIN 1003</dt> - <dd>Unclassified Cloud Support Package <br> OPTION PERIOD 1</dd></dl>", "<dl><dt>CLIN 1003</dt> - <dd>Unclassified Cloud Support Package <br> OPTION PERIOD 1</dd></dl>",
validators=[Required()], validators=[Required()],
description="Review your task order document, the amounts for each CLIN must match exactly here" description="Review your task order document, the amounts for each CLIN must match exactly here",
filters=[number_to_int]
) )
clin_2001 = StringField( clin_2001 = StringField(
"<dl><dt>CLIN 2001</dt> - <dd>Unclassified IaaS and PaaS Amount <br> OPTION PERIOD 2</dd></dl>", "<dl><dt>CLIN 2001</dt> - <dd>Unclassified IaaS and PaaS Amount <br> OPTION PERIOD 2</dd></dl>",
validators=[Required()], validators=[Required()],
description="Review your task order document, the amounts for each CLIN must match exactly here" description="Review your task order document, the amounts for each CLIN must match exactly here",
filters=[number_to_int]
) )
clin_2003 = StringField( clin_2003 = StringField(
"<dl><dt>CLIN 2003</dt> - <dd>Unclassified Cloud Support Package <br> OPTION PERIOD 2</dd></dl>", "<dl><dt>CLIN 2003</dt> - <dd>Unclassified Cloud Support Package <br> OPTION PERIOD 2</dd></dl>",
validators=[Required()], validators=[Required()],
description="Review your task order document, the amounts for each CLIN must match exactly here" description="Review your task order document, the amounts for each CLIN must match exactly here",
filters=[number_to_int]
) )

View File

@ -20,6 +20,9 @@ class Request(Base):
user_id = Column(ForeignKey("users.id"), nullable=False) user_id = Column(ForeignKey("users.id"), nullable=False)
creator = relationship("User") creator = relationship("User")
task_order_id = Column(ForeignKey("task_order.id"))
task_order = relationship("TaskOrder")
@property @property
def status(self): def status(self):
return self.status_events[-1].new_status return self.status_events[-1].new_status

View File

@ -1,10 +1,32 @@
from sqlalchemy import Column, Integer, String from enum import Enum
from sqlalchemy import Column, Integer, String, Enum as SQLAEnum
from atst.models import Base from atst.models import Base
class Source(Enum):
MANUAL = "Manual"
EDA = "eda"
class FundingType(Enum):
RDTE = "RDTE"
OM = "OM"
PROC = "PROC"
OTHER = "OTHER"
class TaskOrder(Base): class TaskOrder(Base):
__tablename__ = "task_order" __tablename__ = "task_order"
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
number = Column(String) number = Column(String, unique=True)
source = Column(SQLAEnum(Source))
funding_type = Column(SQLAEnum(FundingType))
funding_type_other = Column(String)
clin_0001 = Column(Integer)
clin_0003 = Column(Integer)
clin_1001 = Column(Integer)
clin_1003 = Column(Integer)
clin_2001 = Column(Integer)
clin_2003 = Column(Integer)

View File

@ -36,14 +36,15 @@ def update_financial_verification(request_id):
) )
if form.validate(): if form.validate():
request_data = {"financial_verification": form.data}
valid = form.perform_extra_validation( valid = form.perform_extra_validation(
existing_request.body.get("financial_verification") existing_request.body.get("financial_verification")
) )
updated_request = Requests.update(request_id, request_data) updated_request = Requests.update_financial_verification(request_id, form.data)
if valid: if valid:
Requests.submit_financial_verification(request_id)
new_workspace = Requests.approve_and_create_workspace(updated_request) new_workspace = Requests.approve_and_create_workspace(updated_request)
return redirect(url_for("workspaces.workspace_projects", workspace_id=new_workspace.id, newWorkspace=True)) return redirect(url_for("workspaces.workspace_projects", workspace_id=new_workspace.id, newWorkspace=True))
else: else:
form.reset() form.reset()
return render_template( return render_template(

View File

@ -5,8 +5,9 @@ from atst.domain.exceptions import NotFoundError
from atst.domain.requests import Requests from atst.domain.requests import Requests
from atst.models.request import Request from atst.models.request import Request
from atst.models.request_status_event import RequestStatus from atst.models.request_status_event import RequestStatus
from atst.models.task_order import Source as TaskOrderSource
from tests.factories import RequestFactory, UserFactory, RequestStatusEventFactory from tests.factories import RequestFactory, UserFactory, RequestStatusEventFactory, TaskOrderFactory
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
@ -91,3 +92,54 @@ def test_status_count_scoped_to_creator(session):
assert Requests.status_count(RequestStatus.STARTED) == 2 assert Requests.status_count(RequestStatus.STARTED) == 2
assert Requests.status_count(RequestStatus.STARTED, creator=user) == 1 assert Requests.status_count(RequestStatus.STARTED, creator=user) == 1
request_financial_data = {
"pe_id": "123",
"task_order_number": "021345",
"fname_co": "Contracting",
"lname_co": "Officer",
"email_co": "jane@mail.mil",
"office_co": "WHS",
"fname_cor": "Officer",
"lname_cor": "Representative",
"email_cor": "jane@mail.mil",
"office_cor": "WHS",
"uii_ids": "1234",
"treasury_code": "00123456",
"ba_code": "024A",
}
task_order_financial_data = {
"funding_type": "RDTE",
"funding_type_other": "other",
"clin_0001": 50000,
"clin_0003": 13000,
"clin_1001": 30000,
"clin_1003": 7000,
"clin_2001": 30000,
"clin_2003": 7000,
}
def test_update_financial_verification_without_task_order():
request = RequestFactory.create()
financial_data = { **request_financial_data, **task_order_financial_data }
Requests.update_financial_verification(request.id, financial_data)
assert request.task_order
assert request.task_order.clin_0001 == task_order_financial_data["clin_0001"]
assert request.task_order.source == TaskOrderSource.MANUAL
def test_update_financial_verification_with_task_order():
task_order = TaskOrderFactory.create(source=TaskOrderSource.EDA)
financial_data = { **request_financial_data, "task_order_number": task_order.number }
request = RequestFactory.create()
Requests.update_financial_verification(request.id, financial_data)
assert request.task_order == task_order
def test_update_financial_verification_with_invalid_task_order():
request = RequestFactory.create()
Requests.update_financial_verification(request.id, request_financial_data)
assert not request.task_order

View File

@ -1,5 +1,6 @@
import pytest import pytest
from atst.models.task_order import Source as TaskOrderSource
from atst.domain.exceptions import NotFoundError from atst.domain.exceptions import NotFoundError
from atst.domain.task_orders import TaskOrders from atst.domain.task_orders import TaskOrders
from atst.eda_client import MockEDAClient from atst.eda_client import MockEDAClient
@ -19,6 +20,7 @@ def test_can_get_task_order_from_eda(monkeypatch):
to = TaskOrders.get(MockEDAClient.MOCK_CONTRACT_NUMBER) to = TaskOrders.get(MockEDAClient.MOCK_CONTRACT_NUMBER)
assert to.number == MockEDAClient.MOCK_CONTRACT_NUMBER assert to.number == MockEDAClient.MOCK_CONTRACT_NUMBER
assert to.source == TaskOrderSource.EDA
def test_nonexistent_task_order_raises_without_client(): def test_nonexistent_task_order_raises_without_client():

View File

@ -71,13 +71,14 @@ def test_ba_code_validation(input_, expected):
def test_task_order_number_validation(monkeypatch): def test_task_order_number_validation(monkeypatch):
monkeypatch.setattr("atst.domain.task_orders.TaskOrders._client", lambda: MockEDAClient()) monkeypatch.setattr("atst.domain.task_orders.TaskOrders._client", lambda: MockEDAClient())
monkeypatch.setattr("atst.forms.financial.validate_pe_id", lambda *args: True)
form_invalid = FinancialForm(data={"task_order_number": "1234"}) form_invalid = FinancialForm(data={"task_order_number": "1234"})
form_invalid.validate() form_invalid.perform_extra_validation({})
assert "task_order_number" in form_invalid.errors assert "task_order_number" in form_invalid.errors
form_valid = FinancialForm(data={"task_order_number": MockEDAClient.MOCK_CONTRACT_NUMBER}, eda_client=MockEDAClient()) form_valid = FinancialForm(data={"task_order_number": MockEDAClient.MOCK_CONTRACT_NUMBER}, eda_client=MockEDAClient())
form_valid.validate() form_valid.perform_extra_validation({})
assert "task_order_number" not in form_valid.errors assert "task_order_number" not in form_valid.errors

View File

@ -27,12 +27,12 @@ class TestPENumberInForm:
extended_data = { extended_data = {
"funding_type": "RDTE", "funding_type": "RDTE",
"funding_type_other": "other", "funding_type_other": "other",
"clin_0001": "50,000", "clin_0001": "50000",
"clin_0003": "13,000", "clin_0003": "13000",
"clin_1001": "30,000", "clin_1001": "30000",
"clin_1003": "7,000", "clin_1003": "7000",
"clin_2001": "30,000", "clin_2001": "30000",
"clin_2003": "7,000", "clin_2003": "7000",
} }
def _set_monkeypatches(self, monkeypatch): def _set_monkeypatches(self, monkeypatch):