Merge pull request #544 from dod-ccpo/task-order-listing

Portfolio funding screen
This commit is contained in:
patricksmithdds 2019-01-16 11:48:48 -05:00 committed by GitHub
commit a5314b50c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 507 additions and 61 deletions

View File

@ -38,6 +38,7 @@ pytest-env = "*"
pytest-cov = "*"
selenium = "*"
honcho = "*"
blinker = "*"
[requires]
python_version = "3.6.6"

73
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "339ade228d14f03a31061a114103a5f61686b6efe812a1e0295268e08c18f149"
"sha256": "9f750c048a57b4494e2f4e53711a52a8e9f6ae3f6bbdb5d49491510ca105f40f"
},
"pipfile-spec": 6,
"requires": {
@ -18,10 +18,10 @@
"default": {
"alembic": {
"hashes": [
"sha256:e9ffdece0eece55f4108b14b6b0f29ffc730d58e28446a434fe41a1cc5c5f266"
"sha256:35660f7e6159288e2be111126be148ef04cbf7306da73c8b8bd4400837bb08e3"
],
"index": "pypi",
"version": "==1.0.5"
"version": "==1.0.6"
},
"apache-libcloud": {
"hashes": [
@ -499,6 +499,13 @@
"index": "pypi",
"version": "==18.9b0"
},
"blinker": {
"hashes": [
"sha256:471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6"
],
"index": "pypi",
"version": "==1.4"
},
"click": {
"hashes": [
"sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13",
@ -775,10 +782,10 @@
},
"pluggy": {
"hashes": [
"sha256:447ba94990e8014ee25ec853339faf7b0fc8050cdc3289d4d71f7f410fb90095",
"sha256:bde19360a8ec4dfd8a20dcb811780a30998101f078fc7ded6162f0076f50508f"
"sha256:8ddc32f03971bfdf900a81961a48ccf2fb677cf7715108f85295c67405798616",
"sha256:980710797ff6a041e9a73a5787804f848996ecaa6f8a1b1e08224a5894f2074a"
],
"version": "==0.8.0"
"version": "==0.8.1"
},
"prompt-toolkit": {
"hashes": [
@ -827,11 +834,11 @@
},
"pytest-cov": {
"hashes": [
"sha256:513c425e931a0344944f84ea47f3956be0e416d95acbd897a44970c8d926d5d7",
"sha256:e360f048b7dae3f2f2a9a4d067b2dd6b6a015d384d1577c994a43f3f7cbad762"
"sha256:0ab664b25c6aa9716cbf203b17ddb301932383046082c081b9848a0edf5add33",
"sha256:230ef817450ab0699c6cc3c9c8f7a829c34674456f2ed8df1fe1d39780f7c87f"
],
"index": "pypi",
"version": "==2.6.0"
"version": "==2.6.1"
},
"pytest-env": {
"hashes": [
@ -924,30 +931,30 @@
},
"typed-ast": {
"hashes": [
"sha256:0555eca1671ebe09eb5f2176723826f6f44cca5060502fea259de9b0e893ab53",
"sha256:0ca96128ea66163aea13911c9b4b661cb345eb729a20be15c034271360fc7474",
"sha256:16ccd06d614cf81b96de42a37679af12526ea25a208bce3da2d9226f44563868",
"sha256:1e21ae7b49a3f744958ffad1737dfbdb43e1137503ccc59f4e32c4ac33b0bd1c",
"sha256:37670c6fd857b5eb68aa5d193e14098354783b5138de482afa401cc2644f5a7f",
"sha256:46d84c8e3806619ece595aaf4f37743083f9454c9ea68a517f1daa05126daf1d",
"sha256:5b972bbb3819ece283a67358103cc6671da3646397b06e7acea558444daf54b2",
"sha256:6306ffa64922a7b58ee2e8d6f207813460ca5a90213b4a400c2e730375049246",
"sha256:6cb25dc95078931ecbd6cbcc4178d1b8ae8f2b513ae9c3bd0b7f81c2191db4c6",
"sha256:7e19d439fee23620dea6468d85bfe529b873dace39b7e5b0c82c7099681f8a22",
"sha256:7f5cd83af6b3ca9757e1127d852f497d11c7b09b4716c355acfbebf783d028da",
"sha256:81e885a713e06faeef37223a5b1167615db87f947ecc73f815b9d1bbd6b585be",
"sha256:94af325c9fe354019a29f9016277c547ad5d8a2d98a02806f27a7436b2da6735",
"sha256:b1e5445c6075f509d5764b84ce641a1535748801253b97f3b7ea9d948a22853a",
"sha256:cb061a959fec9a514d243831c514b51ccb940b58a5ce572a4e209810f2507dcf",
"sha256:cc8d0b703d573cbabe0d51c9d68ab68df42a81409e4ed6af45a04a95484b96a5",
"sha256:da0afa955865920edb146926455ec49da20965389982f91e926389666f5cf86a",
"sha256:dc76738331d61818ce0b90647aedde17bbba3d3f9e969d83c1d9087b4f978862",
"sha256:e7ec9a1445d27dbd0446568035f7106fa899a36f55e52ade28020f7b3845180d",
"sha256:f741ba03feb480061ab91a465d1a3ed2d40b52822ada5b4017770dfcb88f839f",
"sha256:fe800a58547dd424cd286b7270b967b5b3316b993d86453ede184a17b5a6b17d"
"sha256:023625bfa9359e29bd6e24cac2a4503495b49761d48a5f1e38333fc4ac4d93fe",
"sha256:07591f7a5fdff50e2e566c4c1e9df545c75d21e27d98d18cb405727ed0ef329c",
"sha256:153e526b0f4ffbfada72d0bb5ffe8574ba02803d2f3a9c605c8cf99dfedd72a2",
"sha256:3ad2bdcd46a4a1518d7376e9f5016d17718a9ed3c6a3f09203d832f6c165de4a",
"sha256:3ea98c84df53ada97ee1c5159bb3bc784bd734231235a1ede14c8ae0775049f7",
"sha256:51a7141ccd076fa561af107cfb7a8b6d06a008d92451a1ac7e73149d18e9a827",
"sha256:52c93cd10e6c24e7ac97e8615da9f224fd75c61770515cb323316c30830ddb33",
"sha256:6344c84baeda3d7b33e157f0b292e4dd53d05ddb57a63f738178c01cac4635c9",
"sha256:64699ca1b3bd5070bdeb043e6d43bc1d0cebe08008548f4a6bee782b0ecce032",
"sha256:74903f2e56bbffe29282ef8a5487d207d10be0f8513b41aff787d954a4cf91c9",
"sha256:7891710dba83c29ee2bd51ecaa82f60f6bede40271af781110c08be134207bf2",
"sha256:91976c56224e26c256a0de0f76d2004ab885a29423737684b4f7ebdd2f46dde2",
"sha256:9bad678a576ecc71f25eba9f1e3fd8d01c28c12a2834850b458428b3e855f062",
"sha256:b4726339a4c180a8b6ad9d8b50d2b6dc247e1b79b38fe2290549c98e82e4fd15",
"sha256:ba36f6aa3f8933edf94ea35826daf92cbb3ec248b89eccdc053d4a815d285357",
"sha256:bbc96bde544fd19e9ef168e4dfa5c3dfe704bfa78128fa76f361d64d6b0f731a",
"sha256:c0c927f1e44469056f7f2dada266c79b577da378bbde3f6d2ada726d131e4824",
"sha256:c0f9a3708008aa59f560fa1bd22385e05b79b8e38e0721a15a8402b089243442",
"sha256:f0bf6f36ff9c5643004171f11d2fdc745aa3953c5aacf2536a0685db9ceb3fb1",
"sha256:f5be39a0146be663cbf210a4d95c3c58b2d7df7b043c9047c5448e358f0550a2",
"sha256:fcd198bf19d9213e5cbf2cde2b9ef20a9856e716f76f9476157f90ae6de06cc6"
],
"markers": "python_version < '3.7' and implementation_name == 'cpython'",
"version": "==1.1.1"
"version": "==1.2.0"
},
"urllib3": {
"hashes": [
@ -978,9 +985,9 @@
},
"wrapt": {
"hashes": [
"sha256:d4d560d479f2c21e1b5443bbd15fe7ec4b37fe7e53d335d3b9b0a7b1226fe3c6"
"sha256:e03f19f64d81d0a3099518ca26b04550026f131eced2e76ced7b85c6b8d32128"
],
"version": "==1.10.11"
"version": "==1.11.0"
}
}
}

View File

@ -88,12 +88,21 @@ class TaskOrder(Base, mixins.TimestampsMixin):
else:
return Status.PENDING
@property
def display_status(self):
return self.status.value
@property
def budget(self):
return sum(
filter(None, [self.clin_01, self.clin_02, self.clin_03, self.clin_04])
)
@property
def balance(self):
# TODO: somehow calculate the remaining balance. For now, assume $0 spent
return self.budget
@property
def portfolio_name(self):
return self.portfolio.name

View File

@ -1,14 +1,57 @@
from flask import g, render_template
from collections import defaultdict
from operator import itemgetter
from flask import g, render_template, url_for
from . import portfolios_bp
from atst.domain.task_orders import TaskOrders
from atst.domain.portfolios import Portfolios
from atst.models.task_order import Status as TaskOrderStatus
@portfolios_bp.route("/portfolios/<portfolio_id>/task_orders")
def portfolio_task_orders(portfolio_id):
def portfolio_funding(portfolio_id):
portfolio = Portfolios.get(g.current_user, portfolio_id)
return render_template("portfolios/task_orders/index.html", portfolio=portfolio)
task_orders_by_status = defaultdict(list)
serialize_task_order = lambda task_order: {
key: getattr(task_order, key)
for key in [
"id",
"budget",
"time_created",
"start_date",
"end_date",
"display_status",
"balance",
]
}
for task_order in portfolio.task_orders:
serialized_task_order = serialize_task_order(task_order)
serialized_task_order["url"] = url_for(
"portfolios.view_task_order",
portfolio_id=portfolio.id,
task_order_id=task_order.id,
)
task_orders_by_status[task_order.status].append(serialized_task_order)
active_task_orders = task_orders_by_status.get(TaskOrderStatus.ACTIVE, [])
funding_end_date = (
sorted(active_task_orders, key=itemgetter("end_date"))[-1]["end_date"]
if active_task_orders
else None
)
total_balance = sum([task_order["balance"] for task_order in active_task_orders])
return render_template(
"portfolios/task_orders/index.html",
portfolio=portfolio,
pending_task_orders=task_orders_by_status.get(TaskOrderStatus.PENDING, []),
active_task_orders=active_task_orders,
expired_task_orders=task_orders_by_status.get(TaskOrderStatus.EXPIRED, []),
funding_end_date=funding_end_date,
total_balance=total_balance,
)
@portfolios_bp.route("/portfolios/<portfolio_id>/task_order/<task_order_id>")

View File

@ -0,0 +1,103 @@
import { set } from 'vue/dist/vue'
import { compose, sortBy, reverse, indexBy, prop, toLower } from 'ramda'
import { formatDollars } from '../../lib/dollars'
import localDatetime from '../../components/local_datetime'
const sort = (sortInfo, members) => {
if (sortInfo.columnName === '') {
return members
} else {
const sortColumn = sortInfo.columns[sortInfo.columnName]
const sortedMembers = sortColumn.sortFunc(sortColumn.attr, members)
return sortInfo.isAscending ?
sortedMembers :
reverse(sortedMembers)
}
}
export default {
name: 'task-order-list',
props: {
data: Array,
expired: Boolean
},
components: {
localDatetime
},
data: function () {
const alphabeticalSort = (attr, members) => {
const lowercaseProp = compose(toLower, prop(attr))
return sortBy(lowercaseProp, members)
}
const numericSort = (attr, members) => sortBy(prop(attr), members)
const columns = [
{
displayName: 'Status',
attr: 'display_status',
},
{
displayName: 'Period of Performance',
attr: 'start_date',
sortFunc: numericSort,
width: "50%"
},
{
displayName: 'Initial Value',
attr: 'budget',
class: "table-cell--align-right",
sortFunc: numericSort
},
{
displayName: this.expired ? 'Expired Balance' : 'Balance',
attr: 'budget',
class: "table-cell--align-right",
sortFunc: numericSort
},
{
displayName: ''
}
]
const defaultSortColumn = 'Period of Performance'
return {
sortInfo: {
columnName: defaultSortColumn,
isAscending: false,
columns: indexBy(prop('displayName'), columns)
}
}
},
computed: {
taskOrders: function () {
return sort(this.sortInfo, this.data)
}
},
methods: {
updateSort: function(columnName) {
// clicking a column twice toggles ascending / descending
if (columnName === this.sortInfo.columnName) {
this.sortInfo.isAscending = !this.sortInfo.isAscending
}
this.sortInfo.columnName = columnName
},
getColumns: function() {
return Object.values(this.sortInfo.columns)
},
formatDollars: function (value) {
return formatDollars(value, false)
}
},
template: '<div></div>'
}

View File

@ -21,6 +21,7 @@ import Modal from './mixins/modal'
import selector from './components/selector'
import BudgetChart from './components/charts/budget_chart'
import SpendTable from './components/tables/spend_table'
import TaskOrderList from './components/tables/task_order_list.js'
import CcpoApproval from './components/forms/ccpo_approval'
import MembersList from './components/members_list'
import LocalDatetime from './components/local_datetime'
@ -48,6 +49,7 @@ const app = new Vue({
selector,
BudgetChart,
SpendTable,
TaskOrderList,
CcpoApproval,
MembersList,
LocalDatetime,

View File

@ -110,16 +110,33 @@ def seed_db():
)
db.session.add(invitation)
[expired_start, expired_end] = sorted(
[old_expired_start, expired_start, expired_end] = 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),
]
)
active_start = expired_end
active_end = random_future_date(year_min=1, year_max=1)
[
first_active_start,
second_active_start,
first_active_end,
second_active_end,
] = sorted(
[
expired_end,
random_past_date(year_max=1, year_min=1),
random_future_date(year_min=0, year_max=1),
random_future_date(year_min=1, year_max=1),
]
)
date_ranges = [(expired_start, expired_end), (active_start, active_end)]
date_ranges = [
(old_expired_start, expired_start),
(expired_start, expired_end),
(first_active_start, first_active_end),
(second_active_start, second_active_end),
]
for (start_date, end_date) in date_ranges:
task_order = TaskOrderFactory.build(
start_date=start_date,

View File

@ -29,3 +29,77 @@
}
}
}
.portfolio-funding {
.portfolio-funding__header {
padding: 0;
margin: 0 $gap;
align-items: center;
.portfolio-funding__header--funded-through {
padding: 2 * $gap;
flex-grow: 1;
text-align: left;
font-weight: bold;
}
.funded {
color: $color-green;
.icon {
@include icon-color($color-green);
}
}
}
.pending-task-order {
background-color: $color-gold-lightest;
align-items: center;
margin: 0;
padding: 2 * $gap;
dt {
font-weight: bold;
}
dd {
margin-left: 0;
}
.label {
margin-right: 2 * $gap;
}
.pending-task-order__started {
flex-grow: 1;
}
.pending-task-order__value {
text-align: right;
}
}
.view-task-order-link {
margin-left: $gap * 2;
}
.portfolio-total-balance {
margin-top: -$gap;
.row {
flex-direction: row-reverse;
margin: 2 * $gap 0;
padding-right: 14rem;
.label {
margin: 0 2 * $gap;
}
}
}
table {
td.unused-balance {
color: $color-red;
}
}
}

View File

@ -41,8 +41,8 @@
{% endif %}
{{ SidenavItem(
("navigation.portfolio_navigation.task_orders" | translate),
href=url_for("portfolios.portfolio_task_orders", portfolio_id=portfolio.id),
("navigation.portfolio_navigation.portfolio_funding" | translate),
href=url_for("portfolios.portfolio_funding", portfolio_id=portfolio.id),
active=request.url_rule.rule.startswith('/portfolios/<portfolio_id>/task_order'),
subnav=None
) }}

View File

@ -1,30 +1,142 @@
{% from "components/empty_state.html" import EmptyState %}
{% from "components/icon.html" import Icon %}
{% extends "portfolios/base.html" %}
{% block portfolio_content %}
{% if not portfolio.task_orders %}
{% 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") }}
</a>
{% endmacro %}
{{ EmptyState(
'This portfolio doesnt have any task orders yet.',
action_label='Add a New Task Order',
action_href=url_for('task_orders.new', screen=1, portfolio_id=portfolio.id),
icon='cloud',
) }}
{% macro TaskOrderList(task_orders, label='success', expired=False) %}
<task-order-list
inline-template
v-bind:data='{{ task_orders | tojson }}'
v-bind:expired='{{ 'true' if expired else 'false' }}'
v-cloak
>
<div class='responsive-table-wrapper'>
<table v-cloak>
<thead>
<tr>
<th v-for="col in getColumns()" @click="updateSort(col.displayName)" :width="col.width" :class="col.class" scope="col">
!{ col.displayName }
<template v-if="col.sortFunc">
<span v-if="col.displayName === sortInfo.columnName && sortInfo.isAscending">
{{ Icon("caret_down") }}
</span>
<span v-if="col.displayName === sortInfo.columnName && !sortInfo.isAscending">
{{ Icon("caret_up") }}
</span>
</template>
</th>
</tr>
</thead>
{% else %}
<tbody>
<tr v-for='taskOrder in taskOrders' :key="taskOrder.id">
<td>
<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">
</local-datetime>
</td>
<td class="table-cell--align-right">
<span v-html='formatDollars(taskOrder.budget)'></span>
</td>
<td v-bind:class="{ 'table-cell--align-right': true, 'unused-balance': expired && taskOrder.balance > 0 }">
<span v-html='formatDollars(taskOrder.balance)'></span>
</td>
<td>
<a v-bind:href="taskOrder.url" class="icon-link view-task-order-link">
<span>View</span>
{{ Icon("caret_right") }}
</a>
</td>
</tr>
</tbody>
</table>
</div>
</task-order-list>
{% endmacro %}
<ul>
{% for task_order in portfolio.task_orders %}
<li class='block-list__item'>
<a href='{{ url_for("portfolios.view_task_order", portfolio_id=portfolio.id, task_order_id=task_order.id)}}'>
<span>{{ task_order.start_date }} - {{ task_order.end_date }}</span>
</a>
</li>
{% endfor %}
</ul>
<div class="portfolio-funding">
{% endif %}
<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 %}
{{ Icon('ok') }}
Funded through
<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>
</div>
{% for task_order in pending_task_orders %}
<div class='panel'>
<div class='panel__content pending-task-order row'>
<span class='label label--warning'>Pending</span>
<div class="pending-task-order__started col">
<dt>Started</dt>
<dd>
<local-datetime
timestamp="{{ task_order.time_created }}"
format="M/D/YYYY">
</local-datetime>
</dd>
</div>
<div class="pending-task-order__value col">
<dt>Value</dt>
<dd>{{ task_order.budget | dollars }}</dd>
</div>
{{ ViewLink(task_order) }}
</div>
</div>
{% endfor %}
{% if not active_task_orders and not pending_task_orders %}
{{ EmptyState(
'This portfolio doesnt have any active or pending task orders.',
action_label='Add a New Task Order',
action_href=url_for('task_orders.new', screen=1, portfolio_id=portfolio.id),
icon='cloud',
) }}
{% endif %}
{% if active_task_orders %}
{{ TaskOrderList(active_task_orders, label='success') }}
<div class='panel portfolio-total-balance'>
<div class='panel__content row'>
<span>{{ total_balance | dollars }}</span>
<span class='label label--success'>Total Active Balance</span>
</div>
</div>
{% endif %}
{% if expired_task_orders %}
{{ TaskOrderList(expired_task_orders, label='', expired=True) }}
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,62 @@
from flask import url_for
import pytest
from tests.factories import (
PortfolioFactory,
TaskOrderFactory,
random_future_date,
random_past_date,
)
from tests.utils import captured_templates
class TestPortfolioFunding:
def test_unfunded_portfolio(self, app, user_session):
portfolio = PortfolioFactory.create()
user_session(portfolio.owner)
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 None
assert context["total_balance"] == 0
assert context["pending_task_orders"] == []
assert context["active_task_orders"] == []
assert context["expired_task_orders"] == []
def test_funded_portfolio(self, app, user_session):
portfolio = PortfolioFactory.create()
user_session(portfolio.owner)
pending_to = TaskOrderFactory.create(portfolio=portfolio)
active_to1 = TaskOrderFactory.create(
portfolio=portfolio,
start_date=random_past_date(),
end_date=random_future_date(),
number="42",
)
active_to2 = TaskOrderFactory.create(
portfolio=portfolio,
start_date=random_past_date(),
end_date=random_future_date(),
number="43",
)
end_date = (
active_to1.end_date
if active_to1.end_date > active_to2.end_date
else active_to2.end_date
)
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 end_date
assert context["total_balance"] == active_to1.budget + active_to2.budget

16
tests/utils.py Normal file
View File

@ -0,0 +1,16 @@
from flask import template_rendered
from contextlib import contextmanager
@contextmanager
def captured_templates(app):
recorded = []
def record(sender, template, context, **extra):
recorded.append((template, context))
template_rendered.connect(record, app)
try:
yield recorded
finally:
template_rendered.disconnect(record, app)

View File

@ -254,7 +254,7 @@ navigation:
activity_log: Activity Log
members: Members
applications: Applications
task_orders: Task Orders
portfolio_funding: Funding
portfolio_settings: Portfolio Settings
requests:
_new: