Merge branch 'staging' into billing-owner
This commit is contained in:
commit
285021de7c
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)])
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -513,10 +513,6 @@
|
||||
}
|
||||
&__header {
|
||||
margin: 0;
|
||||
&-icon {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
&__value {
|
||||
font-size: $lead-font-size;
|
||||
|
@ -95,4 +95,9 @@
|
||||
.icon {
|
||||
@include icon-size(16);
|
||||
}
|
||||
|
||||
&--tight {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
@ -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 %}
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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 %}
|
||||
|
@ -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>
|
||||
|
@ -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 }}
|
||||
|
@ -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)
|
||||
|
@ -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:
|
||||
|
Loading…
x
Reference in New Issue
Block a user