Merge pull request #544 from dod-ccpo/task-order-listing
Portfolio funding screen
This commit is contained in:
commit
a5314b50c7
1
Pipfile
1
Pipfile
@ -38,6 +38,7 @@ pytest-env = "*"
|
||||
pytest-cov = "*"
|
||||
selenium = "*"
|
||||
honcho = "*"
|
||||
blinker = "*"
|
||||
|
||||
[requires]
|
||||
python_version = "3.6.6"
|
||||
|
73
Pipfile.lock
generated
73
Pipfile.lock
generated
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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>")
|
||||
|
103
js/components/tables/task_order_list.js
Normal file
103
js/components/tables/task_order_list.js
Normal 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>'
|
||||
}
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
) }}
|
||||
|
@ -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 doesn’t 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 doesn’t 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 %}
|
||||
|
62
tests/routes/portfolios/test_task_orders.py
Normal file
62
tests/routes/portfolios/test_task_orders.py
Normal 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
16
tests/utils.py
Normal 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)
|
@ -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:
|
||||
|
Loading…
x
Reference in New Issue
Block a user