Merge pull request #1439 from dod-ccpo/171364906-billing-account-name-config

Billing account name config and refactor portfolio to_dictionary method
This commit is contained in:
dandds 2020-02-26 11:20:19 -05:00 committed by GitHub
commit f8dd072340
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 140 additions and 108 deletions

View File

@ -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). - `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_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_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_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 - `AZURE_CALC_SECRET`: The secret key used to generate a token for the Azure pricing calculator

View File

@ -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): def do_provision_portfolio(csp: CloudProviderInterface, portfolio_id=None):
portfolio = Portfolios.get_for_update(portfolio_id) portfolio = Portfolios.get_for_update(portfolio_id)
fsm = Portfolios.get_or_create_state_machine(portfolio) 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: if fsm.current_state == FSMStates.COMPLETED:
send_PPOC_email(portfolio.to_dictionary()) send_PPOC_email(portfolio.to_dictionary())
@ -333,7 +340,7 @@ def create_billing_instruction(self):
initial_clin_amount=clin.obligated_amount, initial_clin_amount=clin.obligated_amount,
initial_clin_start_date=str(clin.start_date), initial_clin_start_date=str(clin.start_date),
initial_clin_end_date=str(clin.end_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), initial_task_order_id=str(clin.task_order_id),
) )

View File

@ -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): def to_dictionary(self):
data = { data = {
c.name: getattr(self, c.name) c.name: getattr(self, c.name)
@ -77,5 +81,5 @@ class CLIN(Base, mixins.TimestampsMixin):
@property @property
def is_active(self): def is_active(self):
return ( return (
self.start_date <= pendulum.today() <= self.end_date self.start_date <= pendulum.today().date() <= self.end_date
) and self.task_order.signed_at ) and self.task_order.signed_at

View File

@ -1,23 +1,23 @@
import re import re
from string import ascii_lowercase, digits
from random import choices
from itertools import chain 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 import Column, String
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.types import ARRAY 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 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( class Portfolio(
Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin
@ -54,6 +54,7 @@ class Portfolio(
roles = relationship("PortfolioRole") roles = relationship("PortfolioRole")
task_orders = relationship("TaskOrder") task_orders = relationship("TaskOrder")
clins = relationship("CLIN", secondary="task_orders")
@property @property
def owner_role(self): def owner_role(self):
@ -82,13 +83,27 @@ class Portfolio(
return len(self.task_orders) return len(self.task_orders)
@property @property
def active_clins(self): def initial_clin_dict(self) -> Dict:
return [ initial_clin = min(
clin (
for task_order in self.task_orders clin
for clin in task_order.clins for clin in self.clins
if clin.is_active 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 @property
def active_task_orders(self): def active_task_orders(self):
@ -170,25 +185,33 @@ class Portfolio(
def portfolio_id(self): def portfolio_id(self):
return self.id 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 @property
def application_id(self): def application_id(self):
return None return None
def to_dictionary(self): def to_dictionary(self):
ppoc = self.owner return {
user_id = f"{ppoc.first_name[0]}{ppoc.last_name}".lower() "user_id": f"{self.owner.first_name[0]}{self.owner.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,
"password": "", "password": "",
"domain_name": domain_name, "domain_name": self.domain_name,
"first_name": ppoc.first_name, "first_name": self.owner.first_name,
"last_name": ppoc.last_name, "last_name": self.owner.last_name,
"country_code": "US", "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 "address": { # TODO: TBD if we're sourcing this from data or config
"company_name": "", "company_name": "",
"address_line_1": "", "address_line_1": "",
@ -198,27 +221,9 @@ class Portfolio(
"postal_code": "", "postal_code": "",
}, },
"billing_profile_display_name": "ATAT Billing Profile", "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.sorted_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): def __repr__(self):
return "<Portfolio(name='{}', user_count='{}', id='{}')>".format( return "<Portfolio(name='{}', user_count='{}', id='{}')>".format(
self.name, self.user_count, self.id self.name, self.user_count, self.id

View File

@ -3,12 +3,13 @@ from enum import Enum
from sqlalchemy import Column, DateTime, ForeignKey, String from sqlalchemy import Column, DateTime, ForeignKey, String
from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from atst.models.clin import CLIN
from atst.models.base import Base from atst.models.base import Base
import atst.models.types as types import atst.models.types as types
import atst.models.mixins as mixins import atst.models.mixins as mixins
from atst.models.attachment import Attachment from atst.models.attachment import Attachment
from pendulum import today from pendulum import today
from sqlalchemy import func
class Status(Enum): class Status(Enum):
@ -41,15 +42,13 @@ class TaskOrder(Base, mixins.TimestampsMixin):
number = Column(String, unique=True,) # Task Order Number number = Column(String, unique=True,) # Task Order Number
signer_dod_id = Column(String) signer_dod_id = Column(String)
signed_at = Column(DateTime) signed_at = Column(DateTime)
clins = relationship( 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 @hybrid_property
def pdf(self): def pdf(self):
return self._pdf return self._pdf

View File

@ -2,6 +2,7 @@
ASSETS_URL ASSETS_URL
AZURE_AADP_QTY=5 AZURE_AADP_QTY=5
AZURE_ACCOUNT_NAME AZURE_ACCOUNT_NAME
AZURE_BILLING_ACCOUNT_NAME
AZURE_CLIENT_ID AZURE_CLIENT_ID
AZURE_CALC_CLIENT_ID AZURE_CALC_CLIENT_ID
AZURE_CALC_RESOURCE="http://azurecom.onmicrosoft.com/acom-prod/" AZURE_CALC_RESOURCE="http://azurecom.onmicrosoft.com/acom-prod/"

View File

@ -57,7 +57,7 @@
</thead> </thead>
<tbody> <tbody>
{% for clin in task_order.sorted_clins %} {% for clin in task_order.clins %}
<tr> <tr>
<td>{{ clin.number }}</td> <td>{{ clin.number }}</td>
<td>{{ clin.type }}</td> <td>{{ clin.type }}</td>

View File

@ -15,7 +15,6 @@ from tests.factories import (
PortfolioStateMachineFactory, PortfolioStateMachineFactory,
TaskOrderFactory, TaskOrderFactory,
UserFactory, UserFactory,
get_portfolio_csp_data,
) )
from atst.models import FSMStates, PortfolioStateMachine, TaskOrder 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.state = FSMStates.STARTED
state_machine.trigger_next_transition( 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 assert state_machine.current_state == FSMStates.TENANT_CREATED
@ -319,7 +318,11 @@ def test_fsm_transition_start(
config = {"billing_account_name": "billing_account_name"} config = {"billing_account_name": "billing_account_name"}
assert state_machine.state == FSMStates.UNSTARTED 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: for expected_state in expected_states:
collected_data = dict( collected_data = dict(

View File

@ -79,49 +79,6 @@ def get_all_portfolio_permission_sets():
return PermissionSets.get_many(PortfolioRoles.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): class Base(factory.alchemy.SQLAlchemyModelFactory):
@classmethod @classmethod
def dictionary(cls, **attrs): def dictionary(cls, **attrs):

View File

@ -179,3 +179,58 @@ class TestUpcomingObligatedFunds:
) )
# Only sums the upcoming task order # Only sums the upcoming task order
assert portfolio.upcoming_obligated_funds == Decimal(700.0) 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"

View File

@ -62,7 +62,7 @@ def test_clin_sorting():
CLINFactory.create(number="2001"), CLINFactory.create(number="2001"),
] ]
) )
assert [clin.number for clin in task_order.sorted_clins] == [ assert [clin.number for clin in task_order.clins] == [
"0001", "0001",
"1001", "1001",
"2001", "2001",