diff --git a/alembic/versions/0845b2f0f401_add_request_task_order_relationship.py b/alembic/versions/0845b2f0f401_add_request_task_order_relationship.py new file mode 100644 index 00000000..4b00c41f --- /dev/null +++ b/alembic/versions/0845b2f0f401_add_request_task_order_relationship.py @@ -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 ### diff --git a/alembic/versions/875e4b8a05fc_add_additional_task_order_fields.py b/alembic/versions/875e4b8a05fc_add_additional_task_order_fields.py new file mode 100644 index 00000000..c9ee8ee0 --- /dev/null +++ b/alembic/versions/875e4b8a05fc_add_additional_task_order_fields.py @@ -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 ### diff --git a/atst/domain/requests.py b/atst/domain/requests.py index 4f956b6c..e569bc4b 100644 --- a/atst/domain/requests.py +++ b/atst/domain/requests.py @@ -8,6 +8,7 @@ from atst.models.request import Request from atst.models.request_status_event import RequestStatusEvent, RequestStatus from atst.domain.workspaces import Workspaces from atst.database import db +from atst.domain.task_orders import TaskOrders from .exceptions import NotFoundError @@ -52,6 +53,7 @@ class Requests(object): and_(Request.id == request_id, Request.creator == creator) ) ).scalar() + except exc.DataError: return False @@ -71,10 +73,9 @@ class Requests(object): filters.append(Request.creator == creator) requests = ( - db.session.query(Request) - .filter(*filters) - .order_by(Request.time_created.desc()) - .all() + db.session.query(Request).filter(*filters).order_by( + Request.time_created.desc() + ).all() ) return requests @@ -95,27 +96,39 @@ class Requests(object): @classmethod 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: # 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 - request = ( - db.session.query(Request) - .filter_by(id=request_id) - .with_for_update(of=Request) - .one() + return ( + db.session.query(Request).filter_by(id=request_id).with_for_update( + of=Request + ).one() ) + except NoResultFound: return + @classmethod + def _merge_body(cls, request, request_delta): request.body = deep_merge(request_delta, request.body) # Without this, sqlalchemy won't notice the change to request.body, # since it doesn't track dictionary mutations by default. flag_modified(request, "body") - db.session.add(request) - db.session.commit() - return request @classmethod @@ -139,8 +152,10 @@ class Requests(object): return { RequestStatus.STARTED: "mission_owner", RequestStatus.PENDING_FINANCIAL_VERIFICATION: "mission_owner", - RequestStatus.PENDING_CCPO_APPROVAL: "ccpo" - }.get(request.status) + RequestStatus.PENDING_CCPO_APPROVAL: "ccpo", + }.get( + request.status + ) @classmethod def should_auto_approve(cls, request): @@ -152,16 +167,13 @@ class Requests(object): return dollar_value < cls.AUTO_APPROVE_THRESHOLD _VALID_SUBMISSION_STATUSES = [ - RequestStatus.STARTED, - RequestStatus.CHANGES_REQUESTED, + RequestStatus.STARTED, RequestStatus.CHANGES_REQUESTED ] @classmethod def should_allow_submission(cls, request): all_request_sections = [ - "details_of_use", - "information_about_you", - "primary_poc", + "details_of_use", "information_about_you", "primary_poc" ] existing_request_sections = request.body.keys() return request.status in Requests._VALID_SUBMISSION_STATUSES and all( @@ -201,11 +213,13 @@ WHERE requests_with_status.status = :status @classmethod def in_progress_count(cls): - return sum([ - Requests.status_count(RequestStatus.STARTED), - Requests.status_count(RequestStatus.PENDING_FINANCIAL_VERIFICATION), - Requests.status_count(RequestStatus.CHANGES_REQUESTED), - ]) + return sum( + [ + Requests.status_count(RequestStatus.STARTED), + Requests.status_count(RequestStatus.PENDING_FINANCIAL_VERIFICATION), + Requests.status_count(RequestStatus.CHANGES_REQUESTED), + ] + ) @classmethod def pending_ccpo_count(cls): @@ -215,3 +229,43 @@ WHERE requests_with_status.status = :status def completed_count(cls): 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() diff --git a/atst/domain/task_orders.py b/atst/domain/task_orders.py index fbc587a1..d03910d4 100644 --- a/atst/domain/task_orders.py +++ b/atst/domain/task_orders.py @@ -2,11 +2,12 @@ from sqlalchemy.orm.exc import NoResultFound from flask import current_app as app from atst.database import db -from atst.models.task_order import TaskOrder +from atst.models.task_order import TaskOrder, Source from .exceptions import NotFoundError class TaskOrders(object): + TASK_ORDER_DATA = [col.name for col in TaskOrder.__table__.c if col.name != "id"] @classmethod def get(cls, order_number): @@ -26,13 +27,15 @@ class TaskOrders(object): def _get_from_eda(cls, order_number): to_data = TaskOrders._client().get_contract(order_number, status="y") 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: raise NotFoundError("task_order") @classmethod - def create(cls, order_number): - task_order = TaskOrder(number=order_number) + def create(cls, **kwargs): + task_order = TaskOrder(**kwargs) db.session.add(task_order) db.session.commit() @@ -42,3 +45,14 @@ class TaskOrders(object): @classmethod def _client(cls): 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 + ) diff --git a/atst/eda_client.py b/atst/eda_client.py index 82c01217..3251499e 100644 --- a/atst/eda_client.py +++ b/atst/eda_client.py @@ -73,6 +73,9 @@ class MockEDAClient(EDAClientBase): 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): if contract_number == self.MOCK_CONTRACT_NUMBER and status == "y": return { diff --git a/atst/forms/financial.py b/atst/forms/financial.py index e989d822..83ee7e37 100644 --- a/atst/forms/financial.py +++ b/atst/forms/financial.py @@ -1,7 +1,7 @@ import re from wtforms.fields.html5 import EmailField 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.pe_numbers import PENumbers @@ -58,6 +58,21 @@ def validate_pe_id(field, existing_request): 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): def reset(self): """ @@ -119,11 +134,10 @@ class BaseFinancialForm(ValidatedForm): class FinancialForm(BaseFinancialForm): - def validate_task_order_number(form, field): - try: - TaskOrders.get(field.data) - except NotFoundError: - raise ValidationError("Task Order number not found") + def perform_extra_validation(self, existing_request): + previous_valid = super().perform_extra_validation(existing_request) + task_order_valid = validate_task_order_number(self.task_order_number) + return previous_valid and task_order_valid @property def is_missing_task_order_number(self): @@ -154,35 +168,41 @@ class ExtendedFinancialForm(BaseFinancialForm): clin_0001 = StringField( "