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): 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() fsm.trigger_next_transition(csp_data=portfolio.to_dictionary())
@celery.task(bind=True, base=RecordFailure) @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 import Column, String
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.types import ARRAY from sqlalchemy.types import ARRAY
from itertools import chain
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.task_order import TaskOrder
from atst.models.portfolio_role import PortfolioRole, Status as PortfolioRoleStatus from atst.models.portfolio_role import PortfolioRole, Status as PortfolioRoleStatus
from atst.domain.permission_sets import PermissionSets from atst.domain.permission_sets import PermissionSets
from atst.utils import first_or_none 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) (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 @property
def funding_duration(self): def funding_duration(self):
""" """
@ -159,6 +174,51 @@ class Portfolio(
def application_id(self): def application_id(self):
return None 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): 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

@ -99,6 +99,8 @@ class PortfolioStateMachine(
def trigger_next_transition(self, **kwargs): def trigger_next_transition(self, **kwargs):
state_obj = self.machine.get_state(self.state) state_obj = self.machine.get_state(self.state)
kwargs["csp_data"] = kwargs.get("csp_data", {})
if state_obj.is_system: if state_obj.is_system:
if self.current_state in (FSMStates.UNSTARTED, FSMStates.STARTING): if self.current_state in (FSMStates.UNSTARTED, FSMStates.STARTING):
# call the first trigger availabe for these two system states # 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): def is_expired(self):
return self.status == Status.EXPIRED return self.status == Status.EXPIRED
@property
def is_upcoming(self):
return self.status == Status.UPCOMING
@property @property
def clins_are_completed(self): def clins_are_completed(self):
return all([len(self.clins), (clin.is_completed for clin in self.clins)]) 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( return render_template(
"portfolios/reports/index.html", "portfolios/reports/index.html",
portfolio=portfolio, portfolio=portfolio,
# wrapped in str() because the sum of obligated funds returns a Decimal object # wrapped in str() because this sum returns a Decimal object
total_portfolio_value=str(portfolio.total_obligated_funds), total_portfolio_value=str(
portfolio.total_obligated_funds + portfolio.upcoming_obligated_funds
),
current_obligated_funds=current_obligated_funds, current_obligated_funds=current_obligated_funds,
expired_task_orders=Reports.expired_task_orders(portfolio), expired_task_orders=Reports.expired_task_orders(portfolio),
retrieved=datetime.now(), # mocked datetime of reporting data retrival 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 task_order=unsigned_to, start_date=(today - five_days), end_date=today
), ),
CLINFactory.build( 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( CLINFactory.build(
task_order=expired_to, start_date=(today - five_days), end_date=yesterday task_order=expired_to, start_date=(today - five_days), end_date=yesterday

View File

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

View File

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

View File

@ -1,7 +1,8 @@
{% from "components/alert.html" import Alert %} {% from "components/alert.html" import Alert %}
{% from "components/checkbox_input.html" import CheckboxInput %} {% from "components/checkbox_input.html" import CheckboxInput %}
{% from "components/text_input.html" import TextInput %}
{% from "components/phone_input.html" import PhoneInput %} {% 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) %} {% macro EnvRoleInput(sub_form, member_role_id=None) %}
{% set role = sub_form.role.data if not sub_form.disabled.data else "Access Suspended" %} {% 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) }} {{ TextInput(member_form.email, validation='email', optional=False) }}
{{ PhoneInput(member_form.phone_number, member_form.phone_ext)}} {{ PhoneInput(member_form.phone_number, member_form.phone_ext)}}
{{ TextInput(member_form.dod_id, validation='dodId', optional=False) }} {{ 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> </div>
{% endmacro %} {% endmacro %}

View File

@ -2,7 +2,7 @@
{% macro Tooltip(message,title='Help', classes="") %} {% 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> {{ Icon('question') }}<span>{{ title }}</span>
</button> </button>

View File

@ -2,6 +2,7 @@
{% from "components/icon.html" import Icon %} {% from "components/icon.html" import Icon %}
{% from "components/phone_input.html" import PhoneInput %} {% from "components/phone_input.html" import PhoneInput %}
{% from "components/text_input.html" import TextInput %} {% from "components/text_input.html" import TextInput %}
{% from "components/tooltip.html" import Tooltip %}
{% macro PermsFields(form, member_role_id=None) %} {% macro PermsFields(form, member_role_id=None) %}
<h2>Set Portfolio Permissions</h2> <h2>Set Portfolio Permissions</h2>
@ -32,6 +33,6 @@
{{ TextInput(member_form.email, validation='email', optional=False) }} {{ TextInput(member_form.email, validation='email', optional=False) }}
{{ PhoneInput(member_form.phone_number, member_form.phone_ext)}} {{ PhoneInput(member_form.phone_number, member_form.phone_ext)}}
{{ TextInput(member_form.dod_id, validation='dodId', optional=False) }} {{ 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> </div>
{% endmacro %} {% endmacro %}

View File

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

View File

@ -12,7 +12,7 @@
<div class='col col--grow summary-item'> <div class='col col--grow summary-item'>
<h4 class="summary-item__header"> <h4 class="summary-item__header">
<span class="summary-item__header-text">{{ 'task_orders.summary.total' | translate }}</span> <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> </h4>
<p class="summary-item__value--large"> <p class="summary-item__value--large">
{{ contract_amount | dollars }} {{ contract_amount | dollars }}
@ -21,7 +21,7 @@
<div class='col col--grow summary-item'> <div class='col col--grow summary-item'>
<h4 class="summary-item__header"> <h4 class="summary-item__header">
<span class="summary-item__header-text">{{ 'task_orders.summary.obligated' | translate }}</span> <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> </h4>
<p class="summary-item__value--large"> <p class="summary-item__value--large">
{{ obligated_funds | dollars }} {{ obligated_funds | dollars }}
@ -30,7 +30,7 @@
<div class='col col--grow summary-item'> <div class='col col--grow summary-item'>
<h4 class="summary-item__header"> <h4 class="summary-item__header">
<span class="summary-item__header-text">{{ 'task_orders.summary.expended' | translate }}</span> <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> </h4>
<p class="summary-item__value--large"> <p class="summary-item__value--large">
{{ expended_funds | dollars }} {{ expended_funds | dollars }}

View File

@ -7,6 +7,51 @@ from tests.factories import (
random_past_date, random_past_date,
) )
import datetime 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(): 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()] portfolio=portfolio, signed_at=random_past_date(), clins=[CLINFactory.create()]
) )
assert len(portfolio.active_task_orders) == 1 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. description: Add, remove and edit applications in this Portfolio.
dod_id_label: DoD ID dod_id_label: DoD ID
dod_help: How do I find out the 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 email_label: Email address
first_name_label: First name first_name_label: First name
funding: funding:
@ -507,7 +508,7 @@ portfolios:
estimate_warning: Reports displayed in JEDI are estimates and not a system of record. estimate_warning: Reports displayed in JEDI are estimates and not a system of record.
total_value: total_value:
header: Total Portfolio 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: task_orders:
add_new_button: Add New Task Order add_new_button: Add New Task Order
review: review: