diff --git a/atst/domain/task_orders.py b/atst/domain/task_orders.py index 44d6b47e..7a538c2d 100644 --- a/atst/domain/task_orders.py +++ b/atst/domain/task_orders.py @@ -90,3 +90,13 @@ class TaskOrders(BaseDomainClass): ) .all() ) + + @classmethod + def get_clins_for_create_billing_instructions(cls): + return ( + db.session.query(CLIN) + .filter( + CLIN.last_sent_at.is_(None), CLIN.start_date < pendulum.now(tz="UTC") + ) + .all() + ) diff --git a/atst/jobs.py b/atst/jobs.py index c125bcb5..855af5b2 100644 --- a/atst/jobs.py +++ b/atst/jobs.py @@ -10,6 +10,7 @@ from atst.domain.csp.cloud import CloudProviderInterface from atst.domain.csp.cloud.exceptions import GeneralCSPException from atst.domain.csp.cloud.models import ( ApplicationCSPPayload, + BillingInstructionCSPPayload, EnvironmentCSPPayload, UserCSPPayload, UserRoleCSPPayload, @@ -317,3 +318,32 @@ def send_task_order_files(self): db.session.add(task_order) db.session.commit() + + +@celery.task(bind=True) +def create_billing_instruction(self): + clins = TaskOrders.get_clins_for_create_billing_instructions() + for clin in clins: + portfolio = clin.task_order.portfolio + + payload = BillingInstructionCSPPayload( + tenant_id=portfolio.csp_data.get("tenant_id"), + billing_account_name=portfolio.csp_data.get("billing_account_name"), + billing_profile_name=portfolio.csp_data.get("billing_profile_name"), + initial_clin_amount=clin.obligated_amount, + initial_clin_start_date=str(clin.start_date), + initial_clin_end_date=str(clin.end_date), + initial_clin_type=clin.number, + initial_task_order_id=str(clin.task_order_id), + ) + + try: + app.csp.cloud.create_billing_instruction(payload) + except (AzureError) as err: + app.logger.exception(err) + continue + + clin.last_sent_at = pendulum.now(tz="UTC") + db.session.add(clin) + + db.session.commit() diff --git a/atst/models/clin.py b/atst/models/clin.py index accab107..a96e907f 100644 --- a/atst/models/clin.py +++ b/atst/models/clin.py @@ -66,12 +66,14 @@ class CLIN(Base, mixins.TimestampsMixin): ) def to_dictionary(self): - return { + data = { c.name: getattr(self, c.name) for c in self.__table__.columns if c.name not in ["id"] } + return data + @property def is_active(self): return ( diff --git a/atst/queue.py b/atst/queue.py index 5ed2f136..dcd123d1 100644 --- a/atst/queue.py +++ b/atst/queue.py @@ -31,6 +31,10 @@ def update_celery(celery, app): "task": "atst.jobs.send_task_order_files", "schedule": 60, }, + "beat-create_billing_instruction": { + "task": "atst.jobs.create_billing_instruction", + "schedule": 60, + }, } class ContextTask(celery.Task): diff --git a/tests/domain/test_task_orders.py b/tests/domain/test_task_orders.py index 5999677a..4b56b030 100644 --- a/tests/domain/test_task_orders.py +++ b/tests/domain/test_task_orders.py @@ -9,6 +9,27 @@ from atst.models.task_order import TaskOrder, SORT_ORDERING, Status from tests.factories import TaskOrderFactory, CLINFactory, PortfolioFactory +@pytest.fixture +def new_task_order(): + return TaskOrderFactory.create(create_clins=[{}]) + + +@pytest.fixture +def updated_task_order(): + return TaskOrderFactory.create( + create_clins=[{"last_sent_at": pendulum.date(2020, 2, 1)}], + pdf_last_sent_at=pendulum.date(2020, 1, 1), + ) + + +@pytest.fixture +def sent_task_order(): + return TaskOrderFactory.create( + create_clins=[{"last_sent_at": pendulum.date(2020, 1, 1)}], + pdf_last_sent_at=pendulum.date(2020, 1, 1), + ) + + def test_create_adds_clins(): portfolio = PortfolioFactory.create() clins = [ @@ -181,19 +202,18 @@ def test_allows_alphanumeric_number(): assert TaskOrders.create(portfolio.id, number, [], None) -def test_get_for_send_task_order_files(): - new_to = TaskOrderFactory.create(create_clins=[{}]) - updated_to = TaskOrderFactory.create( - create_clins=[{"last_sent_at": pendulum.datetime(2020, 2, 1)}], - pdf_last_sent_at=pendulum.datetime(2020, 1, 1), - ) - sent_to = TaskOrderFactory.create( - create_clins=[{"last_sent_at": pendulum.datetime(2020, 1, 1)}], - pdf_last_sent_at=pendulum.datetime(2020, 1, 1), - ) - +def test_get_for_send_task_order_files( + new_task_order, updated_task_order, sent_task_order +): updated_and_new_task_orders = TaskOrders.get_for_send_task_order_files() assert len(updated_and_new_task_orders) == 2 - assert sent_to not in updated_and_new_task_orders - assert updated_to in updated_and_new_task_orders - assert new_to in updated_and_new_task_orders + assert sent_task_order not in updated_and_new_task_orders + assert updated_task_order in updated_and_new_task_orders + assert new_task_order in updated_and_new_task_orders + + +def test_get_clins_for_create_billing_instructions(new_task_order, sent_task_order): + new_clins = TaskOrders.get_clins_for_create_billing_instructions() + assert len(new_clins) == 1 + assert new_task_order.clins[0] in new_clins + assert sent_task_order.clins[0] not in new_clins diff --git a/tests/test_jobs.py b/tests/test_jobs.py index ff681c7e..eba01f9b 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -6,7 +6,8 @@ from smtplib import SMTPException from azure.core.exceptions import AzureError from atst.domain.csp.cloud import MockCloudProvider -from atst.domain.csp.cloud.models import UserRoleCSPResult +from atst.domain.csp.cloud.models import BillingInstructionCSPPayload, UserRoleCSPResult +from atst.domain.portfolios import Portfolios from atst.models import ApplicationRoleStatus, Portfolio, FSMStates from atst.jobs import ( @@ -16,6 +17,7 @@ from atst.jobs import ( dispatch_create_user, dispatch_create_environment_role, dispatch_provision_portfolio, + create_billing_instruction, create_environment, do_create_user, do_provision_portfolio, @@ -28,6 +30,7 @@ from atst.jobs import ( from tests.factories import ( ApplicationFactory, ApplicationRoleFactory, + CLINFactory, EnvironmentFactory, EnvironmentRoleFactory, PortfolioFactory, @@ -489,3 +492,78 @@ class TestSendTaskOrderFiles: # Check that pdf_last_sent_at has not been updated assert not task_order.pdf_last_sent_at + + +class TestCreateBillingInstructions: + @pytest.fixture + def unsent_clin(self): + start_date = pendulum.now().subtract(days=1) + portfolio = PortfolioFactory.create( + csp_data={ + "tenant_id": str(uuid4()), + "billing_account_name": "fake", + "billing_profile_name": "fake", + }, + task_orders=[{"create_clins": [{"start_date": start_date}]}], + ) + return portfolio.task_orders[0].clins[0] + + def test_update_clin_last_sent_at(self, session, unsent_clin): + assert not unsent_clin.last_sent_at + + # The session needs to be nested to prevent detached SQLAlchemy instance + session.begin_nested() + create_billing_instruction() + + # check that last_sent_at has been updated + assert unsent_clin.last_sent_at + session.rollback() + + def test_failure(self, monkeypatch, session, unsent_clin): + def _create_billing_instruction(MockCloudProvider, object_name): + raise AzureError("something went wrong") + + monkeypatch.setattr( + "atst.domain.csp.cloud.MockCloudProvider.create_billing_instruction", + _create_billing_instruction, + ) + + # The session needs to be nested to prevent detached SQLAlchemy instance + session.begin_nested() + create_billing_instruction() + + # check that last_sent_at has not been updated + assert not unsent_clin.last_sent_at + session.rollback() + + def test_task_order_with_multiple_clins(self, session): + start_date = pendulum.now(tz="UTC").subtract(days=1) + portfolio = PortfolioFactory.create( + csp_data={ + "tenant_id": str(uuid4()), + "billing_account_name": "fake", + "billing_profile_name": "fake", + }, + task_orders=[ + { + "create_clins": [ + {"start_date": start_date, "last_sent_at": start_date} + ] + } + ], + ) + task_order = portfolio.task_orders[0] + sent_clin = task_order.clins[0] + + # Add new CLIN to the Task Order + new_clin = CLINFactory.create(task_order=task_order) + assert not new_clin.last_sent_at + + session.begin_nested() + create_billing_instruction() + session.add(sent_clin) + + # check that last_sent_at has been update for the new clin only + assert new_clin.last_sent_at + assert sent_clin.last_sent_at != new_clin.last_sent_at + session.rollback()