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

View File

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

View File

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

View File

@ -21,6 +21,7 @@ export default {
props: { props: {
data: Array, data: Array,
expired: Boolean, expired: Boolean,
funded: Boolean,
}, },
components: { components: {
@ -47,6 +48,7 @@ export default {
attr: 'start_date', attr: 'start_date',
sortFunc: numericSort, sortFunc: numericSort,
width: '50%', width: '50%',
class: 'period-of-performance',
}, },
{ {
displayName: 'Initial Value', displayName: 'Initial Value',
@ -72,6 +74,7 @@ export default {
isAscending: false, isAscending: false,
columns: indexBy(prop('displayName'), columns), 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 # Add root application dir to the python path
import os import os
import sys import sys
from datetime import datetime, timedelta, date
parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
sys.path.append(parent_dir) sys.path.append(parent_dir)
@ -94,9 +95,12 @@ def seed_db():
users.append(user) 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: for user in users:
portfolio = Portfolios.create( 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: for portfolio_role in PORTFOLIO_USERS:
ws_role = Portfolios.create_member(user, portfolio, portfolio_role) ws_role = Portfolios.create_member(user, portfolio, portfolio_role)
@ -161,6 +165,92 @@ def seed_db():
environment_names=["dev", "staging", "prod"], 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__": if __name__ == "__main__":
config = make_config({"DISABLE_CRL_CHECK": True}) config = make_config({"DISABLE_CRL_CHECK": True})

View File

@ -50,6 +50,13 @@
@include icon-color($color-green); @include icon-color($color-green);
} }
} }
.unfunded {
color: $color-red;
.icon {
@include icon-color($color-red);
}
}
} }
.pending-task-order { .pending-task-order {
@ -82,10 +89,17 @@
.view-task-order-link { .view-task-order-link {
margin-left: $gap * 2; margin-left: $gap * 2;
.icon--tiny {
@include icon-size(10);
margin-left: 1rem;
}
} }
.portfolio-total-balance { .portfolio-total-balance {
margin-top: -$gap; margin-top: -$gap;
margin-bottom: 3rem;
.row { .row {
flex-direction: row-reverse; flex-direction: row-reverse;
margin: 2 * $gap 0; margin: 2 * $gap 0;
@ -98,8 +112,60 @@
} }
table { table {
th{
.icon {
margin-left: 1rem;
}
&.period-of-performance {
color: $color-blue;
.icon {
@include icon-color($color-primary)
}
}
}
td.unused-balance { td.unused-balance {
color: $color-red; 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) %} {% 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"> <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> <span>View</span>
{{ Icon("caret_right") }} {{ Icon("caret_right", classes="icon--tiny") }}
</a> </a>
{% endmacro %} {% endmacro %}
{% macro TaskOrderList(task_orders, label='success', expired=False) %} {% macro TaskOrderList(task_orders, label='success', expired=False, funded=False) %}
<task-order-list <task-order-list
inline-template inline-template
v-bind:data='{{ task_orders | tojson }}' v-bind:data='{{ task_orders | tojson }}'
v-bind:expired='{{ 'true' if expired else 'false' }}' v-bind:expired='{{ 'true' if expired else 'false' }}'
v-bind:funded='{{'true' if funded else 'false' }}'
v-cloak v-cloak
> >
<div class='responsive-table-wrapper'> <div class='responsive-table-wrapper'>
@ -27,10 +28,10 @@
!{ col.displayName } !{ col.displayName }
<template v-if="col.sortFunc"> <template v-if="col.sortFunc">
<span class="sorting-direction" v-if="col.displayName === sortInfo.columnName && sortInfo.isAscending"> <span class="sorting-direction" v-if="col.displayName === sortInfo.columnName && sortInfo.isAscending">
{{ Icon("caret_down") }} {{ Icon("caret_down", classes="icon--tiny") }}
</span> </span>
<span class="sorting-direction" v-if="col.displayName === sortInfo.columnName && !sortInfo.isAscending"> <span class="sorting-direction" v-if="col.displayName === sortInfo.columnName && !sortInfo.isAscending">
{{ Icon("caret_up") }} {{ Icon("caret_up", classes="icon--tiny") }}
</span> </span>
</template> </template>
</th> </th>
@ -43,7 +44,7 @@
<span class='label label--{{ label }}'>!{ taskOrder.display_status }</span> <span class='label label--{{ label }}'>!{ taskOrder.display_status }</span>
</td> </td>
<td class='table-cell--grow'> <td class='table-cell--grow'>
<span> <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 <local-datetime
v-bind:timestamp="taskOrder.start_date" v-bind:timestamp="taskOrder.start_date"
format="M/D/YYYY"> format="M/D/YYYY">
@ -51,8 +52,21 @@
- -
<local-datetime <local-datetime
v-bind:timestamp="taskOrder.end_date" v-bind:timestamp="taskOrder.end_date"
format="M/D/YYYY"> format="M/D/YYYY"
class="to-end-date"
>
</local-datetime> </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>
<td class="table-cell--align-right"> <td class="table-cell--align-right">
<span v-html='formatDollars(taskOrder.budget)'></span> <span v-html='formatDollars(taskOrder.budget)'></span>
@ -63,7 +77,7 @@
<td> <td>
<a v-bind:href="taskOrder.url" class="icon-link view-task-order-link"> <a v-bind:href="taskOrder.url" class="icon-link view-task-order-link">
<span>View</span> <span>View</span>
{{ Icon("caret_right") }} {{ Icon("caret_right", classes="icon--tiny") }}
</a> </a>
</td> </td>
</tr> </tr>
@ -78,12 +92,19 @@
<div class='panel'> <div class='panel'>
<div class='panel__content portfolio-funding__header row'> <div class='panel__content portfolio-funding__header row'>
<h3>Portfolio Funding</h3> <h3>Portfolio Funding</h3>
<div class='portfolio-funding__header--funded-through {{ "funded" if funding_end_date is not none }}'> <div class='portfolio-funding__header--funded-through {{ "funded" if funding_end_date is not none and funded else "unfunded"}}'>
{% if funding_end_date %} {% if funding_end_date and funded %}
{{ Icon('ok') }} {{ Icon('ok') }}
Funded through Funded through
<local-datetime <local-datetime
timestamp="{{ funding_end_date }}" timestamp='{{ funding_end_date }}'
format="M/D/YYYY">
</local-datetime>
{% elif funding_end_date and not funded %}
{{ Icon('alert') }}
Funded period ends
<local-datetime
timestamp='{{ funding_end_date }}'
format="M/D/YYYY"> format="M/D/YYYY">
</local-datetime> </local-datetime>
{% endif %} {% endif %}
@ -125,7 +146,7 @@
{% endif %} {% endif %}
{% if active_task_orders %} {% 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 portfolio-total-balance'>
<div class='panel__content row'> <div class='panel__content row'>
<span>{{ total_balance | dollars }}</span> <span>{{ total_balance | dollars }}</span>
@ -135,7 +156,7 @@
{% endif %} {% endif %}
{% if expired_task_orders %} {% if expired_task_orders %}
{{ TaskOrderList(expired_task_orders, label='', expired=True) }} {{ TaskOrderList(expired_task_orders, label='expired', expired=True) }}
{% endif %} {% endif %}
</div> </div>

View File

@ -1,5 +1,6 @@
from flask import url_for from flask import url_for
import pytest import pytest
from datetime import timedelta, date
from atst.domain.roles import Roles from atst.domain.roles import Roles
from atst.domain.task_orders import TaskOrders from atst.domain.task_orders import TaskOrders
@ -17,7 +18,7 @@ from tests.utils import captured_templates
class TestPortfolioFunding: class TestPortfolioFunding:
def test_unfunded_portfolio(self, app, user_session): def test_portfolio_with_no_task_orders(self, app, user_session):
portfolio = PortfolioFactory.create() portfolio = PortfolioFactory.create()
user_session(portfolio.owner) user_session(portfolio.owner)
@ -67,6 +68,54 @@ class TestPortfolioFunding:
assert context["funding_end_date"] is end_date assert context["funding_end_date"] is end_date
assert context["total_balance"] == active_to1.budget + active_to2.budget 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: class TestTaskOrderInvitations:
def setup(self): def setup(self):