diff --git a/atst/models/task_order.py b/atst/models/task_order.py index b7c25450..6ea523e2 100644 --- a/atst/models/task_order.py +++ b/atst/models/task_order.py @@ -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( diff --git a/atst/routes/portfolios/task_orders.py b/atst/routes/portfolios/task_orders.py index d5278ec6..70c30a33 100644 --- a/atst/routes/portfolios/task_orders.py +++ b/atst/routes/portfolios/task_orders.py @@ -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, ) diff --git a/atst/utils/json.py b/atst/utils/json.py index a489fcaa..4ce7bd8d 100644 --- a/atst/utils/json.py +++ b/atst/utils/json.py @@ -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) diff --git a/js/components/tables/task_order_list.js b/js/components/tables/task_order_list.js index d9cc3a2f..d7353307 100644 --- a/js/components/tables/task_order_list.js +++ b/js/components/tables/task_order_list.js @@ -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, } }, diff --git a/script/seed_sample.py b/script/seed_sample.py index d7361663..801056e9 100644 --- a/script/seed_sample.py +++ b/script/seed_sample.py @@ -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}) diff --git a/styles/components/_portfolio_layout.scss b/styles/components/_portfolio_layout.scss index 6702d67d..e74481a3 100644 --- a/styles/components/_portfolio_layout.scss +++ b/styles/components/_portfolio_layout.scss @@ -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; + } + } + } + } } } diff --git a/templates/portfolios/task_orders/index.html b/templates/portfolios/task_orders/index.html index 783c676d..18653ab1 100644 --- a/templates/portfolios/task_orders/index.html +++ b/templates/portfolios/task_orders/index.html @@ -8,15 +8,16 @@ {% macro ViewLink(task_order) %} View - {{ Icon("caret_right") }} + {{ Icon("caret_right", classes="icon--tiny") }} {% endmacro %} -{% macro TaskOrderList(task_orders, label='success', expired=False) %} +{% macro TaskOrderList(task_orders, label='success', expired=False, funded=False) %}
@@ -27,10 +28,10 @@ !{ col.displayName } @@ -43,16 +44,29 @@ !{ taskOrder.display_status } - - - - - - + + + + - + + + {{ Icon('ok') }} Period ending in !{ taskOrder.days_to_expiration } days, but new period funded + + + {{ Icon('alert') }} Period ends in !{ taskOrder.days_to_expiration } days, submit a new task order
+
+
@@ -63,7 +77,7 @@ View - {{ Icon("caret_right") }} + {{ Icon("caret_right", classes="icon--tiny") }} @@ -78,15 +92,22 @@

Portfolio Funding

-
- {% if funding_end_date %} +
+ {% if funding_end_date and funded %} {{ Icon('ok') }} Funded through - - {% endif %} + + {% elif funding_end_date and not funded %} + {{ Icon('alert') }} + Funded period ends + + + {% endif %}
Start a New Task Order
@@ -125,7 +146,7 @@ {% endif %} {% if active_task_orders %} - {{ TaskOrderList(active_task_orders, label='success') }} + {{ TaskOrderList(active_task_orders, label='success', funded=funded) }}
{{ total_balance | dollars }} @@ -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 %}
diff --git a/tests/routes/portfolios/test_task_orders.py b/tests/routes/portfolios/test_task_orders.py index 0501d9a6..6bb44b5e 100644 --- a/tests/routes/portfolios/test_task_orders.py +++ b/tests/routes/portfolios/test_task_orders.py @@ -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):