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/portfolio.py b/atst/models/portfolio.py index 86f3c57f..08a65f1c 100644 --- a/atst/models/portfolio.py +++ b/atst/models/portfolio.py @@ -114,7 +114,7 @@ class Portfolio( for task_order in self.task_orders if task_order.is_active ), - default=None, + default=0, ) @property 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/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/templates/components/accordion.html b/templates/components/accordion.html index 7bf03c6f..8e508321 100644 --- a/templates/components/accordion.html +++ b/templates/components/accordion.html @@ -1,36 +1,23 @@ -{% macro Accordion(title, id) %} +{% macro Accordion(title, id, heading_level="h2") %} -
  • - - - - -
  • + +
    + {{ 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/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