Merge branch 'staging' into billing-owner

This commit is contained in:
dandds 2020-02-11 16:01:27 -05:00 committed by GitHub
commit 285021de7c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 187 additions and 20 deletions

View File

@ -180,7 +180,7 @@ def do_work(fn, task, csp, **kwargs):
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()
fsm.trigger_next_transition(csp_data=portfolio.to_dictionary())
@celery.task(bind=True, base=RecordFailure)

View File

@ -1,11 +1,16 @@
import re
from string import ascii_lowercase, digits
from random import choices
from itertools import chain
from sqlalchemy import Column, String
from sqlalchemy.orm import relationship
from sqlalchemy.types import ARRAY
from itertools import chain
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
@ -95,6 +100,16 @@ class Portfolio(
(task_order.total_obligated_funds for task_order in self.active_task_orders)
)
@property
def upcoming_obligated_funds(self):
return sum(
(
task_order.total_obligated_funds
for task_order in self.task_orders
if task_order.is_upcoming
)
)
@property
def funding_duration(self):
"""
@ -159,6 +174,51 @@ class Portfolio(
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,
"password": "",
"domain_name": domain_name,
"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": "ATAT Billing Profile",
}
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):
return "<Portfolio(name='{}', user_count='{}', id='{}')>".format(
self.name, self.user_count, self.id

View File

@ -99,6 +99,8 @@ class PortfolioStateMachine(
def trigger_next_transition(self, **kwargs):
state_obj = self.machine.get_state(self.state)
kwargs["csp_data"] = kwargs.get("csp_data", {})
if state_obj.is_system:
if self.current_state in (FSMStates.UNSTARTED, FSMStates.STARTING):
# call the first trigger availabe for these two system states

View File

@ -87,6 +87,10 @@ class TaskOrder(Base, mixins.TimestampsMixin):
def is_expired(self):
return self.status == Status.EXPIRED
@property
def is_upcoming(self):
return self.status == Status.UPCOMING
@property
def clins_are_completed(self):
return all([len(self.clins), (clin.is_completed for clin in self.clins)])

View File

@ -50,8 +50,10 @@ def reports(portfolio_id):
return render_template(
"portfolios/reports/index.html",
portfolio=portfolio,
# wrapped in str() because the sum of obligated funds returns a Decimal object
total_portfolio_value=str(portfolio.total_obligated_funds),
# wrapped in str() because this sum returns a Decimal object
total_portfolio_value=str(
portfolio.total_obligated_funds + portfolio.upcoming_obligated_funds
),
current_obligated_funds=current_obligated_funds,
expired_task_orders=Reports.expired_task_orders(portfolio),
retrieved=datetime.now(), # mocked datetime of reporting data retrival

View File

@ -195,7 +195,7 @@ def add_task_orders_to_portfolio(portfolio):
task_order=unsigned_to, start_date=(today - five_days), end_date=today
),
CLINFactory.build(
task_order=upcoming_to, start_date=future, end_date=(today + five_days)
task_order=upcoming_to, start_date=(today + five_days), end_date=future
),
CLINFactory.build(
task_order=expired_to, start_date=(today - five_days), end_date=yesterday

View File

@ -513,10 +513,6 @@
}
&__header {
margin: 0;
&-icon {
margin: 0;
padding: 0;
}
}
&__value {
font-size: $lead-font-size;

View File

@ -95,4 +95,9 @@
.icon {
@include icon-size(16);
}
&--tight {
margin: 0;
padding: 0;
}
}

View File

@ -1,7 +1,8 @@
{% from "components/alert.html" import Alert %}
{% from "components/checkbox_input.html" import CheckboxInput %}
{% from "components/text_input.html" import TextInput %}
{% from "components/phone_input.html" import PhoneInput %}
{% from "components/text_input.html" import TextInput %}
{% from "components/tooltip.html" import Tooltip %}
{% macro EnvRoleInput(sub_form, member_role_id=None) %}
{% set role = sub_form.role.data if not sub_form.disabled.data else "Access Suspended" %}
@ -121,6 +122,6 @@
{{ TextInput(member_form.email, validation='email', optional=False) }}
{{ PhoneInput(member_form.phone_number, member_form.phone_ext)}}
{{ TextInput(member_form.dod_id, validation='dodId', optional=False) }}
<a href="#">{{ "forms.new_member.dod_help" | translate }}</a>
{{ "forms.new_member.dod_help" | translate }} {{ Tooltip("forms.new_member.dod_text"|translate, title="", classes="icon-tooltip--tight") }}
</div>
{% endmacro %}

View File

@ -2,7 +2,7 @@
{% macro Tooltip(message,title='Help', classes="") %}
<button type="button" tabindex="0" class="icon-tooltip {{classes}}" v-tooltip.top="{content: '{{message}}', container: false}">
<button type="button" tabindex="0" class="icon-tooltip {{classes}}" v-tooltip.top='{content: "{{message}}", container: false}'>
{{ Icon('question') }}<span>{{ title }}</span>
</button>

View File

@ -2,6 +2,7 @@
{% from "components/icon.html" import Icon %}
{% from "components/phone_input.html" import PhoneInput %}
{% from "components/text_input.html" import TextInput %}
{% from "components/tooltip.html" import Tooltip %}
{% macro PermsFields(form, member_role_id=None) %}
<h2>Set Portfolio Permissions</h2>
@ -32,6 +33,6 @@
{{ TextInput(member_form.email, validation='email', optional=False) }}
{{ PhoneInput(member_form.phone_number, member_form.phone_ext)}}
{{ TextInput(member_form.dod_id, validation='dodId', optional=False) }}
<a href="#">{{ "forms.new_member.dod_help" | translate }}</a>
{{ "forms.new_member.dod_help" | translate }} {{ Tooltip("forms.new_member.dod_text"|translate, title="", classes="icon-tooltip--tight") }}
</div>
{% endmacro %}

View File

@ -6,14 +6,14 @@
<div class='col col--grow summary-item'>
<h5 class="summary-item__header">
<span class="summary-item__header-text">{{ "portfolios.reports.total_value.header" | translate }}</span>
{{Tooltip(("portfolios.reports.total_value.tooltip" | translate), title="", classes="summary-item__header-icon")}}
{{Tooltip(("portfolios.reports.total_value.tooltip" | translate), title="", classes="icon-tooltip--tight")}}
</h5>
<p class="summary-item__value">{{ total_portfolio_value | dollars }}</p>
</div>
<div class='col col--grow summary-item'>
<h5 class="summary-item__header">
<span class="summary-item__header-text">{{ "portfolios.reports.duration.header" | translate }}</span>
{{Tooltip(("portfolios.reports.duration.tooltip" | translate), title="", classes="summary-item__header-icon")}}
{{Tooltip(("portfolios.reports.duration.tooltip" | translate), title="", classes="icon-tooltip--tight")}}
</h5>
{% set earliest_pop_start_date, latest_pop_end_date = portfolio.funding_duration %}
{% if earliest_pop_start_date and latest_pop_end_date %}
@ -29,7 +29,7 @@
<div class='col col--grow summary-item'>
<h5 class="summary-item__header">
<span class="summary-item__header-text">{{ "portfolios.reports.days_remaining.header" | translate }}</span>
{{Tooltip(("portfolios.reports.days_remaining.toolip" | translate), title="", classes="summary-item__header-icon")}}
{{Tooltip(("portfolios.reports.days_remaining.toolip" | translate), title="", classes="icon-tooltip--tight")}}
</h5>
<p class="summary-item__value">{{ portfolio.days_to_funding_expiration }} days</p>
</div>

View File

@ -12,7 +12,7 @@
<div class='col col--grow summary-item'>
<h4 class="summary-item__header">
<span class="summary-item__header-text">{{ 'task_orders.summary.total' | translate }}</span>
{{ Tooltip(("task_orders.review.tooltip.total_value" | translate), title="", classes="summary-item__header-icon") }}
{{ Tooltip(("task_orders.review.tooltip.total_value" | translate), title="", classes="icon-tooltip--tight") }}
</h4>
<p class="summary-item__value--large">
{{ contract_amount | dollars }}
@ -21,7 +21,7 @@
<div class='col col--grow summary-item'>
<h4 class="summary-item__header">
<span class="summary-item__header-text">{{ 'task_orders.summary.obligated' | translate }}</span>
{{ Tooltip(("task_orders.review.tooltip.obligated_funds" | translate), title="", classes="summary-item__header-icon") }}
{{ Tooltip(("task_orders.review.tooltip.obligated_funds" | translate), title="", classes="icon-tooltip--tight") }}
</h4>
<p class="summary-item__value--large">
{{ obligated_funds | dollars }}
@ -30,7 +30,7 @@
<div class='col col--grow summary-item'>
<h4 class="summary-item__header">
<span class="summary-item__header-text">{{ 'task_orders.summary.expended' | translate }}</span>
{{ Tooltip(("task_orders.review.tooltip.expended_funds" | translate), title="", classes="summary-item__header-icon") }}
{{ Tooltip(("task_orders.review.tooltip.expended_funds" | translate), title="", classes="icon-tooltip--tight") }}
</h4>
<p class="summary-item__value--large">
{{ expended_funds | dollars }}

View File

@ -7,6 +7,51 @@ from tests.factories import (
random_past_date,
)
import datetime
import pendulum
from decimal import Decimal
import pytest
@pytest.fixture(scope="function")
def upcoming_task_order():
return dict(
signed_at=pendulum.today().subtract(days=3),
create_clins=[
dict(
start_date=pendulum.today().add(days=2),
end_date=pendulum.today().add(days=3),
obligated_amount=Decimal(700.0),
)
],
)
@pytest.fixture(scope="function")
def current_task_order():
return dict(
signed_at=pendulum.today().subtract(days=3),
create_clins=[
dict(
start_date=pendulum.today().subtract(days=1),
end_date=pendulum.today().add(days=1),
obligated_amount=Decimal(1000.0),
)
],
)
@pytest.fixture(scope="function")
def past_task_order():
return dict(
signed_at=pendulum.today().subtract(days=3),
create_clins=[
dict(
start_date=pendulum.today().subtract(days=3),
end_date=pendulum.today().subtract(days=2),
obligated_amount=Decimal(500.0),
)
],
)
def test_portfolio_applications_excludes_deleted():
@ -85,3 +130,53 @@ def test_active_task_orders(session):
portfolio=portfolio, signed_at=random_past_date(), clins=[CLINFactory.create()]
)
assert len(portfolio.active_task_orders) == 1
class TestCurrentObligatedFunds:
"""
Tests the current_obligated_funds property
"""
def test_no_task_orders(self):
portfolio = PortfolioFactory()
assert portfolio.total_obligated_funds == Decimal(0)
def test_with_current(self, current_task_order):
portfolio = PortfolioFactory(
task_orders=[current_task_order, current_task_order]
)
assert portfolio.total_obligated_funds == Decimal(2000.0)
def test_with_others(
self, past_task_order, current_task_order, upcoming_task_order
):
portfolio = PortfolioFactory(
task_orders=[past_task_order, current_task_order, upcoming_task_order,]
)
# Only sums the current task order
assert portfolio.total_obligated_funds == Decimal(1000.0)
class TestUpcomingObligatedFunds:
"""
Tests the upcoming_obligated_funds property
"""
def test_no_task_orders(self):
portfolio = PortfolioFactory()
assert portfolio.upcoming_obligated_funds == Decimal(0)
def test_with_upcoming(self, upcoming_task_order):
portfolio = PortfolioFactory(
task_orders=[upcoming_task_order, upcoming_task_order]
)
assert portfolio.upcoming_obligated_funds == Decimal(1400.0)
def test_with_others(
self, past_task_order, current_task_order, upcoming_task_order
):
portfolio = PortfolioFactory(
task_orders=[past_task_order, current_task_order, upcoming_task_order]
)
# Only sums the upcoming task order
assert portfolio.upcoming_obligated_funds == Decimal(700.0)

View File

@ -246,6 +246,7 @@ forms:
description: Add, remove and edit applications in this Portfolio.
dod_id_label: DoD ID
dod_help: How do I find out the DoD ID?
dod_text: "An individual's DOD ID (formerly known as EDIPI) number can be found on the back of their CAC. Access to this system requires a valid CAC and DOD ID."
email_label: Email address
first_name_label: First name
funding:
@ -507,7 +508,7 @@ portfolios:
estimate_warning: Reports displayed in JEDI are estimates and not a system of record.
total_value:
header: Total Portfolio Value
tooltip: Total portfolio value is all obligated and projected funds for all task orders in this portfolio.
tooltip: Total portfolio value is all obligated funds for current and upcoming task orders in this portfolio.
task_orders:
add_new_button: Add New Task Order
review: