From 612e2541049899053656f384b3dcab1668a3b946 Mon Sep 17 00:00:00 2001 From: graham-dds Date: Wed, 19 Feb 2020 16:12:14 -0500 Subject: [PATCH 1/6] Add AZURE_BILLING_ACCOUNT_NAME config var --- README.md | 1 + config/base.ini | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 2982ff33..d89db625 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,7 @@ To generate coverage reports for the Javascript tests: - `ASSETS_URL`: URL to host which serves static assets (such as a CDN). - `AZURE_ACCOUNT_NAME`: The name for the Azure blob storage account +- `AZURE_BILLING_ACCOUNT_NAME`: The name for the root Azure billing account - `AZURE_CALC_CLIENT_ID`: The client id used to generate a token for the Azure pricing calculator - `AZURE_CALC_RESOURCE`: The resource URL used to generate a token for the Azure pricing calculator - `AZURE_CALC_SECRET`: The secret key used to generate a token for the Azure pricing calculator diff --git a/config/base.ini b/config/base.ini index a587c74f..db16b1d0 100644 --- a/config/base.ini +++ b/config/base.ini @@ -2,6 +2,7 @@ ASSETS_URL AZURE_AADP_QTY=5 AZURE_ACCOUNT_NAME +AZURE_BILLING_ACCOUNT_NAME AZURE_CLIENT_ID AZURE_CALC_CLIENT_ID AZURE_CALC_RESOURCE="http://azurecom.onmicrosoft.com/acom-prod/" From d46ed2b5b4ac2b3e36b4a7c42b7fc7694997f293 Mon Sep 17 00:00:00 2001 From: graham-dds Date: Wed, 19 Feb 2020 16:41:51 -0500 Subject: [PATCH 2/6] Specify clin sorting in TO <> CLIN relationship This also removes the sorted_clins property on the task_order model --- atst/models/portfolio.py | 2 +- atst/models/task_order.py | 13 ++++++------- .../task_orders/fragments/task_order_view.html | 2 +- tests/models/test_task_order.py | 2 +- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/atst/models/portfolio.py b/atst/models/portfolio.py index c006dd37..f683ac30 100644 --- a/atst/models/portfolio.py +++ b/atst/models/portfolio.py @@ -202,7 +202,7 @@ class Portfolio( try: initial_task_order: TaskOrder = self.task_orders[0] - initial_clin = initial_task_order.sorted_clins[0] + initial_clin = initial_task_order.clins[0] portfolio_data.update( { "initial_clin_amount": initial_clin.obligated_amount, diff --git a/atst/models/task_order.py b/atst/models/task_order.py index d2a63964..a931df9b 100644 --- a/atst/models/task_order.py +++ b/atst/models/task_order.py @@ -3,12 +3,13 @@ from enum import Enum from sqlalchemy import Column, DateTime, ForeignKey, String from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import relationship - +from atst.models.clin import CLIN from atst.models.base import Base import atst.models.types as types import atst.models.mixins as mixins from atst.models.attachment import Attachment from pendulum import today +from sqlalchemy import func class Status(Enum): @@ -41,15 +42,13 @@ class TaskOrder(Base, mixins.TimestampsMixin): number = Column(String, unique=True,) # Task Order Number signer_dod_id = Column(String) signed_at = Column(DateTime) - clins = relationship( - "CLIN", back_populates="task_order", cascade="all, delete-orphan" + "CLIN", + back_populates="task_order", + cascade="all, delete-orphan", + order_by=lambda: [func.substr(CLIN.number, 2), func.substr(CLIN.number, 1, 2)], ) - @property - def sorted_clins(self): - return sorted(self.clins, key=lambda clin: (clin.number[1:], clin.number[0])) - @hybrid_property def pdf(self): return self._pdf diff --git a/templates/task_orders/fragments/task_order_view.html b/templates/task_orders/fragments/task_order_view.html index c78434ee..28978c96 100644 --- a/templates/task_orders/fragments/task_order_view.html +++ b/templates/task_orders/fragments/task_order_view.html @@ -57,7 +57,7 @@ - {% for clin in task_order.sorted_clins %} + {% for clin in task_order.clins %} {{ clin.number }} {{ clin.type }} diff --git a/tests/models/test_task_order.py b/tests/models/test_task_order.py index 0b03cbf8..7b280c74 100644 --- a/tests/models/test_task_order.py +++ b/tests/models/test_task_order.py @@ -62,7 +62,7 @@ def test_clin_sorting(): CLINFactory.create(number="2001"), ] ) - assert [clin.number for clin in task_order.sorted_clins] == [ + assert [clin.number for clin in task_order.clins] == [ "0001", "1001", "2001", From dba63cdd15903add73f01620a69e743834103a90 Mon Sep 17 00:00:00 2001 From: graham-dds Date: Thu, 20 Feb 2020 15:15:50 -0500 Subject: [PATCH 3/6] Refactor Portfolio to_dictionary method In refactoring this method, refactor some other areas: - add a property to generate a dict needed for the "initial clin" - add new relationship field to get all CLINs associated with a portfolio - add a property to either get or generate a portfolio's domain name - least importantly, sort imports - On the CLIN model, add a property to get a JEDI clin type's number, i.e. JEDI_CLIN_3 -> 3. This should be the "initial_clin_type". --- atst/jobs.py | 2 +- atst/models/clin.py | 4 ++ atst/models/portfolio.py | 105 ++++++++++++++++++++------------------- 3 files changed, 60 insertions(+), 51 deletions(-) diff --git a/atst/jobs.py b/atst/jobs.py index 855af5b2..e5facabe 100644 --- a/atst/jobs.py +++ b/atst/jobs.py @@ -333,7 +333,7 @@ def create_billing_instruction(self): 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_clin_type=clin.jedi_clin_number, initial_task_order_id=str(clin.task_order_id), ) diff --git a/atst/models/clin.py b/atst/models/clin.py index a96e907f..b74f9b0d 100644 --- a/atst/models/clin.py +++ b/atst/models/clin.py @@ -65,6 +65,10 @@ class CLIN(Base, mixins.TimestampsMixin): ] ) + @property + def jedi_clin_number(self): + return self.jedi_clin_type.value[-1] + def to_dictionary(self): data = { c.name: getattr(self, c.name) diff --git a/atst/models/portfolio.py b/atst/models/portfolio.py index f683ac30..bd430ed3 100644 --- a/atst/models/portfolio.py +++ b/atst/models/portfolio.py @@ -1,23 +1,23 @@ import re -from string import ascii_lowercase, digits -from random import choices from itertools import chain +from random import choices +from string import ascii_lowercase, digits +from typing import Dict from sqlalchemy import Column, String from sqlalchemy.orm import relationship from sqlalchemy.types import ARRAY - -from atst.models.base import Base -import atst.models.types as types -import atst.models.mixins as mixins -from atst.models.task_order import TaskOrder -from atst.models.portfolio_role import PortfolioRole, Status as PortfolioRoleStatus -from atst.domain.permission_sets import PermissionSets -from atst.utils import first_or_none -from atst.database import db - from sqlalchemy_json import NestedMutableJson +from atst.database import db +import atst.models.mixins as mixins +import atst.models.types as types +from atst.domain.permission_sets import PermissionSets +from atst.models.base import Base +from atst.models.portfolio_role import PortfolioRole +from atst.models.portfolio_role import Status as PortfolioRoleStatus +from atst.utils import first_or_none + class Portfolio( Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin @@ -54,6 +54,7 @@ class Portfolio( roles = relationship("PortfolioRole") task_orders = relationship("TaskOrder") + clins = relationship("CLIN", secondary="task_orders") @property def owner_role(self): @@ -82,13 +83,27 @@ class Portfolio( return len(self.task_orders) @property - def active_clins(self): - return [ - clin - for task_order in self.task_orders - for clin in task_order.clins - if clin.is_active - ] + def initial_clin_dict(self) -> Dict: + initial_clin = min( + ( + clin + for clin in self.clins + if (clin.is_active and clin.task_order.is_signed) + ), + key=lambda clin: clin.start_date, + default=None, + ) + if initial_clin: + return { + "initial_task_order_id": initial_clin.task_order.number, + "initial_clin_number": initial_clin.number, + "initial_clin_type": initial_clin.jedi_clin_number, + "initial_clin_amount": initial_clin.obligated_amount, + "initial_clin_start_date": initial_clin.start_date.strftime("%Y/%m/%d"), + "initial_clin_end_date": initial_clin.end_date.strftime("%Y/%m/%d"), + } + else: + return {} @property def active_task_orders(self): @@ -170,25 +185,33 @@ class Portfolio( def portfolio_id(self): return self.id + @property + def domain_name(self): + """ + CSP domain name associated with portfolio. + If a domain name is not set, generate one. + """ + domain_name = re.sub("[^0-9a-zA-Z]+", "", self.name).lower() + "".join( + choices(ascii_lowercase + digits, k=4) + ) + if self.csp_data: + return self.csp_data.get("domain_name", domain_name) + else: + return domain_name + @property def application_id(self): return None def to_dictionary(self): - ppoc = self.owner - user_id = f"{ppoc.first_name[0]}{ppoc.last_name}".lower() - - domain_name = re.sub("[^0-9a-zA-Z]+", "", self.name).lower() + "".join( - choices(ascii_lowercase + digits, k=4) - ) - portfolio_data = { - "user_id": user_id, + return { + "user_id": f"{self.owner.first_name[0]}{self.owner.last_name}".lower(), "password": "", - "domain_name": domain_name, - "first_name": ppoc.first_name, - "last_name": ppoc.last_name, + "domain_name": self.domain_name, + "first_name": self.owner.first_name, + "last_name": self.owner.last_name, "country_code": "US", - "password_recovery_email_address": ppoc.email, + "password_recovery_email_address": self.owner.email, "address": { # TODO: TBD if we're sourcing this from data or config "company_name": "", "address_line_1": "", @@ -198,27 +221,9 @@ class Portfolio( "postal_code": "", }, "billing_profile_display_name": "ATAT Billing Profile", + **self.initial_clin_dict, } - try: - initial_task_order: TaskOrder = self.task_orders[0] - initial_clin = initial_task_order.clins[0] - portfolio_data.update( - { - "initial_clin_amount": initial_clin.obligated_amount, - "initial_clin_start_date": initial_clin.start_date.strftime( - "%Y/%m/%d" - ), - "initial_clin_end_date": initial_clin.end_date.strftime("%Y/%m/%d"), - "initial_clin_type": initial_clin.number, - "initial_task_order_id": initial_task_order.number, - } - ) - except IndexError: - pass - - return portfolio_data - def __repr__(self): return "".format( self.name, self.user_count, self.id From bb886dbe0f79c740fe71de3930a53de5249d09cf Mon Sep 17 00:00:00 2001 From: graham-dds Date: Fri, 21 Feb 2020 11:39:04 -0500 Subject: [PATCH 4/6] Add tests for portfolio.initial_clin_dict property In doing this, fixed a bug on the clin.is_active property --- atst/models/clin.py | 2 +- tests/models/test_portfolio.py | 55 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/atst/models/clin.py b/atst/models/clin.py index b74f9b0d..11e07401 100644 --- a/atst/models/clin.py +++ b/atst/models/clin.py @@ -81,5 +81,5 @@ class CLIN(Base, mixins.TimestampsMixin): @property def is_active(self): return ( - self.start_date <= pendulum.today() <= self.end_date + self.start_date <= pendulum.today().date() <= self.end_date ) and self.task_order.signed_at diff --git a/tests/models/test_portfolio.py b/tests/models/test_portfolio.py index ed6e2513..3e16c5a6 100644 --- a/tests/models/test_portfolio.py +++ b/tests/models/test_portfolio.py @@ -179,3 +179,58 @@ class TestUpcomingObligatedFunds: ) # Only sums the upcoming task order assert portfolio.upcoming_obligated_funds == Decimal(700.0) + + +class TestInitialClinDict: + def test_formats_dict_correctly(self): + portfolio = PortfolioFactory() + task_order = TaskOrderFactory( + portfolio=portfolio, number="1234567890123", signed_at=pendulum.now() + ) + clin = CLINFactory(task_order=task_order) + initial_clin = portfolio.initial_clin_dict + + assert initial_clin["initial_clin_amount"] == clin.obligated_amount + assert initial_clin["initial_clin_start_date"] == clin.start_date.strftime( + "%Y/%m/%d" + ) + assert initial_clin["initial_clin_end_date"] == clin.end_date.strftime( + "%Y/%m/%d" + ) + assert initial_clin["initial_clin_type"] == clin.jedi_clin_number + assert initial_clin["initial_clin_number"] == clin.number + assert initial_clin["initial_task_order_id"] == task_order.number + + def test_no_valid_clins(self): + portfolio = PortfolioFactory() + assert portfolio.initial_clin_dict == {} + + def test_picks_the_initial_clin(self): + yesterday = pendulum.now().subtract(days=1).date() + tomorrow = pendulum.now().add(days=1).date() + portfolio = PortfolioFactory( + task_orders=[ + { + "signed_at": pendulum.now(), + "create_clins": [ + { + "number": "0001", + "start_date": yesterday.subtract(days=1), + "end_date": yesterday, + }, + { + "number": "1001", + "start_date": yesterday, + "end_date": tomorrow, + }, + { + "number": "0002", + "start_date": yesterday, + "end_date": tomorrow, + }, + ], + }, + {"create_clins": [{"number": "0003"}]}, + ], + ) + assert portfolio.initial_clin_dict["initial_clin_number"] == "1001" From 7b76210b6abea8fd12a36349ffe39ef927eb2749 Mon Sep 17 00:00:00 2001 From: graham-dds Date: Fri, 21 Feb 2020 11:40:38 -0500 Subject: [PATCH 5/6] Add Azure billing acct name for initial CSP data --- atst/jobs.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/atst/jobs.py b/atst/jobs.py index e5facabe..80585d3a 100644 --- a/atst/jobs.py +++ b/atst/jobs.py @@ -209,10 +209,17 @@ def send_PPOC_email(portfolio_dict): ) +def make_initial_csp_data(portfolio): + return { + **portfolio.to_dictionary(), + "billing_account_name": app.config.get("AZURE_BILLING_ACCOUNT_NAME"), + } + + def do_provision_portfolio(csp: CloudProviderInterface, portfolio_id=None): portfolio = Portfolios.get_for_update(portfolio_id) fsm = Portfolios.get_or_create_state_machine(portfolio) - fsm.trigger_next_transition(csp_data=portfolio.to_dictionary()) + fsm.trigger_next_transition(csp_data=make_initial_csp_data(portfolio)) if fsm.current_state == FSMStates.COMPLETED: send_PPOC_email(portfolio.to_dictionary()) From dd19a12381b0d287c142db6bc6f119a0a18a30c6 Mon Sep 17 00:00:00 2001 From: graham-dds Date: Fri, 21 Feb 2020 14:12:24 -0500 Subject: [PATCH 6/6] Replace csp data factory with to_dictionary method --- tests/domain/test_portfolio_state_machine.py | 9 ++-- tests/factories.py | 43 -------------------- 2 files changed, 6 insertions(+), 46 deletions(-) diff --git a/tests/domain/test_portfolio_state_machine.py b/tests/domain/test_portfolio_state_machine.py index 23664079..a3dc2dfb 100644 --- a/tests/domain/test_portfolio_state_machine.py +++ b/tests/domain/test_portfolio_state_machine.py @@ -15,7 +15,6 @@ from tests.factories import ( PortfolioStateMachineFactory, TaskOrderFactory, UserFactory, - get_portfolio_csp_data, ) from atst.models import FSMStates, PortfolioStateMachine, TaskOrder @@ -82,7 +81,7 @@ def test_state_machine_trigger_next_transition(state_machine): state_machine.state = FSMStates.STARTED state_machine.trigger_next_transition( - csp_data=get_portfolio_csp_data(state_machine.portfolio) + csp_data=state_machine.portfolio.to_dictionary() ) assert state_machine.current_state == FSMStates.TENANT_CREATED @@ -319,7 +318,11 @@ def test_fsm_transition_start( config = {"billing_account_name": "billing_account_name"} assert state_machine.state == FSMStates.UNSTARTED - portfolio_data = get_portfolio_csp_data(portfolio) + portfolio_data = { + **portfolio.to_dictionary(), + "display_name": "mgmt group display name", + "management_group_name": "mgmt-group-uuid", + } for expected_state in expected_states: collected_data = dict( diff --git a/tests/factories.py b/tests/factories.py index fa857a3c..f02459b3 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -79,49 +79,6 @@ def get_all_portfolio_permission_sets(): return PermissionSets.get_many(PortfolioRoles.PORTFOLIO_PERMISSION_SETS) -def get_portfolio_csp_data(portfolio): - - ppoc = portfolio.owner - if not ppoc: - - class ppoc: - first_name = "John" - last_name = "Doe" - email = "email@example.com" - - user_id = f"{ppoc.first_name[0]}{ppoc.last_name}".lower() - domain_name = re.sub("[^0-9a-zA-Z]+", "", portfolio.name).lower() - - initial_task_order: TaskOrder = portfolio.task_orders[0] - initial_clin = initial_task_order.sorted_clins[0] - - return { - "user_id": user_id, - "password": "jklfsdNCVD83nklds2#202", # pragma: allowlist secret - "domain_name": domain_name, - "display_name": "mgmt group display name", - "management_group_name": "mgmt-group-uuid", - "first_name": ppoc.first_name, - "last_name": ppoc.last_name, - "country_code": "US", - "password_recovery_email_address": ppoc.email, - "address": { # TODO: TBD if we're sourcing this from data or config - "company_name": "", - "address_line_1": "", - "city": "", - "region": "", - "country": "", - "postal_code": "", - }, - "billing_profile_display_name": "My Billing Profile", - "initial_clin_amount": initial_clin.obligated_amount, - "initial_clin_start_date": initial_clin.start_date.strftime("%Y/%m/%d"), - "initial_clin_end_date": initial_clin.end_date.strftime("%Y/%m/%d"), - "initial_clin_type": initial_clin.number, - "initial_task_order_id": initial_task_order.number, - } - - class Base(factory.alchemy.SQLAlchemyModelFactory): @classmethod def dictionary(cls, **attrs):