Merge pull request #593 from dod-ccpo/to-funding-status-alerts
TO Statuses on Portfolio Funding Page
This commit is contained in:
commit
cd13a01af8
@ -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(
|
||||
|
@ -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,
|
||||
)
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -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})
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,7 +44,7 @@
|
||||
<span class='label label--{{ label }}'>!{ taskOrder.display_status }</span>
|
||||
</td>
|
||||
<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
|
||||
v-bind:timestamp="taskOrder.start_date"
|
||||
format="M/D/YYYY">
|
||||
@ -51,8 +52,21 @@
|
||||
-
|
||||
<local-datetime
|
||||
v-bind:timestamp="taskOrder.end_date"
|
||||
format="M/D/YYYY">
|
||||
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,12 +92,19 @@
|
||||
<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>
|
||||
{% 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 %}
|
||||
@ -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>
|
||||
|
||||
|
@ -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):
|
||||
|
Loading…
x
Reference in New Issue
Block a user