Merge pull request #593 from dod-ccpo/to-funding-status-alerts

TO Statuses on Portfolio Funding Page
This commit is contained in:
leigh-mil 2019-02-05 15:44:13 -05:00 committed by GitHub
commit cd13a01af8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 264 additions and 23 deletions

View File

@ -1,4 +1,5 @@
from enum import Enum
from datetime import date
import pendulum
from sqlalchemy import Column, Numeric, String, ForeignKey, Date, Integer
@ -117,6 +118,11 @@ class TaskOrder(Base, mixins.TimestampsMixin):
def display_status(self):
return self.status.value
@property
def days_to_expiration(self):
if self.end_date:
return (self.end_date - date.today()).days
@property
def budget(self):
return sum(

View File

@ -26,6 +26,7 @@ def portfolio_funding(portfolio_id):
"start_date",
"end_date",
"display_status",
"days_to_expiration",
"balance",
]
}
@ -45,6 +46,7 @@ def portfolio_funding(portfolio_id):
if active_task_orders
else None
)
funded = len(active_task_orders) > 1
total_balance = sum([task_order["balance"] for task_order in active_task_orders])
return render_template(
@ -54,6 +56,7 @@ def portfolio_funding(portfolio_id):
active_task_orders=active_task_orders,
expired_task_orders=task_orders_by_status.get(TaskOrderStatus.EXPIRED, []),
funding_end_date=funding_end_date,
funded=funded,
total_balance=total_balance,
)

View File

@ -1,4 +1,5 @@
from flask.json import JSONEncoder
from datetime import date
from atst.models.attachment import Attachment
@ -6,4 +7,6 @@ class CustomJSONEncoder(JSONEncoder):
def default(self, obj):
if isinstance(obj, Attachment):
return obj.filename
if isinstance(obj, date):
return obj.strftime("%Y-%m-%d")
return JSONEncoder.default(self, obj)

View File

@ -21,6 +21,7 @@ export default {
props: {
data: Array,
expired: Boolean,
funded: Boolean,
},
components: {
@ -47,6 +48,7 @@ export default {
attr: 'start_date',
sortFunc: numericSort,
width: '50%',
class: 'period-of-performance',
},
{
displayName: 'Initial Value',
@ -72,6 +74,7 @@ export default {
isAscending: false,
columns: indexBy(prop('displayName'), columns),
},
days_to_exp_alert_limit: 30,
}
},

View File

@ -1,6 +1,7 @@
# Add root application dir to the python path
import os
import sys
from datetime import datetime, timedelta, date
parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
sys.path.append(parent_dir)
@ -94,9 +95,12 @@ def seed_db():
users.append(user)
amanda = Users.get_by_dod_id("2345678901")
# create Portfolios for all users that have funding and are not expiring soon
for user in users:
portfolio = Portfolios.create(
user, name="{}'s portfolio".format(user.first_name)
user, name="{}'s portfolio (not expiring)".format(user.first_name)
)
for portfolio_role in PORTFOLIO_USERS:
ws_role = Portfolios.create_member(user, portfolio, portfolio_role)
@ -161,6 +165,92 @@ def seed_db():
environment_names=["dev", "staging", "prod"],
)
# Create Portfolio for Amanda with TO that is expiring soon and does not have another TO
unfunded_portfolio = Portfolios.create(
amanda, name="{}'s portfolio (expiring and unfunded)".format(amanda.first_name)
)
[past_date_1, past_date_2, past_date_3, future_date] = sorted(
[
random_past_date(year_max=3, year_min=2),
random_past_date(year_max=2, year_min=1),
random_past_date(year_max=1, year_min=1),
(date.today() + timedelta(days=20)),
]
)
date_ranges = [
(past_date_1, past_date_2),
(past_date_2, past_date_3),
(past_date_3, future_date),
]
for (start_date, end_date) in date_ranges:
task_order = TaskOrderFactory.build(
start_date=start_date,
end_date=end_date,
number=random_task_order_number(),
portfolio=unfunded_portfolio,
)
db.session.add(task_order)
db.session.commit()
Applications.create(
amanda,
portfolio=unfunded_portfolio,
name="First Application",
description="This is our first application.",
environment_names=["dev", "staging", "prod"],
)
# Create Portfolio for Amanda with TO that is expiring soon and has another TO
funded_portfolio = Portfolios.create(
amanda, name="{}'s portfolio (expiring and funded)".format(amanda.first_name)
)
[
past_date_1,
past_date_2,
past_date_3,
past_date_4,
future_date_1,
future_date_2,
] = sorted(
[
random_past_date(year_max=3, year_min=2),
random_past_date(year_max=2, year_min=1),
random_past_date(year_max=1, year_min=1),
random_past_date(year_max=1, year_min=1),
(date.today() + timedelta(days=20)),
random_future_date(year_min=0, year_max=1),
]
)
date_ranges = [
(past_date_1, past_date_2),
(past_date_2, past_date_3),
(past_date_3, future_date_1),
(past_date_4, future_date_2),
]
for (start_date, end_date) in date_ranges:
task_order = TaskOrderFactory.build(
start_date=start_date,
end_date=end_date,
number=random_task_order_number(),
portfolio=funded_portfolio,
)
db.session.add(task_order)
db.session.commit()
Applications.create(
amanda,
portfolio=funded_portfolio,
name="First Application",
description="This is our first application.",
environment_names=["dev", "staging", "prod"],
)
if __name__ == "__main__":
config = make_config({"DISABLE_CRL_CHECK": True})

View File

@ -50,6 +50,13 @@
@include icon-color($color-green);
}
}
.unfunded {
color: $color-red;
.icon {
@include icon-color($color-red);
}
}
}
.pending-task-order {
@ -82,10 +89,17 @@
.view-task-order-link {
margin-left: $gap * 2;
.icon--tiny {
@include icon-size(10);
margin-left: 1rem;
}
}
.portfolio-total-balance {
margin-top: -$gap;
margin-bottom: 3rem;
.row {
flex-direction: row-reverse;
margin: 2 * $gap 0;
@ -98,8 +112,60 @@
}
table {
th{
.icon {
margin-left: 1rem;
}
&.period-of-performance {
color: $color-blue;
.icon {
@include icon-color($color-primary)
}
}
}
td.unused-balance {
color: $color-red;
}
.label--expired {
background-color: $color-gray-light;
}
.to-performance-period {
&.to-expiring-soon {
.to-expiration-alert {
font-weight: $font-bold;
font-size: 1.5rem;
margin-left: $gap;
}
&.funded .to-expiration-alert {
color: $color-blue;
.icon {
@include icon-color($color-blue);
}
}
&.unfunded {
.to-expiration-alert {
color: $color-red;
}
.icon {
@include icon-color($color-red);
}
.to-end-date {
color: $color-red;
}
}
}
}
}
}

View File

@ -8,15 +8,16 @@
{% macro ViewLink(task_order) %}
<a href="{{ url_for('portfolios.view_task_order', portfolio_id=portfolio.id, task_order_id=task_order.id) }}" class="icon-link view-task-order-link">
<span>View</span>
{{ Icon("caret_right") }}
{{ Icon("caret_right", classes="icon--tiny") }}
</a>
{% endmacro %}
{% macro TaskOrderList(task_orders, label='success', expired=False) %}
{% macro TaskOrderList(task_orders, label='success', expired=False, funded=False) %}
<task-order-list
inline-template
v-bind:data='{{ task_orders | tojson }}'
v-bind:expired='{{ 'true' if expired else 'false' }}'
v-bind:funded='{{'true' if funded else 'false' }}'
v-cloak
>
<div class='responsive-table-wrapper'>
@ -27,10 +28,10 @@
!{ col.displayName }
<template v-if="col.sortFunc">
<span class="sorting-direction" v-if="col.displayName === sortInfo.columnName && sortInfo.isAscending">
{{ Icon("caret_down") }}
{{ Icon("caret_down", classes="icon--tiny") }}
</span>
<span class="sorting-direction" v-if="col.displayName === sortInfo.columnName && !sortInfo.isAscending">
{{ Icon("caret_up") }}
{{ Icon("caret_up", classes="icon--tiny") }}
</span>
</template>
</th>
@ -43,16 +44,29 @@
<span class='label label--{{ label }}'>!{ taskOrder.display_status }</span>
</td>
<td class='table-cell--grow'>
<span>
<local-datetime
v-bind:timestamp="taskOrder.start_date"
format="M/D/YYYY">
</local-datetime>
-
<local-datetime
v-bind:timestamp="taskOrder.end_date"
format="M/D/YYYY">
<span :class="{ 'to-performance-period': true, 'to-expiring-soon': (taskOrder.days_to_expiration > 0 && taskOrder.days_to_expiration <= days_to_exp_alert_limit), 'funded': funded && taskOrder.display_status === 'Active', 'unfunded': !funded && taskOrder.display_status === 'Active' }">
<local-datetime
v-bind:timestamp="taskOrder.start_date"
format="M/D/YYYY">
</local-datetime>
-
<local-datetime
v-bind:timestamp="taskOrder.end_date"
format="M/D/YYYY"
class="to-end-date"
>
</local-datetime>
<span
v-if="taskOrder.days_to_expiration > 0 && taskOrder.days_to_expiration <= days_to_exp_alert_limit && funded"
class="to-expiration-alert">
{{ Icon('ok') }} Period ending in !{ taskOrder.days_to_expiration } days, but new period funded
</span>
<span
v-if="taskOrder.days_to_expiration > 0 && taskOrder.days_to_expiration <= days_to_exp_alert_limit && !funded"
class="to-expiration-alert">
{{ Icon('alert') }} Period ends in !{ taskOrder.days_to_expiration } days, submit a new task order<br>
</span>
</span>
</td>
<td class="table-cell--align-right">
<span v-html='formatDollars(taskOrder.budget)'></span>
@ -63,7 +77,7 @@
<td>
<a v-bind:href="taskOrder.url" class="icon-link view-task-order-link">
<span>View</span>
{{ Icon("caret_right") }}
{{ Icon("caret_right", classes="icon--tiny") }}
</a>
</td>
</tr>
@ -78,15 +92,22 @@
<div class='panel'>
<div class='panel__content portfolio-funding__header row'>
<h3>Portfolio Funding</h3>
<div class='portfolio-funding__header--funded-through {{ "funded" if funding_end_date is not none }}'>
{% if funding_end_date %}
<div class='portfolio-funding__header--funded-through {{ "funded" if funding_end_date is not none and funded else "unfunded"}}'>
{% if funding_end_date and funded %}
{{ Icon('ok') }}
Funded through
<local-datetime
timestamp="{{ funding_end_date }}"
timestamp='{{ funding_end_date }}'
format="M/D/YYYY">
</local-datetime>
{% endif %}
</local-datetime>
{% elif funding_end_date and not funded %}
{{ Icon('alert') }}
Funded period ends
<local-datetime
timestamp='{{ funding_end_date }}'
format="M/D/YYYY">
</local-datetime>
{% endif %}
</div>
<a href="{{ url_for("task_orders.new", screen=1, portfolio_id=portfolio.id) }}" class="usa-button">Start a New Task Order</a>
</div>
@ -125,7 +146,7 @@
{% endif %}
{% if active_task_orders %}
{{ TaskOrderList(active_task_orders, label='success') }}
{{ TaskOrderList(active_task_orders, label='success', funded=funded) }}
<div class='panel portfolio-total-balance'>
<div class='panel__content row'>
<span>{{ total_balance | dollars }}</span>
@ -135,7 +156,7 @@
{% endif %}
{% if expired_task_orders %}
{{ TaskOrderList(expired_task_orders, label='', expired=True) }}
{{ TaskOrderList(expired_task_orders, label='expired', expired=True) }}
{% endif %}
</div>

View File

@ -1,5 +1,6 @@
from flask import url_for
import pytest
from datetime import timedelta, date
from atst.domain.roles import Roles
from atst.domain.task_orders import TaskOrders
@ -17,7 +18,7 @@ from tests.utils import captured_templates
class TestPortfolioFunding:
def test_unfunded_portfolio(self, app, user_session):
def test_portfolio_with_no_task_orders(self, app, user_session):
portfolio = PortfolioFactory.create()
user_session(portfolio.owner)
@ -67,6 +68,54 @@ class TestPortfolioFunding:
assert context["funding_end_date"] is end_date
assert context["total_balance"] == active_to1.budget + active_to2.budget
def test_expiring_and_funded_portfolio(self, app, user_session):
portfolio = PortfolioFactory.create()
user_session(portfolio.owner)
expiring_to = TaskOrderFactory.create(
portfolio=portfolio,
start_date=random_past_date(),
end_date=(date.today() + timedelta(days=10)),
number="42",
)
active_to = TaskOrderFactory.create(
portfolio=portfolio,
start_date=random_past_date(),
end_date=random_future_date(year_min=1, year_max=2),
number="43",
)
with captured_templates(app) as templates:
response = app.test_client().get(
url_for("portfolios.portfolio_funding", portfolio_id=portfolio.id)
)
assert response.status_code == 200
_, context = templates[0]
assert context["funding_end_date"] is active_to.end_date
assert context["funded"] == True
def test_expiring_and_unfunded_portfolio(self, app, user_session):
portfolio = PortfolioFactory.create()
user_session(portfolio.owner)
expiring_to = TaskOrderFactory.create(
portfolio=portfolio,
start_date=random_past_date(),
end_date=(date.today() + timedelta(days=10)),
number="42",
)
with captured_templates(app) as templates:
response = app.test_client().get(
url_for("portfolios.portfolio_funding", portfolio_id=portfolio.id)
)
assert response.status_code == 200
_, context = templates[0]
assert context["funding_end_date"] is expiring_to.end_date
assert context["funded"] == False
class TestTaskOrderInvitations:
def setup(self):