Merge pull request #1199 from dod-ccpo/reporting-refactor-part-1

Reporting refactor part 1
This commit is contained in:
graham-dds 2019-11-25 16:37:49 -05:00 committed by GitHub
commit 92ae191f37
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 463 additions and 932 deletions

View File

@ -1,6 +1,7 @@
from itertools import groupby
from collections import OrderedDict
from atst.utils.localization import translate
import pendulum
from decimal import Decimal
class ReportingInterface:
@ -35,14 +36,16 @@ def generate_sample_dates(_max=8):
current = pendulum.now()
sample_dates = []
for _i in range(_max):
current = current.subtract(months=1)
sample_dates.append(current.strftime("%m/%Y"))
current = current.subtract(months=1)
reversed(sample_dates)
return sample_dates
class MockReportingProvider(ReportingInterface):
MOCK_PERCENT_EXPENDED_FUNDS = 0.75
FIXTURE_MONTHS = generate_sample_dates()
MONTHLY_SPEND_BY_ENVIRONMENT = {
@ -163,25 +166,8 @@ class MockReportingProvider(ReportingInterface):
"FM_Prod": {FIXTURE_MONTHS[0]: 5686},
}
CUMULATIVE_BUDGET_A_WING = {
FIXTURE_MONTHS[7]: {"spend": 9857, "cumulative": 9857},
FIXTURE_MONTHS[6]: {"spend": 7881, "cumulative": 17738},
FIXTURE_MONTHS[5]: {"spend": 14010, "cumulative": 31748},
FIXTURE_MONTHS[4]: {"spend": 43510, "cumulative": 75259},
FIXTURE_MONTHS[3]: {"spend": 41725, "cumulative": 116_984},
FIXTURE_MONTHS[2]: {"spend": 41328, "cumulative": 158_312},
FIXTURE_MONTHS[1]: {"spend": 47491, "cumulative": 205_803},
FIXTURE_MONTHS[0]: {"spend": 36028, "cumulative": 241_831},
}
CUMULATIVE_BUDGET_B_WING = {
FIXTURE_MONTHS[1]: {"spend": 4838, "cumulative": 4838},
FIXTURE_MONTHS[0]: {"spend": 14500, "cumulative": 19338},
}
REPORT_FIXTURE_MAP = {
"A-Wing": {
"cumulative": CUMULATIVE_BUDGET_A_WING,
"applications": [
MockApplication("LC04", ["Integ", "PreProd", "Prod"]),
MockApplication("SF18", ["Integ", "PreProd", "Prod"]),
@ -202,7 +188,6 @@ class MockReportingProvider(ReportingInterface):
"budget": 500_000,
},
"B-Wing": {
"cumulative": CUMULATIVE_BUDGET_B_WING,
"applications": [
MockApplication("NP02", ["Integ", "PreProd", "Prod"]),
MockApplication("FM", ["Integ", "Prod"]),
@ -211,28 +196,6 @@ class MockReportingProvider(ReportingInterface):
},
}
def _sum_monthly_spend(self, data):
return sum(
[
spend
for application in data
for env in application.environments
for spend in self.MONTHLY_SPEND_BY_ENVIRONMENT[env.id].values()
]
)
def get_budget(self, portfolio):
if portfolio.name in self.REPORT_FIXTURE_MAP:
return self.REPORT_FIXTURE_MAP[portfolio.name]["budget"]
return 0
def get_total_spending(self, portfolio):
if portfolio.name in self.REPORT_FIXTURE_MAP:
return self._sum_monthly_spend(
self.REPORT_FIXTURE_MAP[portfolio.name]["applications"]
)
return 0
def _rollup_application_totals(self, data):
application_totals = {}
for application, environments in data.items():
@ -270,7 +233,14 @@ class MockReportingProvider(ReportingInterface):
{ "01/2018": 79.85, "02/2018": 86.54 }
"""
return self.MONTHLY_SPEND_BY_ENVIRONMENT.get(environment_id, {})
environment_monthly_totals = self.MONTHLY_SPEND_BY_ENVIRONMENT.get(
environment_id, {}
).copy()
environment_monthly_totals["total_spend_to_date"] = sum(
monthly_total for monthly_total in environment_monthly_totals.values()
)
return environment_monthly_totals
def monthly_totals(self, portfolio):
"""Return month totals rolled up by environment, application, and portfolio.
@ -309,19 +279,46 @@ class MockReportingProvider(ReportingInterface):
"portfolio": portfolio_totals,
}
def cumulative_budget(self, portfolio):
def get_obligated_funds_by_JEDI_clin(self, portfolio):
"""
Returns a dictionary of obligated funds and spending per JEDI CLIN
{
JEDI_CLIN: {
obligated_funds,
expended_funds
}
}
"""
if portfolio.name in self.REPORT_FIXTURE_MAP:
budget_months = self.REPORT_FIXTURE_MAP[portfolio.name]["cumulative"]
else:
budget_months = {}
return_dict = {}
for jedi_clin, clins in groupby(
portfolio.active_clins, lambda clin: clin.jedi_clin_type
):
obligated_funds = sum(clin.obligated_amount for clin in clins)
return_dict[translate(f"JEDICLINType.{jedi_clin.value}")] = {
"obligated_funds": obligated_funds,
"expended_funds": (
obligated_funds * Decimal(self.MOCK_PERCENT_EXPENDED_FUNDS)
),
}
return return_dict
return {}
end = pendulum.now()
start = end.subtract(months=12)
period = pendulum.period(start, end)
all_months = OrderedDict()
for t in period.range("months"):
month_str = "{month:02d}/{year}".format(month=t.month, year=t.year)
all_months[month_str] = budget_months.get(month_str, None)
return {"months": all_months}
def get_expired_task_orders(self, portfolio):
return [
{
"id": task_order.id,
"number": task_order.number,
"period_of_performance": {
"start_date": task_order.start_date,
"end_date": task_order.end_date,
},
"total_obligated_funds": task_order.total_obligated_funds,
"expended_funds": (
task_order.total_obligated_funds
* Decimal(self.MOCK_PERCENT_EXPENDED_FUNDS)
),
}
for task_order in portfolio.task_orders
if task_order.is_expired
]

View File

@ -2,16 +2,14 @@ from flask import current_app
class Reports:
@classmethod
def portfolio_totals(cls, portfolio):
budget = current_app.csp.reports.get_budget(portfolio)
spent = current_app.csp.reports.get_total_spending(portfolio)
return {"budget": budget, "spent": spent}
@classmethod
def monthly_totals(cls, portfolio):
return current_app.csp.reports.monthly_totals(portfolio)
@classmethod
def cumulative_budget(cls, portfolio):
return current_app.csp.reports.cumulative_budget(portfolio)
def expired_task_orders(cls, portfolio):
return current_app.csp.reports.get_expired_task_orders(portfolio)
@classmethod
def obligated_funds_by_JEDI_clin(cls, portfolio):
return current_app.csp.reports.get_obligated_funds_by_JEDI_clin(portfolio)

View File

@ -1,6 +1,7 @@
from enum import Enum
from sqlalchemy import Column, Date, Enum as SQLAEnum, ForeignKey, Numeric, String
from sqlalchemy.orm import relationship
from datetime import date
from atst.models.base import Base
import atst.models.mixins as mixins
@ -61,3 +62,7 @@ class CLIN(Base, mixins.TimestampsMixin):
for c in self.__table__.columns
if c.name not in ["id"]
}
@property
def is_active(self):
return self.start_date <= date.today() <= self.end_date

View File

@ -65,6 +65,58 @@ class Portfolio(
def num_task_orders(self):
return len(self.task_orders)
@property
def active_clins(self):
return [
clin
for task_order in self.task_orders
for clin in task_order.clins
if clin.is_active
]
@property
def active_task_orders(self):
return [task_order for task_order in self.task_orders if task_order.is_active]
@property
def funding_duration(self):
"""
Return the earliest period of performance start date and latest period
of performance end date for all active task orders in a portfolio.
@return: (datetime.date or None, datetime.date or None)
"""
start_dates = (
task_order.start_date
for task_order in self.task_orders
if task_order.is_active
)
end_dates = (
task_order.end_date
for task_order in self.task_orders
if task_order.is_active
)
earliest_pop_start_date = min(start_dates, default=None)
latest_pop_end_date = max(end_dates, default=None)
return (earliest_pop_start_date, latest_pop_end_date)
@property
def days_to_funding_expiration(self):
"""
Returns the number of days between today and the lastest period performance
end date of all active Task Orders
"""
return max(
(
task_order.days_to_expiration
for task_order in self.task_orders
if task_order.is_active
),
default=0,
)
@property
def members(self):
return (

View File

@ -46,34 +46,24 @@ def create_portfolio():
def reports(portfolio_id):
portfolio = Portfolios.get(g.current_user, portfolio_id)
today = date.today()
month = http_request.args.get("month", today.month)
year = http_request.args.get("year", today.year)
current_month = date(int(year), int(month), 15)
current_month = date(int(today.year), int(today.month), 15)
prev_month = current_month - timedelta(days=28)
two_months_ago = prev_month - timedelta(days=28)
task_order = next(
(task_order for task_order in portfolio.task_orders if task_order.is_active),
None,
# wrapped in str() because the sum of obligated funds returns a Decimal object
total_portfolio_value = str(
sum(
task_order.total_obligated_funds
for task_order in portfolio.active_task_orders
)
)
expiration_date = task_order and task_order.end_date
if expiration_date:
remaining_difference = expiration_date - today
remaining_days = remaining_difference.days
else:
remaining_days = None
return render_template(
"portfolios/reports/index.html",
cumulative_budget=Reports.cumulative_budget(portfolio),
portfolio_totals=Reports.portfolio_totals(portfolio),
portfolio=portfolio,
total_portfolio_value=total_portfolio_value,
current_obligated_funds=Reports.obligated_funds_by_JEDI_clin(portfolio),
expired_task_orders=Reports.expired_task_orders(portfolio),
monthly_totals=Reports.monthly_totals(portfolio),
task_order=task_order,
current_month=current_month,
prev_month=prev_month,
two_months_ago=two_months_ago,
expiration_date=expiration_date,
remaining_days=remaining_days,
)

View File

@ -0,0 +1,14 @@
import ToggleMixin from '../mixins/toggle'
export default {
name: 'accordion',
mixins: [ToggleMixin],
props: {
defaultVisible: {
type: Boolean,
default: false,
},
},
}

View File

@ -1,189 +0,0 @@
import {
format,
isWithinRange,
addMonths,
isSameMonth,
getMonth,
} from 'date-fns'
import { abbreviateDollars, formatDollars } from '../../lib/dollars'
const TOP_OFFSET = 20
const BOTTOM_OFFSET = 70
const CHART_HEIGHT = 360
export default {
name: 'budget-chart',
props: {
currentMonth: String,
expirationDate: String,
months: Object,
budget: String,
},
data: function() {
const heightScale =
this.budget / (CHART_HEIGHT - TOP_OFFSET - BOTTOM_OFFSET)
return {
numMonths: 10,
focusedMonthPosition: 4,
height: CHART_HEIGHT,
heightScale,
budgetHeight: CHART_HEIGHT - BOTTOM_OFFSET - this.budget / heightScale,
baseHeight: CHART_HEIGHT - BOTTOM_OFFSET,
width: 0,
displayedMonths: [],
spendPath: '',
projectedPath: '',
displayBudget: formatDollars(parseFloat(this.budget)),
}
},
mounted: function() {
this._setDisplayedMonths()
this._setMetrics()
addEventListener('load', this._setMetrics)
addEventListener('resize', this._setMetrics)
},
methods: {
_setMetrics: function() {
this.width = this.$refs.panel.clientWidth
this.spendPath = ''
this.projectedPath = ''
let lastSpend = 0
let lastSpendPoint = ''
for (let i = 0; i < this.numMonths; i++) {
const {
metrics,
budget,
rollingAverage,
cumulativeTotal,
} = this.displayedMonths[i]
const blockWidth = this.width / this.numMonths
const blockX = blockWidth * i
const spend = budget && budget.spend ? budget.spend : rollingAverage
const barHeight = spend / this.heightScale
lastSpend = spend
const cumulativeY =
this.height - cumulativeTotal / this.heightScale - BOTTOM_OFFSET
const cumulativeX = blockX + blockWidth / 2
const cumulativePoint = `${cumulativeX} ${cumulativeY}`
this.displayedMonths[i].metrics = Object.assign(metrics, {
blockWidth,
blockX,
barHeight,
barWidth: 30,
barX: blockX + (blockWidth / 2 - 15),
barY: this.height - barHeight - BOTTOM_OFFSET,
cumulativeR: 2.5,
cumulativeY,
cumulativeX,
})
if (budget && budget.spend) {
this.spendPath += this.spendPath === '' ? 'M' : ' L'
this.spendPath += cumulativePoint
lastSpendPoint = cumulativePoint
} else if (lastSpendPoint !== '') {
this.projectedPath +=
this.projectedPath === '' ? `M${lastSpendPoint} L` : ' L'
this.projectedPath += cumulativePoint
}
}
},
_setDisplayedMonths: function() {
const [month, year] = this.currentMonth.split('/')
const [expYear, expMonth, expDate] = this.expirationDate.split('-') // assumes format 'YYYY-MM-DD'
const monthsRange = []
const monthsBack = this.focusedMonthPosition + 1
const monthsForward = this.numMonths - this.focusedMonthPosition - 1
// currently focused date
const current = new Date(year, month)
// starting date of the chart
const start = addMonths(current, -monthsBack)
// ending date of the chart
const end = addMonths(start, this.numMonths + 1)
// expiration date
const expires = new Date(expYear, expMonth - 1, expDate)
// is the expiration date within the displayed date range?
const expirationWithinRange = isWithinRange(expires, start, end)
let rollingAverage = 0
let cumulativeTotal = 0
for (let i = 0; i < this.numMonths; i++) {
const date = addMonths(start, i)
const dateMinusOne = addMonths(date, -1)
const dateMinusTwo = addMonths(date, -2)
const dateMinusThree = addMonths(date, -3)
const index = format(date, 'MM/YYYY')
const indexMinusOne = format(dateMinusOne, 'MM/YYYY')
const indexMinusTwo = format(dateMinusTwo, 'MM/YYYY')
const indexMinusThree = format(dateMinusThree, 'MM/YYYY')
const budget = this.months[index] || null
const spendAmount = budget ? budget.spend : rollingAverage
const spendMinusOne = this.months[indexMinusOne]
? this.months[indexMinusOne].spend
: rollingAverage
const spendMinusTwo = this.months[indexMinusTwo]
? this.months[indexMinusTwo].spend
: rollingAverage
const spendMinusThree = this.months[indexMinusThree]
? this.months[indexMinusThree].spend
: rollingAverage
const isExpirationMonth = isSameMonth(date, expires)
if (budget && budget.cumulative) {
cumulativeTotal = budget.cumulative
} else {
cumulativeTotal += spendAmount
}
rollingAverage =
(spendAmount + spendMinusOne + spendMinusTwo + spendMinusThree) / 4
monthsRange.push({
budget,
rollingAverage,
cumulativeTotal,
isExpirationMonth,
spendAmount: formatDollars(spendAmount),
abbreviatedSpend: abbreviateDollars(spendAmount),
cumulativeAmount: formatDollars(cumulativeTotal),
abbreviatedCumulative: abbreviateDollars(cumulativeTotal),
date: {
monthIndex: format(date, 'M'),
month: format(date, 'MMM'),
year: format(date, 'YYYY'),
},
showYear: isExpirationMonth || i === 0 || getMonth(date) === 0,
isHighlighted: this.currentMonth === index,
metrics: {
blockWidth: 0,
blockX: 0,
barHeight: 0,
barWidth: 0,
barX: 0,
barY: 0,
cumulativeY: 0,
cumulativeX: 0,
cumulativeR: 0,
},
})
}
this.displayedMonths = monthsRange
},
},
}

View File

@ -6,11 +6,9 @@ export default {
props: {
applications: Object,
portfolio: Object,
environments: Object,
currentMonthIndex: String,
prevMonthIndex: String,
twoMonthsAgoIndex: String,
},
data: function() {
@ -40,9 +38,5 @@ export default {
formatDollars: function(value) {
return formatDollars(value, false)
},
round: function(value) {
return Math.round(value)
},
},
}

View File

@ -17,7 +17,6 @@ import ApplicationEnvironments from './components/forms/new_application/environm
import MultiStepModalForm from './components/forms/multi_step_modal_form'
import uploadinput from './components/upload_input'
import Modal from './mixins/modal'
import BudgetChart from './components/charts/budget_chart'
import SpendTable from './components/tables/spend_table'
import LocalDatetime from './components/local_datetime'
import { isNotInVerticalViewport } from './lib/viewport'
@ -30,6 +29,7 @@ import SemiCollapsibleText from './components/semi_collapsible_text'
import ToForm from './components/forms/to_form'
import ClinFields from './components/clin_fields'
import PopDateRange from './components/pop_date_range'
import Accordion from './components/accordion'
Vue.config.productionTip = false
@ -40,6 +40,7 @@ Vue.mixin(Modal)
const app = new Vue({
el: '#app-root',
components: {
Accordion,
dodlogin,
toggler,
optionsinput,
@ -47,7 +48,6 @@ const app = new Vue({
textinput,
checkboxinput,
ApplicationEnvironments,
BudgetChart,
SpendTable,
LocalDatetime,
MultiStepModalForm,

View File

@ -33,7 +33,6 @@
@import "components/progress_menu.scss";
@import "components/forms";
@import "components/selector";
@import "components/budget_chart";
@import "components/audit_log";
@import "components/usa_banner";
@import "components/dod_login_notice.scss";

View File

@ -1,169 +0,0 @@
.budget-chart {
svg {
display: block;
.filter__text-background {
feFlood {
flood-color: $color-white;
flood-opacity: 1;
}
&--highlighted {
feFlood {
flood-color: $color-aqua-lightest;
flood-opacity: 1;
}
}
}
a {
text-decoration: none;
&:focus {
outline: none;
stroke: $color-gray-light;
stroke-dasharray: 2px;
}
&:hover {
.filter__text-background {
feFlood {
flood-color: $color-aqua-lightest;
flood-opacity: 1;
}
}
}
}
}
&__header {
border-bottom: 1px solid $color-gray-light;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
&__legend {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
dl {
margin: 0 0 0 ($gap * 2);
> div {
margin: 0;
display: flex;
flex-direction: row-reverse;
align-items: center;
dt {
@include small-label;
}
}
}
&__dot {
width: $gap;
height: $gap;
border-radius: $gap / 2;
margin: 0 $gap;
&.accumulated {
background-color: $color-gold;
}
&.monthly {
background-color: $color-blue;
}
}
&__line {
height: 2px;
width: $gap * 3;
border-top-width: 2px;
border-top-style: dashed;
margin: $gap;
&.spend {
border-color: $color-blue;
}
&.accumulated {
border-color: $color-gold;
}
}
}
&__block {
fill: transparent;
cursor: pointer;
&--highlighted {
fill: rgba($color-aqua, 0.15);
}
&--is-expiration {
border-left: 2px dotted $color-gray;
}
&:hover {
fill: rgba($color-aqua, 0.15);
}
}
&__bar {
fill: $color-blue;
&--projected {
fill: transparent;
stroke-width: 2px;
stroke: $color-blue;
stroke-dasharray: 4px;
}
}
&__expiration-line {
stroke-width: 2px;
stroke: $color-gray-light;
stroke-dasharray: 4px;
}
&__cumulative {
&__dot {
fill: $color-gold;
}
}
&__projected-path {
stroke-width: 1px;
stroke: $color-gold;
stroke-dasharray: 4px;
fill: none;
}
&__spend-path {
stroke-width: 1px;
stroke: $color-gold;
fill: none;
}
&__budget-line {
stroke-width: 2px;
stroke: $color-gray-light;
stroke-dasharray: 4px;
}
&__label {
@include small-label;
fill: $color-gray;
pointer-events: none;
&--strong {
fill: $color-black;
}
}
}

View File

@ -0,0 +1,23 @@
{% macro Accordion(title, id, heading_level="h2") %}
<accordion inline-template>
<div>
<{{heading_level}}>
<button
v-on:click="toggle($event)"
class="usa-accordion-button"
aria-controls="{{ id }}"
v-bind:aria-expanded= "isVisible ? 'true' : 'false'"
>
{{ title }}
</button>
</{{heading_level}}>
<div
id="{{ id }}"
class="usa-accordion-content"
v-bind:aria-hidden="isVisible ? 'false' : 'true'"
>
{{ caller() }}
</div>
</div>
</accordion>
{% endmacro %}

View File

@ -0,0 +1,82 @@
{% from "components/empty_state.html" import EmptyState %}
{% from "components/icon.html" import Icon %}
<div>
<h2>Funds Expended per Application and Environment</h2>
{% set current_month_index = current_month.strftime('%m/%Y') %}
{% set prev_month_index = prev_month.strftime('%m/%Y') %}
{% if not portfolio.applications %}
{% set can_create_applications = user_can(permissions.CREATE_APPLICATION) %}
{% set message = ('portfolios.reports.empty_state.sub_message.can_create_applications' | translate)
if can_create_applications
else ('portfolios.reports.empty_state.sub_message.cannot_create_applications' | translate)
%}
{{ EmptyState(
('portfolios.reports.empty_state.message' | translate),
action_label= ('portfolios.reports.empty_state.action_label' | translate) if can_create_applications else None,
action_href=url_for('applications.create_new_application_step_1', portfolio_id=portfolio.id) if can_create_applications else None,
icon='chart',
sub_message=message,
add_perms=can_create_applications
) }}
{% else %}
<spend-table
v-bind:applications='{{ monthly_totals['applications'] | tojson }}'
v-bind:environments='{{ monthly_totals['environments'] | tojson }}'
current-month-index='{{ current_month_index }}'
prev-month-index='{{ prev_month_index }}'
inline-template>
<div class="responsive-table-wrapper">
<table class="atat-table">
<thead>
<tr>
<th>Applications and Environments</th>
<th class="table-cell--align-right">Current Month</th>
<th class="table-cell--align-right">Last Month</th>
<th class="table-cell--align-right">Total Spent</th>
</tr>
</thead>
<tbody>
<template v-for='(application, name) in applicationsState'>
<tr>
<td>
<button v-on:click='toggle($event, name)' class='icon-link icon-link--large'>
<span v-html='name'></span>
<template v-if='application.isVisible'>{{ Icon('caret_down') }}</template>
<template v-else>{{ Icon('caret_up') }}</template>
</button>
</td>
<td class="table-cell--align-right">
<span v-html='formatDollars(application[currentMonthIndex] || 0)'></span>
</td>
<td class="table-cell--align-right">
<span v-html='formatDollars(application[prevMonthIndex] || 0)'></span>
</td>
<td class="table-cell--align-right">
<span v-html='formatDollars(application["total_spend_to_date"])'></span>
</td>
</tr>
<tr v-for='(environment, envName) in environments[name]' v-show='application.isVisible'>
<td>
<span v-html='envName'></span>
</td>
<td class="table-cell--align-right">
<span v-html='formatDollars(environment[currentMonthIndex] || 0)'></span>
</td>
<td class="table-cell--align-right">
<span v-html='formatDollars(environment[prevMonthIndex] || 0)'></span>
</td>
<td class="table-cell--align-right">
<span v-html='formatDollars(environment["total_spend_to_date"])'></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</spend-table>
{% endif %}
</div>

View File

@ -0,0 +1,34 @@
{% from "components/accordion.html" import Accordion %}
<section>
<div class="usa-accordion">
{% call Accordion("Expired Task Orders", "expired_task_orders", "h3") %}
{% for task_order in expired_task_orders %}
<a href="{{ url_for("task_orders.review_task_order", task_order_id=task_order["id"]) }}">
Task Order {{ task_order["number"] }}
</a>
<div>
<p>Period of Performance</p>
<p>
{{ task_order["period_of_performance"].start_date | formattedDate(formatter="%B %d, %Y") }}
-
{{ task_order["period_of_performance"].end_date | formattedDate(formatter="%B %d, %Y") }}
</p>
</div>
<div>
<p>Total Obligated</p>
<p>{{ task_order["total_obligated_funds"] | dollars }}</p>
</div>
<div>
<p>Total Expended</p>
<p>{{ task_order["expended_funds"] | dollars }}</p>
</div>
<div>
<p>Total Unused</p>
<p>{{ (task_order["total_obligated_funds"] - task_order["expended_funds"]) | dollars }}</p>
</div>
{% endfor %}
{% endcall %}
</div>
</section>

View File

@ -1,463 +1,16 @@
{% extends "portfolios/base.html" %}
{% from "components/icon.html" import Icon %}
{% from "components/empty_state.html" import EmptyState %}
{% from "components/sticky_cta.html" import StickyCTA %}
{% block portfolio_content %}
<div class='portfolio-reports'>
<div v-cloak class='funding-summary-row'>
<div class='funding-summary-row__col'>
<div class='panel spend-summary'>
<h4 class='spend-summary__heading subheading'>Portfolio Total Spend</h4>
<div class='row'>
<dl class='spend-summary__budget col col--grow row'>
{% set budget = portfolio_totals['budget'] %}
{% set spent = portfolio_totals['spent'] %}
{% set remaining = budget - spent %}
<dl class='col col--grow'>
<dt>Budget</dt>
<dd>{{ budget | dollars }}</dd>
</dl>
<dl class='col col--grow'>
<dt>Remaining</dt>
<dd>{{ remaining | dollars }}</dd>
</dl>
</dl>
</div>
<hr></hr>
<div>
<meter value='{{ spent }}' min='0' {% if budget %}max='{{ budget }}' {% endif %}title='{{ spent | dollars }} Total spend to date'>
<div class='meter__fallback' style='width:{{ (spent / budget) * 100 if budget else 0 }}%;'></div>
</meter>
<dl class='spend-summary__spent'>
<dt>Total spending to date</dt>
<dd>{{ spent | dollars }}</dd>
</dl>
</div>
</div>
</div>
<div class='funding-summary-row__col'>
<div class='panel to-summary'>
<div class='to-summary__row'>
<div class='to-summary__to'>
<h2 class='to-summary__heading subheading'>Current Task Order</h2>
<dl class='to-summary__to-number'>
<dt class='usa-sr-only'>Task Order Number</dt>
<dd>{{ task_order.number }}</dd>
</dl>
</div>
<hr></hr>
<div class='to-summary__expiration'>
<div class='row'>
<h4 class='subheading'>Expiration Date</h4>
</div>
<div class='row'>
<div class='col col--grow'>
<div>
{% if expiration_date %}
<local-datetime
timestamp='{{ expiration_date }}'
format='MMMM D, YYYY'>
</local-datetime>
{% else %}
-
{% endif %}
</div>
<a href='{{ url_for("task_orders.review_task_order", task_order_id=task_order.id) }}' class='icon-link'>
{{ Icon('cog') }}
Manage Task Order
</a>
</div>
<div class='col col--grow'>
<dl>
<dt>Remaining Days</dt>
<dd class='{{ 'ending-soon' if remaining_days is not none }}'>
{% if remaining_days is not none %}
{{ Icon('arrow-down') }}
<span>{{ remaining_days }}</span>
{% else %}
-
{% endif %}
</dd>
</dl>
</div>
</div>
</div>
</div>
<hr></hr>
<dl class='to-summary__co'>
<dt class='subheading'>Contracting Officer</dt>
<dd class='row'>
<div class='col col--grow'>
{% if task_order.ko_first_name and task_order.ko_last_name %}
{{ task_order.ko_first_name }} {{ task_order.ko_last_name }}
{% endif %}
</div>
<div class='col'>
{% if task_order.ko_email %}
<a class='icon-link' href='mailto:{{ task_order.ko_email }}'>
{{ Icon('envelope') }}
{{ task_order.ko_email }}
</a>
{% endif %}
</div>
</dd>
</dl>
</div>
</div>
{{ StickyCTA("Reports") }}
<div class="portfolio-reports col col--grow">
{% include "portfolios/reports/portfolio_summary.html" %}
<hr>
{% include "portfolios/reports/obligated_funds.html" %}
{% include "portfolios/reports/expired_task_orders.html" %}
<hr>
{% include "portfolios/reports/application_and_env_spending.html" %}
</div>
{% set portfolio_totals = monthly_totals['portfolio'] %}
{% set current_month_index = current_month.strftime('%m/%Y') %}
{% set prev_month_index = prev_month.strftime('%m/%Y') %}
{% set two_months_ago_index = two_months_ago.strftime('%m/%Y') %}
{% set reports_url = url_for("portfolios.reports", portfolio_id=portfolio.id) %}
{% if not portfolio.applications %}
{% set can_create_applications = user_can(permissions.CREATE_APPLICATION) %}
{% set message = 'This portfolio has no cloud environments set up, so there is no spending data to report. Create an application with some cloud environments to get started.'
if can_create_applications
else 'This portfolio has no cloud environments set up, so there is no spending data to report. Contact the portfolio owner to set up some cloud environments.'
%}
{{ EmptyState(
'Nothing to report',
action_label='Add a new application' if can_create_applications else None,
action_href=url_for('applications.create_new_application_step_1', portfolio_id=portfolio.id) if can_create_applications else None,
icon='chart',
sub_message=message,
add_perms=can_create_applications
) }}
{% else %}
<budget-chart
v-cloak
budget={{ budget }}
current-month='{{ current_month_index }}'
expiration-date='{{ expiration_date }}'
v-bind:months='{{ cumulative_budget.months | tojson }}'
inline-template>
<div class='budget-chart panel' ref='panel'>
<header class='budget-chart__header panel__heading panel__heading--tight'>
<h4>Cumulative Budget</h4>
<div class='budget-chart__legend'>
<dl class='budget-chart__legend__spend'>
<div>
<dt>Monthly Spend</dt>
<dd class='budget-chart__legend__dot monthly'><span class='usa-sr-only'>Monthly spend visual key</span></dd>
</div>
<div>
<dt>Accumulated Spend</dt>
<dd class='budget-chart__legend__dot accumulated'><span class='usa-sr-only'>Accumulated spend visual key</span></dd>
</div>
</dl>
<dl class='budget-chart__legend__projected'>
<div>
<dt>Projected</dt>
<dd>
<div class='budget-chart__legend__line spend'><span class='usa-sr-only'>Projected monthly spend visual key</span></div>
<div class='budget-chart__legend__line accumulated'><span class='usa-sr-only'>Projected accumulated spend visual key</span></div>
</dd>
</div>
</dl>
</div>
</header>
<svg v-bind:height='height' v-bind:width='width'>
<defs>
<filter x="-0.04" y="0" width="1.08" height="1" class='filter__text-background' id="text-background">
<feFlood/>
<feComposite in="SourceGraphic"/>
</filter>
</defs>
{# spend/projected budget path lines #}
<path class='budget-chart__projected-path' v-bind:d='projectedPath'></path>
<path class='budget-chart__spend-path' v-bind:d='spendPath'></path>
{# max budget line #}
<line
class='budget-chart__budget-line'
x1='0'
v-bind:x2='width'
v-bind:y1='budgetHeight'
v-bind:y2='budgetHeight'></line>
<g v-for='month in displayedMonths' >
{# make this clickable to focus on that month #}
<a v-bind:href='"{{ reports_url }}?month=" + month.date.monthIndex + "&year=" + month.date.year'>
<defs>
<filter
x="-0.04"
y="0"
width="1.08"
height="1"
class='filter__text-background'
v-bind:class='{ "filter__text-background--highlighted": month.isHighlighted }'
v-bind:id="'text-background__' +month.date.month + month.date.year">
<feFlood/>
<feComposite in="SourceGraphic"/>
</filter>
</defs>
<title>
<span v-html='month.date.month + " " + month.date.year'></span>&nbsp;|&nbsp;<!--
--><template v-if='month.cumulativeTotal'><!--
--><template v-if='month.budget && month.budget.spend'>Spend:</template><!--
--><template v-else>Projected Spend:</template><!--
--><span v-html='month.spendAmount'></span><!--
-->&nbsp;|&nbsp;<!--
--><template v-if='month.budget'>Total:</template><!--
--><template v-else>Projected Total:</template><!--
--><span v-html='month.cumulativeAmount'></span><!--
--></template><!--
--><template v-else>No spend for this month</template>
</title>
{# container block #}
<rect
class='budget-chart__block'
v-bind:class='{ "budget-chart__block--highlighted": month.isHighlighted, "budget-chart__block-is-expiration": month.isExpirationMonth }'
v-bind:width='month.metrics.blockWidth'
v-bind:x='month.metrics.blockX'
v-bind:height='height'></rect>
{# budget bar #}
<rect
v-if='month.budget'
class='budget-chart__bar'
v-bind:class='{ "budget-chart__bar--projected": month.budget.projected }'
v-bind:width='month.metrics.barWidth'
v-bind:height='month.metrics.barHeight'
v-bind:x='month.metrics.barX'
v-bind:y='month.metrics.barY'></rect>
{# projected budget bar #}
<rect
v-if='!month.budget'
class='budget-chart__bar budget-chart__bar--projected'
v-bind:width='month.metrics.barWidth'
v-bind:height='month.metrics.barHeight'
v-bind:x='month.metrics.barX'
v-bind:y='month.metrics.barY'></rect>
{# task order expiration line #}
<line
v-if='month.isExpirationMonth'
class='budget-chart__expiration-line'
v-bind:x1='month.metrics.cumulativeX'
v-bind:x2='month.metrics.cumulativeX'
y1='0'
v-bind:y2='baseHeight'></line>
{# task order expiration label #}
<text
v-bind:filter="'url(#text-background__' + month.date.month + month.date.year + ')'"
v-if='month.isExpirationMonth'
text-anchor='middle'
v-bind:x='month.metrics.cumulativeX'
v-bind:y='budgetHeight + 20'
class='budget-chart__label'>T.O. Expires</text>
{# cumulative dot #}
<circle
v-if='month.cumulativeTotal'
class='budget-chart__cumulative__dot'
v-bind:r='month.metrics.cumulativeR'
v-bind:cx='month.metrics.cumulativeX'
v-bind:cy='month.metrics.cumulativeY'></circle>
{# abbreviated cumulative label #}
<text
v-bind:filter="'url(#text-background__' + month.date.month + month.date.year + ')'"
v-if='month.cumulativeTotal'
v-bind:x='month.metrics.cumulativeX'
v-bind:y='month.metrics.cumulativeY - 10'
text-anchor='middle'
class='budget-chart__label'
v-html='month.abbreviatedCumulative'></text>
{# abbreviated spend label #}
<text
v-bind:filter="'url(#text-background__' + month.date.month + month.date.year + ')'"
v-bind:x='month.metrics.cumulativeX'
v-bind:y='baseHeight + 20'
text-anchor='middle'
class='budget-chart__label'
v-html='"+" + month.abbreviatedSpend'></text>
{# month label #}
<text
v-bind:filter="'url(#text-background__' + month.date.month + month.date.year + ')'"
v-bind:x='month.metrics.cumulativeX'
v-bind:y='baseHeight + 40'
text-anchor='middle'
class='budget-chart__label budget-chart__label--strong'
v-html='month.date.month'></text>
{# year label #}
<text
v-bind:filter="'url(#text-background__' + month.date.month + month.date.year + ')'"
v-if='month.showYear'
v-bind:x='month.metrics.cumulativeX'
v-bind:y='baseHeight + 55'
text-anchor='middle'
class='budget-chart__label budget-chart__label--strong'
v-html='month.date.year'></text>
</g>
</a>
<text
x='20'
v-bind:y='budgetHeight + 20'
class='budget-chart__label'>Total Budget</text>
<text
x='20'
v-bind:y='budgetHeight + 40'
class='budget-chart__label'
v-html='displayBudget'></text>
</svg>
</div>
</budget-chart>
<div class='accordion-table responsive-table-wrapper'>
<div class='responsive-table-wrapper__header'>
<h2 class='responsive-table-wrapper__title'>Total spent per month</h2>
<select name='month' id='month' onchange='location = this.value' class='spend-table__month-select'>
{% for m in cumulative_budget["months"] %}
{% set month = m | dateFromString %}
<option
{% if month.month == current_month.month and month.year == current_month.year %}
selected='selected'
{% endif %}
value='{{ url_for("portfolios.reports",
portfolio_id=portfolio.id,
month=month.month,
year=month.year) }}'
>
{{ month.strftime('%B %Y') }}
</option>
{% endfor %}
{% if not cumulative_budget["months"] %}
<option>{{ current_month.strftime('%B %Y') }}</option>
{% endif %}
</select>
</div>
<spend-table
v-bind:applications='{{ monthly_totals['applications'] | tojson }}'
v-bind:portfolio='{{ portfolio_totals | tojson }}'
v-bind:environments='{{ monthly_totals['environments'] | tojson }}'
current-month-index='{{ current_month_index }}'
prev-month-index='{{ prev_month_index }}'
two-months-ago-index='{{ two_months_ago_index }}'
inline-template>
<table class="atat-table">
<thead>
<th scope='col'><span class='usa-sr-only'>Spending scope</span></th>
<th scope='col' class='table-cell--align-right previous-month'>{{ two_months_ago.strftime('%B %Y') }}</th>
<th scope='col' class='table-cell--align-right previous-month'>{{ prev_month.strftime('%B %Y') }}</th>
<th scope='col' class='table-cell--align-right current-month'>{{ current_month.strftime('%B %Y') }}</th>
<th class='current-month'></th>
</thead>
<tbody class='spend-table__portfolio'>
<tr>
<th scope='row'>Portfolio Total</th>
<td class='table-cell--align-right previous-month'>{{ portfolio_totals.get(two_months_ago_index, 0) | dollars }}</td>
<td class='table-cell--align-right previous-month'>{{ portfolio_totals.get(prev_month_index, 0) | dollars }}</td>
<td class='table-cell--align-right current-month'>{{ portfolio_totals.get(current_month_index, 0) | dollars }}</td>
<td class='table-cell--expand current-month meter-cell'>
<meter value='{{ portfolio_totals.get(current_month_index, 0) }}' min='0' max='{{ portfolio_totals.get(current_month_index, 0) }}'>
<div class='meter__fallback' style='width: 100%'></div>
</meter>
</td>
</tr>
</tbody>
<tbody v-for='(application, name) in applicationsState' class='accordion-table__items'>
<tr>
<th scope='rowgroup'>
<button v-on:click='toggle($event, name)' class='icon-link icon-link--large accordion-table__item__toggler'>
<template v-if='application.isVisible'>{{ Icon('caret_down') }}<div class='open-indicator'></div></template>
<template v-else>{{ Icon('caret_right') }}</template>
<span v-html='name'></span>
</button>
</th>
<td class='table-cell--align-right previous-month'>
<span v-html='formatDollars(application[twoMonthsAgoIndex] || 0)'></span>
</td>
<td class='table-cell--align-right previous-month'>
<span v-html='formatDollars(application[prevMonthIndex] || 0)'></span>
</td>
<td class='table-cell--align-right current-month'>
<span v-html='formatDollars(application[currentMonthIndex] || 0)'></span>
</td>
<td class='table-cell--expand current-month meter-cell'>
<span class='spend-table__meter-value'>
<span v-html='round( 100 * ((application[currentMonthIndex] || 0) / (portfolio[currentMonthIndex] || 1) )) + "%"'></span>
</span>
<meter v-bind:value='application[currentMonthIndex] || 0' min='0' v-bind:max='portfolio[currentMonthIndex] || 1'>
<div class='meter__fallback' v-bind:style='"width:" + round( 100 * ((application[currentMonthIndex] || 0) / (portfolio[currentMonthIndex] || 1) )) + "%;"'></div>
</meter>
</td>
</tr>
<tr v-for='(environment, envName) in environments[name]' v-show='application.isVisible' class='accordion-table__item__expanded'>
<th scope='rowgroup'>
<div class='icon-link accordion-table__item__expanded'>
<span v-html='envName'></span>
</div>
</th>
<td class='table-cell--align-right previous-month'>
<span v-html='formatDollars(environment[twoMonthsAgoIndex] || 0)'></span>
</td>
<td class='table-cell--align-right previous-month'>
<span v-html='formatDollars(environment[prevMonthIndex] || 0)'></span>
</td>
<td class='table-cell--align-right current-month'>
<span v-html='formatDollars(environment[currentMonthIndex] || 0)'></span>
</td>
<td class='table-cell--expand current-month'></td>
</tr>
</tbody>
</table>
</spend-table>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,31 @@
<section>
<header>
<h2>Current Obligated funds</h2>
<span>As of DATE</span>
</header>
<div class='panel'>
<div class='panel__content'>
<div>
{% for JEDI_clin, funds in current_obligated_funds.items() %}
{{ JEDI_clin }}
<meter value='{{ funds["expended_funds"] }}' min='0' max='{{ funds["obligated_funds"] }}' title='{{ JEDI_clin }}'>
<div class='meter__fallback' style='width:{{ (funds["expended_funds"] / funds["obligated_funds"]) * 100 }}%;'></div>
</meter>
<div>
<p>Remaining funds:</p>
<p>{{ (funds["obligated_funds"] - funds["expended_funds"]) | dollars }}</p>
</div>
<div>
<p>Funds expended to date:</p>
<p>{{ funds["expended_funds"] | dollars }}</p>
</div>
<hr>
{% endfor %}
{% for task_order in portfolio.active_task_orders %}
<a href="{{ url_for("task_orders.review_task_order", task_order_id=task_order.id) }}">{{ task_order.number }}</a>
{% endfor %}
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,36 @@
{% from "components/tooltip.html" import Tooltip %}
{% from "components/icon.html" import Icon %}
<section class="row">
<div class='col col--grow'>
<p>
Total Portfolio Value
{{Tooltip(("common.lorem" | translate), title="")}}
</p>
<p>{{ total_portfolio_value | dollars }}</p>
</div>
<div class='col col--grow'>
<p>
Funding Duration
{{Tooltip(("common.lorem" | translate), title="")}}
</p>
{% set earliest_pop_start_date, latest_pop_end_date = portfolio.funding_duration %}
{% if earliest_pop_start_date and latest_pop_end_date %}
<p>
{{ earliest_pop_start_date | formattedDate(formatter="%B %d, %Y") }}
-
{{ latest_pop_end_date | formattedDate(formatter="%B %d, %Y") }}
</p>
{% else %}
<p> - </p>
{% endif %}
</div>
<div class='col col--grow'>
<p>
Days Remaining
{{Tooltip(("common.lorem" | translate), title="")}}
</p>
<p>{{ portfolio.days_to_funding_expiration }} days</p>
</div>
</section>

View File

@ -1,27 +1,22 @@
from atst.domain.reports import Reports
from tests.factories import PortfolioFactory
from tests.factories import *
def test_portfolio_totals():
portfolio = PortfolioFactory.create()
report = Reports.portfolio_totals(portfolio)
assert report == {"budget": 0, "spent": 0}
# this is sketched in until we do real reporting
# this is sketched out until we do real reporting
def test_monthly_totals():
portfolio = PortfolioFactory.create()
monthly = Reports.monthly_totals(portfolio)
assert not monthly["environments"]
assert not monthly["applications"]
assert not monthly["portfolio"]
pass
# this is sketched in until we do real reporting
def test_cumulative_budget():
portfolio = PortfolioFactory.create()
months = Reports.cumulative_budget(portfolio)
# this is sketched out until we do real reporting
def test_current_obligated_funds():
pass
assert len(months["months"]) >= 12
# this is sketched out until we do real reporting
def test_expired_task_orders():
pass
# this is sketched out until we do real reporting
def test_obligated_funds_by_JEDI_clin():
pass

View File

@ -289,6 +289,7 @@ class TaskOrderFactory(Base):
)
number = factory.LazyFunction(random_task_order_number)
creator = factory.SubFactory(UserFactory)
signed_at = None
_pdf = factory.SubFactory(AttachmentFactory)
@classmethod

View File

@ -1,4 +1,12 @@
from tests.factories import ApplicationFactory, PortfolioFactory
from tests.factories import (
ApplicationFactory,
PortfolioFactory,
TaskOrderFactory,
CLINFactory,
random_future_date,
random_past_date,
)
import datetime
def test_portfolio_applications_excludes_deleted():
@ -7,3 +15,73 @@ def test_portfolio_applications_excludes_deleted():
ApplicationFactory.create(portfolio=portfolio, deleted=True)
assert len(portfolio.applications) == 1
assert portfolio.applications[0].id == app.id
def test_funding_duration(session):
# portfolio with active task orders
portfolio = PortfolioFactory()
funding_start_date = random_past_date()
funding_end_date = random_future_date(year_min=2)
TaskOrderFactory.create(
signed_at=random_past_date(),
portfolio=portfolio,
create_clins=[
{
"start_date": funding_start_date,
"end_date": random_future_date(year_max=1),
}
],
)
TaskOrderFactory.create(
portfolio=portfolio,
signed_at=random_past_date(),
create_clins=[
{"start_date": datetime.datetime.now(), "end_date": funding_end_date,}
],
)
assert portfolio.funding_duration == (funding_start_date, funding_end_date)
# empty portfolio
empty_portfolio = PortfolioFactory()
assert empty_portfolio.funding_duration == (None, None)
def test_days_remaining(session):
# portfolio with task orders
funding_end_date = random_future_date(year_min=2)
portfolio = PortfolioFactory()
TaskOrderFactory.create(
portfolio=portfolio,
signed_at=random_past_date(),
create_clins=[{"end_date": funding_end_date}],
)
assert (
portfolio.days_to_funding_expiration
== (funding_end_date - datetime.date.today()).days
)
# empty portfolio
empty_portfolio = PortfolioFactory()
assert empty_portfolio.days_to_funding_expiration == 0
def test_active_task_orders(session):
portfolio = PortfolioFactory()
TaskOrderFactory.create(
portfolio=portfolio,
signed_at=random_past_date(),
create_clins=[
{
"start_date": datetime.date(2019, 1, 1),
"end_date": datetime.date(2019, 10, 31),
}
],
)
TaskOrderFactory.create(
portfolio=portfolio, signed_at=random_past_date(), clins=[CLINFactory.create()]
)
assert len(portfolio.active_task_orders) == 1

View File

@ -110,7 +110,6 @@ def test_portfolio_reports_with_mock_portfolio(client, user_session):
response = client.get(url_for("portfolios.reports", portfolio_id=portfolio.id))
assert response.status_code == 200
assert portfolio.name in response.data.decode()
assert "$251,626.00 Total spend to date" in response.data.decode()
def test_delete_portfolio_success(client, user_session):

View File

@ -449,6 +449,14 @@ portfolios:
name: Name
portfolio_mgmt: Portfolio management
reporting: Reporting
reports:
empty_state:
message: Nothing to report.
sub_message:
can_create_applications: This portfolio has no cloud environments set up, so there is no spending data to report. Create an application with some cloud environments to get started.
cannot_create_applications: This portfolio has no cloud environments set up, so there is no spending data to report. Contact the portfolio owner to set up some cloud environments.
action_label: 'Add a new application'
task_orders:
review:
pdf_title: Approved Task Order