diff --git a/atst/domain/csp/reports.py b/atst/domain/csp/reports.py index 88c30482..683c1139 100644 --- a/atst/domain/csp/reports.py +++ b/atst/domain/csp/reports.py @@ -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 + ] diff --git a/atst/domain/reports.py b/atst/domain/reports.py index 96085afb..94f6c54e 100644 --- a/atst/domain/reports.py +++ b/atst/domain/reports.py @@ -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) diff --git a/atst/models/clin.py b/atst/models/clin.py index e376da9c..2802e292 100644 --- a/atst/models/clin.py +++ b/atst/models/clin.py @@ -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 diff --git a/atst/models/portfolio.py b/atst/models/portfolio.py index d749e470..08a65f1c 100644 --- a/atst/models/portfolio.py +++ b/atst/models/portfolio.py @@ -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 ( diff --git a/atst/routes/portfolios/index.py b/atst/routes/portfolios/index.py index 9c2ec1a0..336cd2e6 100644 --- a/atst/routes/portfolios/index.py +++ b/atst/routes/portfolios/index.py @@ -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, ) diff --git a/js/components/accordion.js b/js/components/accordion.js new file mode 100644 index 00000000..d281a9e7 --- /dev/null +++ b/js/components/accordion.js @@ -0,0 +1,14 @@ +import ToggleMixin from '../mixins/toggle' + +export default { + name: 'accordion', + + mixins: [ToggleMixin], + + props: { + defaultVisible: { + type: Boolean, + default: false, + }, + }, +} diff --git a/js/components/charts/budget_chart.js b/js/components/charts/budget_chart.js deleted file mode 100644 index 11fee9b0..00000000 --- a/js/components/charts/budget_chart.js +++ /dev/null @@ -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 - }, - }, -} diff --git a/js/components/tables/spend_table.js b/js/components/tables/spend_table.js index f8d663a3..c3a1c90f 100644 --- a/js/components/tables/spend_table.js +++ b/js/components/tables/spend_table.js @@ -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) - }, }, } diff --git a/js/index.js b/js/index.js index 7381c828..dd5ef06a 100644 --- a/js/index.js +++ b/js/index.js @@ -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, diff --git a/styles/atat.scss b/styles/atat.scss index 346b5d44..c3fd1a55 100644 --- a/styles/atat.scss +++ b/styles/atat.scss @@ -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"; diff --git a/styles/components/_budget_chart.scss b/styles/components/_budget_chart.scss deleted file mode 100644 index d5930236..00000000 --- a/styles/components/_budget_chart.scss +++ /dev/null @@ -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; - } - } -} diff --git a/templates/components/accordion.html b/templates/components/accordion.html new file mode 100644 index 00000000..8e508321 --- /dev/null +++ b/templates/components/accordion.html @@ -0,0 +1,23 @@ +{% macro Accordion(title, id, heading_level="h2") %} + +
+ <{{heading_level}}> + + +
+ {{ caller() }} +
+
+
+{% endmacro %} \ No newline at end of file diff --git a/templates/portfolios/reports/application_and_env_spending.html b/templates/portfolios/reports/application_and_env_spending.html new file mode 100644 index 00000000..d969a767 --- /dev/null +++ b/templates/portfolios/reports/application_and_env_spending.html @@ -0,0 +1,82 @@ +{% from "components/empty_state.html" import EmptyState %} +{% from "components/icon.html" import Icon %} + +
+

Funds Expended per Application and Environment

+ {% 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 %} + +
+ + + + + + + + + + + + +
Applications and EnvironmentsCurrent MonthLast MonthTotal Spent
+
+
+ {% endif %} +
\ No newline at end of file diff --git a/templates/portfolios/reports/expired_task_orders.html b/templates/portfolios/reports/expired_task_orders.html new file mode 100644 index 00000000..d7dd843a --- /dev/null +++ b/templates/portfolios/reports/expired_task_orders.html @@ -0,0 +1,34 @@ +{% from "components/accordion.html" import Accordion %} + + +
+
+ {% call Accordion("Expired Task Orders", "expired_task_orders", "h3") %} + {% for task_order in expired_task_orders %} + + Task Order {{ task_order["number"] }} + +
+

Period of Performance

+

+ {{ task_order["period_of_performance"].start_date | formattedDate(formatter="%B %d, %Y") }} + - + {{ task_order["period_of_performance"].end_date | formattedDate(formatter="%B %d, %Y") }} +

+
+
+

Total Obligated

+

{{ task_order["total_obligated_funds"] | dollars }}

+
+
+

Total Expended

+

{{ task_order["expended_funds"] | dollars }}

+
+
+

Total Unused

+

{{ (task_order["total_obligated_funds"] - task_order["expended_funds"]) | dollars }}

+
+ {% endfor %} + {% endcall %} +
+
diff --git a/templates/portfolios/reports/index.html b/templates/portfolios/reports/index.html index 9cde86ae..7de02212 100644 --- a/templates/portfolios/reports/index.html +++ b/templates/portfolios/reports/index.html @@ -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 %} - -
-
- -
-
-

Portfolio Total Spend

-
-
- {% set budget = portfolio_totals['budget'] %} - {% set spent = portfolio_totals['spent'] %} - {% set remaining = budget - spent %} -
-
Budget
-
{{ budget | dollars }}
-
- -
-
Remaining
-
{{ remaining | dollars }}
-
-
-
- -
- -
- -
-
- -
-
Total spending to date
-
{{ spent | dollars }}
-
-
-
-
- -
-
-
- -
-

Current Task Order

-
-
Task Order Number
-
{{ task_order.number }}
-
-
- -
- -
-
-

Expiration Date

-
-
- -
-
- {% if expiration_date %} - - - {% else %} - - - {% endif %} -
- - {{ Icon('cog') }} - Manage Task Order - -
-
-
-
Remaining Days
-
- {% if remaining_days is not none %} - {{ Icon('arrow-down') }} - {{ remaining_days }} - {% else %} - - - {% endif %} -
-
-
- -
-
-
- -
- -
-
Contracting Officer
-
-
- {% if task_order.ko_first_name and task_order.ko_last_name %} - {{ task_order.ko_first_name }} {{ task_order.ko_last_name }} - {% endif %} -
- -
- {% if task_order.ko_email %} - - {{ Icon('envelope') }} - {{ task_order.ko_email }} - - {% endif %} -
-
-
- -
-
- + {{ StickyCTA("Reports") }} +
+ {% include "portfolios/reports/portfolio_summary.html" %} +
+ {% include "portfolios/reports/obligated_funds.html" %} + {% include "portfolios/reports/expired_task_orders.html" %} +
+ {% include "portfolios/reports/application_and_env_spending.html" %}
- - {% 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 %} - - - -
-
-

Cumulative Budget

- -
-
-
-
Monthly Spend
-
Monthly spend visual key
-
- -
-
Accumulated Spend
-
Accumulated spend visual key
-
-
-
-
-
Projected
-
-
Projected monthly spend visual key
-
Projected accumulated spend visual key
-
-
-
-
-
- - - - - - - - - - - {# spend/projected budget path lines #} - - - - {# max budget line #} - - - - - {# make this clickable to focus on that month #} - - - - - - - - - - -  |  - - - {# container block #} - - - {# budget bar #} - - - {# projected budget bar #} - - - {# task order expiration line #} - - - {# task order expiration label #} - T.O. Expires - - {# cumulative dot #} - - - {# abbreviated cumulative label #} - - - {# abbreviated spend label #} - - - {# month label #} - - - {# year label #} - - - - - Total Budget - - -
-
- -
-
-

Total spent per month

- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Spending scope{{ two_months_ago.strftime('%B %Y') }}{{ prev_month.strftime('%B %Y') }}{{ current_month.strftime('%B %Y') }}
Portfolio Total{{ portfolio_totals.get(two_months_ago_index, 0) | dollars }}{{ portfolio_totals.get(prev_month_index, 0) | dollars }}{{ portfolio_totals.get(current_month_index, 0) | dollars }} - -
-
-
- - - - - - - - - - - - -
-
-
- - - - - - - -
-
-
- {% endif %} -
- {% endblock %} diff --git a/templates/portfolios/reports/obligated_funds.html b/templates/portfolios/reports/obligated_funds.html new file mode 100644 index 00000000..04ba4883 --- /dev/null +++ b/templates/portfolios/reports/obligated_funds.html @@ -0,0 +1,31 @@ + +
+
+

Current Obligated funds

+ As of DATE +
+
+
+
+ {% for JEDI_clin, funds in current_obligated_funds.items() %} + {{ JEDI_clin }} + +
+
+
+

Remaining funds:

+

{{ (funds["obligated_funds"] - funds["expended_funds"]) | dollars }}

+
+
+

Funds expended to date:

+

{{ funds["expended_funds"] | dollars }}

+
+
+ {% endfor %} + {% for task_order in portfolio.active_task_orders %} + {{ task_order.number }} + {% endfor %} +
+
+
+
\ No newline at end of file diff --git a/templates/portfolios/reports/portfolio_summary.html b/templates/portfolios/reports/portfolio_summary.html new file mode 100644 index 00000000..e29e96be --- /dev/null +++ b/templates/portfolios/reports/portfolio_summary.html @@ -0,0 +1,36 @@ +{% from "components/tooltip.html" import Tooltip %} +{% from "components/icon.html" import Icon %} + + +
+
+

+ Total Portfolio Value + {{Tooltip(("common.lorem" | translate), title="")}} +

+

{{ total_portfolio_value | dollars }}

+
+
+

+ Funding Duration + {{Tooltip(("common.lorem" | translate), title="")}} +

+ {% set earliest_pop_start_date, latest_pop_end_date = portfolio.funding_duration %} + {% if earliest_pop_start_date and latest_pop_end_date %} +

+ {{ earliest_pop_start_date | formattedDate(formatter="%B %d, %Y") }} + - + {{ latest_pop_end_date | formattedDate(formatter="%B %d, %Y") }} +

+ {% else %} +

-

+ {% endif %} +
+
+

+ Days Remaining + {{Tooltip(("common.lorem" | translate), title="")}} +

+

{{ portfolio.days_to_funding_expiration }} days

+
+
\ No newline at end of file diff --git a/tests/domain/test_reports.py b/tests/domain/test_reports.py index d0e3a24e..e11b8dee 100644 --- a/tests/domain/test_reports.py +++ b/tests/domain/test_reports.py @@ -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 diff --git a/tests/factories.py b/tests/factories.py index 8b78ffc5..efb6fb82 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -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 diff --git a/tests/models/test_portfolio.py b/tests/models/test_portfolio.py index 613b6435..af082876 100644 --- a/tests/models/test_portfolio.py +++ b/tests/models/test_portfolio.py @@ -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 diff --git a/tests/routes/portfolios/test_index.py b/tests/routes/portfolios/test_index.py index 153ea406..ef368c3d 100644 --- a/tests/routes/portfolios/test_index.py +++ b/tests/routes/portfolios/test_index.py @@ -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): diff --git a/translations.yaml b/translations.yaml index fddf9524..ea0ddca9 100644 --- a/translations.yaml +++ b/translations.yaml @@ -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