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:
commit
f8dd072340
@ -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
|
||||||
|
11
atst/jobs.py
11
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):
|
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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
clin
|
||||||
for task_order in self.task_orders
|
for clin in self.clins
|
||||||
for clin in task_order.clins
|
if (clin.is_active and clin.task_order.is_signed)
|
||||||
if clin.is_active
|
),
|
||||||
]
|
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
|
||||||
|
@ -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
|
||||||
|
@ -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/"
|
||||||
|
@ -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>
|
||||||
|
@ -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(
|
||||||
|
@ -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):
|
||||||
|
@ -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"
|
||||||
|
@ -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",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user