Merge pull request #1236 from dod-ccpo/obligated-funds-graph-and-reporting-refactor

Obligated funds graph and reporting refactor
This commit is contained in:
graham-dds 2019-12-11 09:04:39 -05:00 committed by GitHub
commit 4ab27eb625
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 770 additions and 428 deletions

View File

@ -1,344 +1,121 @@
from itertools import groupby
import pendulum
from collections import defaultdict
import json
from decimal import Decimal
from collections import OrderedDict
class ReportingInterface:
def monthly_totals_for_environment(environment):
"""Return the monthly totals for the specified environment.
def load_fixture_data():
with open("fixtures/fixture_spend_data.json") as json_file:
return json.load(json_file)
Data should be in the format of a dictionary with the month as the key
and the spend in that month as the value. For example:
{ "01/2018": 79.85, "02/2018": 86.54 }
class MockReportingProvider:
FIXTURE_SPEND_DATA = load_fixture_data()
@classmethod
def get_portfolio_monthly_spending(cls, portfolio):
"""
raise NotImplementedError()
class MockEnvironment:
def __init__(self, id_, env_name):
self.id = id_
self.name = env_name
class MockApplication:
def __init__(self, application_name, envs):
def make_env(name):
return MockEnvironment("{}_{}".format(application_name, name), name)
self.name = application_name
self.environments = [make_env(env_name) for env_name in envs]
def generate_sample_dates(_max=8):
current = pendulum.now()
sample_dates = []
for _i in range(_max):
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 = {
"LC04_Integ": {
FIXTURE_MONTHS[7]: 284,
FIXTURE_MONTHS[6]: 1210,
FIXTURE_MONTHS[5]: 1430,
FIXTURE_MONTHS[4]: 1366,
FIXTURE_MONTHS[3]: 1169,
FIXTURE_MONTHS[2]: 991,
FIXTURE_MONTHS[1]: 978,
FIXTURE_MONTHS[0]: 737,
},
"LC04_PreProd": {
FIXTURE_MONTHS[7]: 812,
FIXTURE_MONTHS[6]: 1389,
FIXTURE_MONTHS[5]: 1425,
FIXTURE_MONTHS[4]: 1306,
FIXTURE_MONTHS[3]: 1112,
FIXTURE_MONTHS[2]: 936,
FIXTURE_MONTHS[1]: 921,
FIXTURE_MONTHS[0]: 694,
},
"LC04_Prod": {
FIXTURE_MONTHS[7]: 1742,
FIXTURE_MONTHS[6]: 1716,
FIXTURE_MONTHS[5]: 1866,
FIXTURE_MONTHS[4]: 1809,
FIXTURE_MONTHS[3]: 1839,
FIXTURE_MONTHS[2]: 1633,
FIXTURE_MONTHS[1]: 1654,
FIXTURE_MONTHS[0]: 1103,
},
"SF18_Integ": {
FIXTURE_MONTHS[5]: 1498,
FIXTURE_MONTHS[4]: 1400,
FIXTURE_MONTHS[3]: 1394,
FIXTURE_MONTHS[2]: 1171,
FIXTURE_MONTHS[1]: 1200,
FIXTURE_MONTHS[0]: 963,
},
"SF18_PreProd": {
FIXTURE_MONTHS[5]: 1780,
FIXTURE_MONTHS[4]: 1667,
FIXTURE_MONTHS[3]: 1703,
FIXTURE_MONTHS[2]: 1474,
FIXTURE_MONTHS[1]: 1441,
FIXTURE_MONTHS[0]: 933,
},
"SF18_Prod": {
FIXTURE_MONTHS[5]: 1686,
FIXTURE_MONTHS[4]: 1779,
FIXTURE_MONTHS[3]: 1792,
FIXTURE_MONTHS[2]: 1570,
FIXTURE_MONTHS[1]: 1539,
FIXTURE_MONTHS[0]: 986,
},
"Canton_Prod": {
FIXTURE_MONTHS[4]: 28699,
FIXTURE_MONTHS[3]: 26766,
FIXTURE_MONTHS[2]: 22619,
FIXTURE_MONTHS[1]: 24090,
FIXTURE_MONTHS[0]: 16719,
},
"BD04_Integ": {},
"BD04_PreProd": {
FIXTURE_MONTHS[7]: 7019,
FIXTURE_MONTHS[6]: 3004,
FIXTURE_MONTHS[5]: 2691,
FIXTURE_MONTHS[4]: 2901,
FIXTURE_MONTHS[3]: 3463,
FIXTURE_MONTHS[2]: 3314,
FIXTURE_MONTHS[1]: 3432,
FIXTURE_MONTHS[0]: 723,
},
"SCV18_Dev": {FIXTURE_MONTHS[1]: 9797},
"Crown_CR Portal Dev": {
FIXTURE_MONTHS[6]: 208,
FIXTURE_MONTHS[5]: 457,
FIXTURE_MONTHS[4]: 671,
FIXTURE_MONTHS[3]: 136,
FIXTURE_MONTHS[2]: 1524,
FIXTURE_MONTHS[1]: 2077,
FIXTURE_MONTHS[0]: 1858,
},
"Crown_CR Staging": {
FIXTURE_MONTHS[6]: 208,
FIXTURE_MONTHS[5]: 457,
FIXTURE_MONTHS[4]: 671,
FIXTURE_MONTHS[3]: 136,
FIXTURE_MONTHS[2]: 1524,
FIXTURE_MONTHS[1]: 2077,
FIXTURE_MONTHS[0]: 1858,
},
"Crown_CR Portal Test 1": {
FIXTURE_MONTHS[2]: 806,
FIXTURE_MONTHS[1]: 1966,
FIXTURE_MONTHS[0]: 2597,
},
"Crown_Jewels Prod": {
FIXTURE_MONTHS[2]: 806,
FIXTURE_MONTHS[1]: 1966,
FIXTURE_MONTHS[0]: 2597,
},
"Crown_Jewels Dev": {
FIXTURE_MONTHS[6]: 145,
FIXTURE_MONTHS[5]: 719,
FIXTURE_MONTHS[4]: 1243,
FIXTURE_MONTHS[3]: 2214,
FIXTURE_MONTHS[2]: 2959,
FIXTURE_MONTHS[1]: 4151,
FIXTURE_MONTHS[0]: 4260,
},
"NP02_Integ": {FIXTURE_MONTHS[1]: 284, FIXTURE_MONTHS[0]: 1210},
"NP02_PreProd": {FIXTURE_MONTHS[1]: 812, FIXTURE_MONTHS[0]: 1389},
"NP02_Prod": {FIXTURE_MONTHS[1]: 3742, FIXTURE_MONTHS[0]: 4716},
"FM_Integ": {FIXTURE_MONTHS[1]: 1498},
"FM_Prod": {FIXTURE_MONTHS[0]: 5686},
}
REPORT_FIXTURE_MAP = {
"A-Wing": {
"applications": [
MockApplication("LC04", ["Integ", "PreProd", "Prod"]),
MockApplication("SF18", ["Integ", "PreProd", "Prod"]),
MockApplication("Canton", ["Prod"]),
MockApplication("BD04", ["Integ", "PreProd"]),
MockApplication("SCV18", ["Dev"]),
MockApplication(
"Crown",
[
"CR Portal Dev",
"CR Staging",
"CR Portal Test 1",
"Jewels Prod",
"Jewels Dev",
],
),
],
"budget": 500_000,
},
"B-Wing": {
"applications": [
MockApplication("NP02", ["Integ", "PreProd", "Prod"]),
MockApplication("FM", ["Integ", "Prod"]),
],
"budget": 70000,
},
}
def _rollup_application_totals(self, data):
application_totals = {}
for application, environments in data.items():
application_spend = [
(month, spend)
for env in environments.values()
if env
for month, spend in env.items()
]
application_totals[application] = {
month: sum([spend[1] for spend in spends])
for month, spends in groupby(sorted(application_spend), lambda x: x[0])
}
return application_totals
def _rollup_portfolio_totals(self, application_totals):
monthly_spend = [
(month, spend)
for application in application_totals.values()
for month, spend in application.items()
]
portfolio_totals = {}
for month, spends in groupby(sorted(monthly_spend), lambda m: m[0]):
portfolio_totals[month] = sum([spend[1] for spend in spends])
return portfolio_totals
def monthly_totals_for_environment(self, environment_id):
"""Return the monthly totals for the specified environment.
Data should be in the format of a dictionary with the month as the key
and the spend in that month as the value. For example:
{ "01/2018": 79.85, "02/2018": 86.54 }
"""
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.
Data should returned with three top level keys, "portfolio", "applications",
and "environments".
The "applications" key will have budget data per month for each application,
The "environments" key will have budget data for each environment.
The "portfolio" key will be total monthly spending for the portfolio.
For example:
returns an array of application and environment spending for the
portfolio. Applications and their nested environments are sorted in
alphabetical order by name.
[
{
"environments": { "X-Wing": { "Prod": { "01/2018": 75.42 } } },
"applications": { "X-Wing": { "01/2018": 75.42 } },
"portfolio": { "01/2018": 75.42 },
name
this_month
last_month
total
environments [
{
name
this_month
last_month
total
}
]
}
]
"""
applications = portfolio.applications
if portfolio.name in self.REPORT_FIXTURE_MAP:
applications = self.REPORT_FIXTURE_MAP[portfolio.name]["applications"]
environments = {
application.name: {
env.name: self.monthly_totals_for_environment(env.id)
for env in application.environments
}
for application in applications
}
application_totals = self._rollup_application_totals(environments)
portfolio_totals = self._rollup_portfolio_totals(application_totals)
return {
"environments": environments,
"applications": application_totals,
"portfolio": portfolio_totals,
}
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:
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[jedi_clin.value] = {
"obligated_funds": obligated_funds,
"expended_funds": (
obligated_funds * Decimal(self.MOCK_PERCENT_EXPENDED_FUNDS)
),
}
return OrderedDict(
# 0 index for dict item, -1 for last digit of 4 digit CLIN, e.g. 0001
sorted(return_dict.items(), key=lambda clin: clin[0][-1])
if portfolio.name in cls.FIXTURE_SPEND_DATA:
applications = cls.FIXTURE_SPEND_DATA[portfolio.name]["applications"]
return sorted(
[
cls._get_application_monthly_totals(application)
for application in applications
],
key=lambda app: app["name"],
)
return {}
return []
def get_expired_task_orders(self, portfolio):
def sorted_task_orders(to_list):
return sorted(to_list, key=lambda to: to["number"])
@classmethod
def _get_environment_monthly_totals(cls, environment):
"""
returns a dictionary that represents spending totals for an environment e.g.
{
name
this_month
last_month
total
}
"""
return {
"name": environment["name"],
"this_month": sum(environment["spending"]["this_month"].values()),
"last_month": sum(environment["spending"]["last_month"].values()),
"total": sum(environment["spending"]["total"].values()),
}
def sorted_clins(clin_list):
return sorted(clin_list, key=lambda clin: clin["number"])
def serialize_clin(clin):
return {
"number": clin.number,
"jedi_clin_type": clin.jedi_clin_type,
"period_of_performance": {
"start_date": clin.start_date,
"end_date": clin.end_date,
},
"total_value": clin.total_amount,
"total_obligated_funds": clin.obligated_amount,
"expended_funds": (
clin.obligated_amount * Decimal(self.MOCK_PERCENT_EXPENDED_FUNDS)
),
@classmethod
def _get_application_monthly_totals(cls, application):
"""
returns a dictionary that represents spending totals for an application
and its environments e.g.
{
name
this_month
last_month
total
environments: [
{
name
this_month
last_month
total
}
]
}
return sorted_task_orders(
"""
environments = sorted(
[
{
"id": task_order.id,
"number": task_order.number,
"clins": sorted_clins(
[serialize_clin(clin) for clin in task_order.clins]
),
}
for task_order in portfolio.task_orders
if task_order.is_expired
]
cls._get_environment_monthly_totals(env)
for env in application["environments"]
],
key=lambda env: env["name"],
)
return {
"name": application["name"],
"this_month": sum(env["this_month"] for env in environments),
"last_month": sum(env["last_month"] for env in environments),
"total": sum(env["total"] for env in environments),
"environments": environments,
}
@classmethod
def get_spending_by_JEDI_clin(cls, portfolio):
"""
returns an dictionary of spending per JEDI CLIN for a portfolio
{
jedi_clin: {
invoiced
estimated
},
}
"""
if portfolio.name in cls.FIXTURE_SPEND_DATA:
CLIN_spend_dict = defaultdict(lambda: defaultdict(Decimal))
for application in cls.FIXTURE_SPEND_DATA[portfolio.name]["applications"]:
for environment in application["environments"]:
for clin, spend in environment["spending"]["this_month"].items():
CLIN_spend_dict[clin]["estimated"] += Decimal(spend)
for clin, spend in environment["spending"]["total"].items():
CLIN_spend_dict[clin]["invoiced"] += Decimal(spend)
return CLIN_spend_dict
return {}

View File

@ -1,15 +1,44 @@
from flask import current_app
from itertools import groupby
class Reports:
@classmethod
def monthly_totals(cls, portfolio):
return current_app.csp.reports.monthly_totals(portfolio)
def monthly_spending(cls, portfolio):
return current_app.csp.reports.get_portfolio_monthly_spending(portfolio)
@classmethod
def expired_task_orders(cls, portfolio):
return current_app.csp.reports.get_expired_task_orders(portfolio)
return [
task_order for task_order in portfolio.task_orders if task_order.is_expired
]
@classmethod
def obligated_funds_by_JEDI_clin(cls, portfolio):
return current_app.csp.reports.get_obligated_funds_by_JEDI_clin(portfolio)
clin_spending = current_app.csp.reports.get_spending_by_JEDI_clin(portfolio)
active_clins = portfolio.active_clins
for jedi_clin, clins in groupby(
active_clins, key=lambda clin: clin.jedi_clin_type
):
if not clin_spending.get(jedi_clin.name):
clin_spending[jedi_clin.name] = {}
clin_spending[jedi_clin.name]["obligated"] = sum(
clin.obligated_amount for clin in clins
)
output = []
for clin in clin_spending.keys():
invoiced = clin_spending[clin].get("invoiced", 0)
estimated = clin_spending[clin].get("estimated", 0)
obligated = clin_spending[clin].get("obligated", 0)
remaining = obligated - (invoiced + estimated)
output.append(
{
"name": clin,
"invoiced": invoiced,
"estimated": estimated,
"obligated": obligated,
"remaining": remaining,
}
)
return output

View File

@ -5,6 +5,7 @@ from flask import render_template
from jinja2 import contextfilter
from jinja2.exceptions import TemplateNotFound
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
from decimal import DivisionByZero as DivisionByZeroException
def iconSvg(name):
@ -38,6 +39,14 @@ def usPhone(number):
return "+1 ({}) {} - {}".format(phone[0:3], phone[3:6], phone[6:])
def obligatedFundingGraphWidth(values):
numerator, denominator = values
try:
return (numerator / denominator) * 100
except DivisionByZeroException:
return 0
def formattedDate(value, formatter="%m/%d/%Y"):
if value:
return value.strftime(formatter)
@ -76,6 +85,7 @@ def register_filters(app):
app.jinja_env.filters["pageWindow"] = pageWindow
app.jinja_env.filters["renderAuditEvent"] = renderAuditEvent
app.jinja_env.filters["withExtraParams"] = with_extra_params
app.jinja_env.filters["obligatedFundingGraphWidth"] = obligatedFundingGraphWidth
@contextfilter
def translateWithoutCache(context, *kwargs):

View File

@ -65,4 +65,6 @@ class CLIN(Base, mixins.TimestampsMixin):
@property
def is_active(self):
return self.start_date <= date.today() <= self.end_date
return (
self.start_date <= date.today() <= self.end_date
) and self.task_order.signed_at

View File

@ -1,4 +1,4 @@
from datetime import date, datetime, timedelta
from datetime import datetime
from flask import redirect, render_template, url_for, request as http_request, g
@ -35,9 +35,12 @@ def create_portfolio():
@user_can(Permissions.VIEW_PORTFOLIO_REPORTS, message="view portfolio reports")
def reports(portfolio_id):
portfolio = Portfolios.get(g.current_user, portfolio_id)
today = date.today()
current_month = date(int(today.year), int(today.month), 15)
prev_month = current_month - timedelta(days=28)
current_obligated_funds = Reports.obligated_funds_by_JEDI_clin(portfolio)
if any(map(lambda clin: clin["remaining"] < 0, current_obligated_funds)):
flash("insufficient_funds")
# wrapped in str() because the sum of obligated funds returns a Decimal object
total_portfolio_value = str(
sum(
@ -49,12 +52,10 @@ def reports(portfolio_id):
"portfolios/reports/index.html",
portfolio=portfolio,
total_portfolio_value=total_portfolio_value,
current_obligated_funds=Reports.obligated_funds_by_JEDI_clin(portfolio),
current_obligated_funds=current_obligated_funds,
expired_task_orders=Reports.expired_task_orders(portfolio),
monthly_totals=Reports.monthly_totals(portfolio),
current_month=current_month,
prev_month=prev_month,
now=datetime.now(), # mocked datetime of reporting data retrival
monthly_spending=Reports.monthly_spending(portfolio),
retrieved=datetime.now(), # mocked datetime of reporting data retrival
)

View File

@ -96,6 +96,11 @@ MESSAGES = {
"message_template": "<p>Please see below.</p>",
"category": "error",
},
"insufficient_funds": {
"title_template": "Insufficient Funds",
"message_template": "",
"category": "warning",
},
"logged_out": {
"title_template": translate("flash.logged_out"),
"message_template": """

View File

@ -0,0 +1,390 @@
{
"A-Wing": {
"applications": [
{
"name": "LC04",
"environments": [
{
"name": "Integ",
"spending": {
"this_month": {
"JEDI_CLIN_1": 663,
"JEDI_CLIN_2": 397
},
"last_month": {
"JEDI_CLIN_1": 590,
"JEDI_CLIN_2": 829
},
"total": {
"JEDI_CLIN_1": 42467,
"JEDI_CLIN_2": 33873
}
}
},
{
"name": "PreProd",
"spending": {
"this_month": {
"JEDI_CLIN_1": 1000,
"JEDI_CLIN_2": 626
},
"last_month": {
"JEDI_CLIN_1": 685,
"JEDI_CLIN_2": 331
},
"total": {
"JEDI_CLIN_1": 21874,
"JEDI_CLIN_2": 25506
}
}
},
{
"name": "Prod",
"spending": {
"this_month": {
"JEDI_CLIN_1": 856,
"JEDI_CLIN_2": 627
},
"last_month": {
"JEDI_CLIN_1": 921,
"JEDI_CLIN_2": 473
},
"total": {
"JEDI_CLIN_1": 35566,
"JEDI_CLIN_2": 42514
}
}
}
]
},
{
"name": "SF18",
"environments": [
{
"name": "Integ",
"spending": {
"this_month": {
"JEDI_CLIN_1": 777,
"JEDI_CLIN_2": 850
},
"last_month": {
"JEDI_CLIN_1": 584,
"JEDI_CLIN_2": 362
},
"total": {
"JEDI_CLIN_1": 44505,
"JEDI_CLIN_2": 21378
}
}
},
{
"name": "PreProd",
"spending": {
"this_month": {
"JEDI_CLIN_1": 487,
"JEDI_CLIN_2": 733
},
"last_month": {
"JEDI_CLIN_1": 542,
"JEDI_CLIN_2": 999
},
"total": {
"JEDI_CLIN_1": 8713,
"JEDI_CLIN_2": 10586
}
}
},
{
"name": "Prod",
"spending": {
"this_month": {
"JEDI_CLIN_1": 420,
"JEDI_CLIN_2": 503
},
"last_month": {
"JEDI_CLIN_1": 756,
"JEDI_CLIN_2": 941
},
"total": {
"JEDI_CLIN_1": 43003,
"JEDI_CLIN_2": 20601
}
}
}
]
},
{
"name": "Canton",
"environments": [
{
"name": "Prod",
"spending": {
"this_month": {
"JEDI_CLIN_1": 661,
"JEDI_CLIN_2": 599
},
"last_month": {
"JEDI_CLIN_1": 962,
"JEDI_CLIN_2": 383
},
"total": {
"JEDI_CLIN_1": 24501,
"JEDI_CLIN_2": 7551
}
}
}
]
},
{
"name": "BD04",
"environments": [
{
"name": "Integ",
"spending": {
"this_month": {
"JEDI_CLIN_1": 790,
"JEDI_CLIN_2": 513
},
"last_month": {
"JEDI_CLIN_1": 886,
"JEDI_CLIN_2": 991
},
"total": {
"JEDI_CLIN_1": 43684,
"JEDI_CLIN_2": 40196
}
}
},
{
"name": "PreProd",
"spending": {
"this_month": {
"JEDI_CLIN_1": 513,
"JEDI_CLIN_2": 706
},
"last_month": {
"JEDI_CLIN_1": 945,
"JEDI_CLIN_2": 380
},
"total": {
"JEDI_CLIN_1": 28189,
"JEDI_CLIN_2": 9759
}
}
}
]
},
{
"name": "SCV18",
"environments": [
{
"name": "Dev",
"spending": {
"this_month": {
"JEDI_CLIN_1": 933,
"JEDI_CLIN_2": 993
},
"last_month": {
"JEDI_CLIN_1": 319,
"JEDI_CLIN_2": 619
},
"total": {
"JEDI_CLIN_1": 40585,
"JEDI_CLIN_2": 28872
}
}
}
]
},
{
"name": "Crown",
"environments": [
{
"name": "CR Portal Dev",
"spending": {
"this_month": {
"JEDI_CLIN_1": 711,
"JEDI_CLIN_2": 413
},
"last_month": {
"JEDI_CLIN_1": 908,
"JEDI_CLIN_2": 632
},
"total": {
"JEDI_CLIN_1": 18753,
"JEDI_CLIN_2": 4004
}
}
},
{
"name": "CR Staging",
"spending": {
"this_month": {
"JEDI_CLIN_1": 440,
"JEDI_CLIN_2": 918
},
"last_month": {
"JEDI_CLIN_1": 370,
"JEDI_CLIN_2": 472
},
"total": {
"JEDI_CLIN_1": 40602,
"JEDI_CLIN_2": 6834
}
}
},
{
"name": "CR Portal Test 1",
"spending": {
"this_month": {
"JEDI_CLIN_1": 928,
"JEDI_CLIN_2": 796
},
"last_month": {
"JEDI_CLIN_1": 680,
"JEDI_CLIN_2": 312
},
"total": {
"JEDI_CLIN_1": 36058,
"JEDI_CLIN_2": 42375
}
}
},
{
"name": "Jewels Prod",
"spending": {
"this_month": {
"JEDI_CLIN_1": 304,
"JEDI_CLIN_2": 428
},
"last_month": {
"JEDI_CLIN_1": 898,
"JEDI_CLIN_2": 729
},
"total": {
"JEDI_CLIN_1": 3162,
"JEDI_CLIN_2": 49836
}
}
},
{
"name": "Jewels Dev",
"spending": {
"this_month": {
"JEDI_CLIN_1": 498,
"JEDI_CLIN_2": 890
},
"last_month": {
"JEDI_CLIN_1": 506,
"JEDI_CLIN_2": 659
},
"total": {
"JEDI_CLIN_1": 6248,
"JEDI_CLIN_2": 3866
}
}
}
]
}
]
},
"B-Wing": {
"applications": [
{
"name": "NP02",
"environments": [
{
"name": "Integ",
"spending": {
"this_month": {
"JEDI_CLIN_1": 455,
"JEDI_CLIN_2": 746
},
"last_month": {
"JEDI_CLIN_1": 973,
"JEDI_CLIN_2": 504
},
"total": {
"JEDI_CLIN_1": 11493,
"JEDI_CLIN_2": 17751
}
}
},
{
"name": "PreProd",
"spending": {
"this_month": {
"JEDI_CLIN_1": 582,
"JEDI_CLIN_2": 339
},
"last_month": {
"JEDI_CLIN_1": 392,
"JEDI_CLIN_2": 885
},
"total": {
"JEDI_CLIN_1": 41856,
"JEDI_CLIN_2": 46399
}
}
},
{
"name": "Prod",
"spending": {
"this_month": {
"JEDI_CLIN_1": 446,
"JEDI_CLIN_2": 670
},
"last_month": {
"JEDI_CLIN_1": 368,
"JEDI_CLIN_2": 963
},
"total": {
"JEDI_CLIN_1": 10030,
"JEDI_CLIN_2": 29253
}
}
}
]
},
{
"name": "FM",
"environments": [
{
"name": "Integ",
"spending": {
"this_month": {
"JEDI_CLIN_1": 994,
"JEDI_CLIN_2": 573
},
"last_month": {
"JEDI_CLIN_1": 699,
"JEDI_CLIN_2": 418
},
"total": {
"JEDI_CLIN_1": 27881,
"JEDI_CLIN_2": 37092
}
}
},
{
"name": "Prod",
"spending": {
"this_month": {
"JEDI_CLIN_1": 838,
"JEDI_CLIN_2": 839
},
"last_month": {
"JEDI_CLIN_1": 775,
"JEDI_CLIN_2": 946
},
"total": {
"JEDI_CLIN_1": 45007,
"JEDI_CLIN_2": 16197
}
}
}
]
}
]
}
}

View File

@ -1,14 +1,12 @@
import { set } from 'vue/dist/vue'
import { formatDollars } from '../../lib/dollars'
import { set as _set } from 'lodash'
export default {
name: 'spend-table',
props: {
applications: Object,
environments: Object,
currentMonthIndex: String,
prevMonthIndex: String,
applications: Array,
},
data: function() {
@ -18,20 +16,16 @@ export default {
},
created: function() {
Object.keys(this.applications).forEach(application => {
set(this.applicationsState[application], 'isVisible', false)
this.applicationsState.forEach(application => {
application.isVisible = false
})
},
methods: {
toggle: function(e, applicationName) {
this.applicationsState = Object.assign(this.applicationsState, {
[applicationName]: Object.assign(
this.applicationsState[applicationName],
{
isVisible: !this.applicationsState[applicationName].isVisible,
}
),
toggle: function(e, applicationIndex) {
set(this.applicationsState, applicationIndex, {
...this.applicationsState[applicationIndex],
isVisible: !this.applicationsState[applicationIndex].isVisible,
})
},

View File

@ -14,6 +14,7 @@ from atst.app import make_config, make_app
from atst.database import db
from atst.models.application import Application
from atst.models.clin import JEDICLINType
from atst.models.environment_role import CSPRole
from atst.domain.application_roles import ApplicationRoles
@ -199,7 +200,22 @@ def add_task_orders_to_portfolio(portfolio):
CLINFactory.build(
task_order=expired_to, start_date=(today - five_days), end_date=yesterday
),
CLINFactory.build(task_order=active_to, start_date=yesterday, end_date=future),
CLINFactory.build(
task_order=active_to,
start_date=yesterday,
end_date=future,
total_amount=1_000_000,
obligated_amount=500_000,
jedi_clin_type=JEDICLINType.JEDI_CLIN_1,
),
CLINFactory.build(
task_order=active_to,
start_date=yesterday,
end_date=future,
total_amount=500_000,
obligated_amount=200_000,
jedi_clin_type=JEDICLINType.JEDI_CLIN_2,
),
]
task_orders = [draft_to, unsigned_to, upcoming_to, expired_to, active_to]
@ -300,9 +316,9 @@ def create_demo_portfolio(name, data):
for mock_application in data["applications"]:
application = Application(
portfolio=portfolio, name=mock_application.name, description=""
portfolio=portfolio, name=mock_application["name"], description=""
)
env_names = [env.name for env in mock_application.environments]
env_names = [env["name"] for env in mock_application["environments"]]
envs = Environments.create_many(portfolio.owner, application, env_names)
db.session.add(application)
db.session.commit()
@ -313,8 +329,8 @@ def seed_db():
amanda = Users.get_by_dod_id("2345678901")
# Create Portfolios for Amanda with mocked reporting data
create_demo_portfolio("A-Wing", MockReportingProvider.REPORT_FIXTURE_MAP["A-Wing"])
create_demo_portfolio("B-Wing", MockReportingProvider.REPORT_FIXTURE_MAP["B-Wing"])
create_demo_portfolio("A-Wing", MockReportingProvider.FIXTURE_SPEND_DATA["A-Wing"])
create_demo_portfolio("B-Wing", MockReportingProvider.FIXTURE_SPEND_DATA["B-Wing"])
tie_interceptor = Portfolios.create(
user=amanda,

View File

@ -73,6 +73,10 @@
color: $color-green;
}
.text-danger {
color: $color-secondary;
}
.user-permission {
font-weight: $font-normal;
}

View File

@ -16,6 +16,16 @@
}
.jedi-clin-funding {
$insufficient-gradient: repeating-linear-gradient(
45deg,
$color-secondary-dark,
$color-secondary-dark 10px,
$color-secondary-darkest 11px,
$color-secondary-darkest 14px
);
$graph-bar-height: 2rem;
padding-top: $gap * 3;
padding-bottom: $gap * 3;
@ -37,14 +47,36 @@
margin: 0;
}
&__meter {
margin: 10px 0;
-moz-transform: scale(-1, 1);
-webkit-transform: scale(-1, 1);
-o-transform: scale(-1, 1);
-ms-transform: scale(-1, 1);
transform: scale(-1, 1);
&__graph {
width: 100%;
height: $graph-bar-height;
margin-top: $gap * 2;
margin-bottom: $gap * 2;
display: flex;
&-bar {
height: 100%;
display: block;
float: left;
margin-right: $gap / 2;
&:last-child {
margin-right: 0;
}
&--invoiced {
background: $color-green;
}
&--estimated {
background: $color-green-lighter;
}
&--remaining {
background: $color-primary-darkest;
}
&--insufficient {
background: $insufficient-gradient;
}
}
&-values {
display: flex;
@ -52,13 +84,32 @@
}
&__meta {
&--remaining {
margin-left: auto;
text-align: right;
}
margin-right: $gap * 5;
&-header {
@include small-copy;
margin-bottom: 0;
display: flex;
align-items: center;
}
&-key {
height: $graph-bar-height;
width: $graph-bar-height;
margin-right: $gap / 2;
&--invoiced {
background: $color-green;
}
&--estimated {
background: $color-green-lighter;
}
&--remaining {
background: $color-primary-darkest;
}
&--insufficient {
background: $insufficient-gradient;
}
}
&-value {
margin-bottom: 0;

View File

@ -3,9 +3,6 @@
<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) %}
@ -24,12 +21,7 @@
) }}
{% 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>
<spend-table v-bind:applications='{{ monthly_spending | tojson }}' inline-template>
<div class="responsive-table-wrapper">
<table class="atat-table">
<thead>
@ -41,41 +33,41 @@
</tr>
</thead>
<tbody>
<template v-for='(application, name) in applicationsState'>
<template v-for='(application, applicationIndex) in applicationsState'>
<tr>
<td>
<button v-on:click='toggle($event, name)' class='icon-link icon-link--large'>
<span v-html='name'></span>
<button v-on:click='toggle($event, applicationIndex)' class='icon-link icon-link--large'>
<span v-html='application.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>
<span v-html='formatDollars(application.this_month || 0)'></span>
</td>
<td class="table-cell--align-right">
<span v-html='formatDollars(application[prevMonthIndex] || 0)'></span>
<span v-html='formatDollars(application.last_month || 0)'></span>
</td>
<td class="table-cell--align-right">
<span v-html='formatDollars(application["total_spend_to_date"])'></span>
<span v-html='formatDollars(application.total)'></span>
</td>
</tr>
<tr
v-for='(environment, envName, index) in environments[name]'
v-show='application.isVisible'
v-bind:class="[ index == Object.keys(environments[name]).length -1 ? 'reporting-spend-table__env-row--last' : '']"
v-for='(environment, index) in application.environments'
v-bind:class="[ index == application.environments.length -1 ? 'reporting-spend-table__env-row--last' : '']"
>
<td>
<span class="reporting-spend-table__env-row-label" v-html='envName'></span>
<span class="reporting-spend-table__env-row-label" v-html='environment.name'></span>
</td>
<td class="table-cell--align-right">
<span v-html='formatDollars(environment[currentMonthIndex] || 0)'></span>
<span v-html='formatDollars(environment.this_month || 0)'></span>
</td>
<td class="table-cell--align-right">
<span v-html='formatDollars(environment[prevMonthIndex] || 0)'></span>
<span v-html='formatDollars(environment.last_month || 0)'></span>
</td>
<td class="table-cell--align-right">
<span v-html='formatDollars(environment["total_spend_to_date"])'></span>
<span v-html='formatDollars(environment.total)'></span>
</td>
</tr>
</template>

View File

@ -35,13 +35,13 @@
<div>{{ ("{}".format(clin.jedi_clin_type) | translate)[15:] }}</div>
</td>
<td>
{{ clin.period_of_performance.start_date | formattedDate(formatter="%b %d, %Y") }}
{{ clin.start_date | formattedDate(formatter="%b %d, %Y") }}
-
{{ clin.period_of_performance.end_date | formattedDate(formatter="%b %d, %Y") }}
{{ clin.end_date | formattedDate(formatter="%b %d, %Y") }}
</td>
<td>{{ clin.total_value | dollars }}</td>
<td>{{ clin.total_obligated_funds | dollars }}</td>
<td>{{ (clin.total_obligated_funds - clin.expended_funds) | dollars }}</td>
<td>{{ clin.total_amount | dollars }}</td>
<td>{{ clin.obligated_amount | dollars }}</td>
<td>{{ 0 | dollars }}</td>
<tr>
{% endfor %}
{% endfor %}

View File

@ -5,7 +5,9 @@
{% block portfolio_content %}
{{ StickyCTA("Reports") }}
<div class="portfolio-reports col col--grow">
{% include "fragments/flash.html" %}
<p class="row estimate-warning">{{ "portfolios.reports.estimate_warning" | translate }}</p>
{% include "portfolios/reports/portfolio_summary.html" %}
<hr>

View File

@ -3,28 +3,61 @@
<section>
<header class="reporting-section-header">
<h2 class="reporting-section-header__header">Current Obligated funds</h2>
<span class="reporting-section-header__subheader">As of {{ now | formattedDate(formatter="%B %d, %Y at %H:%M") }}</span>
<span class="reporting-section-header__subheader">As of {{ retrieved | formattedDate(formatter="%B %d, %Y at %H:%M") }}</span>
</header>
<div class='panel'>
<div class='panel__content jedi-clin-funding'>
{% for JEDI_clin, funds in current_obligated_funds.items() %}
{% set remaining_funds = (funds["obligated_funds"] - funds["expended_funds"]) %}
{% for JEDI_clin in current_obligated_funds | sort(attribute='name')%}
<div class="jedi-clin-funding__clin-wrapper">
<h3 class="h5 jedi-clin-funding__header">
{{ "JEDICLINType.{}".format(JEDI_clin) | translate }}
{{ "JEDICLINType.{}".format(JEDI_clin.name) | translate }}
</h3>
<p class="jedi-clin-funding__subheader">Total obligated amount: {{ funds["obligated_funds"] | dollars }}</p>
<meter class="jedi-clin-funding__meter" value='{{remaining_funds}}' min='0' max='{{ funds["obligated_funds"] }}' title='{{ JEDI_clin }}'>
<div class='jedi-clin-funding__meter-fallback' style='width:{{ (funds["expended_funds"] / funds["obligated_funds"]) * 100 }}%;'></div>
</meter>
<div class="jedi-clin-funding__meter-values">
<p class="jedi-clin-funding__subheader">Total obligated amount: {{ JEDI_clin.obligated | dollars }}</p>
<div class="jedi-clin-funding__graph">
{% if JEDI_clin.remaining < 0 %}
<span style="width:100%" class="jedi-clin-funding__graph-bar jedi-clin-funding__graph-bar--insufficient"></span>
{% else %}
{% set invoiced_width = (JEDI_clin.invoiced, JEDI_clin.obligated) | obligatedFundingGraphWidth %}
{% if invoiced_width %}
<span style="width:{{ invoiced_width }}%"
class="jedi-clin-funding__graph-bar jedi-clin-funding__graph-bar--invoiced">
</span>
{% endif %}
{% set estimated_width = (JEDI_clin.estimated, JEDI_clin.obligated) | obligatedFundingGraphWidth %}
{% if estimated_width %}
<span style="width:{{ (JEDI_clin.estimated, JEDI_clin.obligated) | obligatedFundingGraphWidth }}%"
class="jedi-clin-funding__graph-bar jedi-clin-funding__graph-bar--estimated">
</span>
{% endif %}
<span style="width:{{ (JEDI_clin.remaining, JEDI_clin.obligated) | obligatedFundingGraphWidth }}%"
class="jedi-clin-funding__graph-bar jedi-clin-funding__graph-bar--remaining">
</span>
{% endif %}
</div>
<div class="jedi-clin-funding__graph-values">
<div class="jedi-clin-funding__meta">
<p class="jedi-clin-funding__meta-header">Funds expended:</p>
<p class="h3 jedi-clin-funding__meta-value">{{ funds["expended_funds"] | dollars }}</p>
<p class="jedi-clin-funding__meta-header">
<span class="jedi-clin-funding__meta-key jedi-clin-funding__meta-key--invoiced"></span>
Invoiced expended funds:
</p>
<p class="h3 jedi-clin-funding__meta-value">{{ JEDI_clin.invoiced | dollars }}</p>
</div>
<div class="jedi-clin-funding__meta jedi-clin-funding__meta--remaining">
<p class="jedi-clin-funding__meta-header">Remaining funds:</p>
<p class="h3 jedi-clin-funding__meta-value">{{ remaining_funds | dollars }}</p>
<div class="jedi-clin-funding__meta">
<p class="jedi-clin-funding__meta-header">
<span class="jedi-clin-funding__meta-key jedi-clin-funding__meta-key--estimated"></span>
Estimated expended funds:
</p>
<p class="h3 jedi-clin-funding__meta-value">{{ JEDI_clin.estimated | dollars }}</p>
</div>
<div class="jedi-clin-funding__meta">
<p class="jedi-clin-funding__meta-header">
<span class="jedi-clin-funding__meta-key jedi-clin-funding__meta-key--{{"remaining" if JEDI_clin.remaining > 0 else "insufficient"}}"></span>
Remaining funds:
</p>
<p class="h3 jedi-clin-funding__meta-value {% if JEDI_clin.remaining < 0 %}text-danger{% endif %}">{{ JEDI_clin.remaining | dollars }}</p>
</div>
</div>
</div>

View File

View File

@ -0,0 +1,50 @@
from atst.domain.csp.reports import MockReportingProvider
def test_get_environment_monthly_totals():
environment = {
"name": "Test Environment",
"spending": {
"this_month": {"JEDI_CLIN_1": 100, "JEDI_CLIN_2": 100},
"last_month": {"JEDI_CLIN_1": 200, "JEDI_CLIN_2": 200},
"total": {"JEDI_CLIN_1": 1000, "JEDI_CLIN_2": 1000},
},
}
totals = MockReportingProvider._get_environment_monthly_totals(environment)
assert totals == {
"name": "Test Environment",
"this_month": 200,
"last_month": 400,
"total": 2000,
}
def test_get_application_monthly_totals():
application = {
"name": "Test Application",
"environments": [
{
"name": "Z",
"spending": {
"this_month": {"JEDI_CLIN_1": 50, "JEDI_CLIN_2": 50},
"last_month": {"JEDI_CLIN_1": 150, "JEDI_CLIN_2": 150},
"total": {"JEDI_CLIN_1": 250, "JEDI_CLIN_2": 250},
},
},
{
"name": "A",
"spending": {
"this_month": {"JEDI_CLIN_1": 100, "JEDI_CLIN_2": 100},
"last_month": {"JEDI_CLIN_1": 200, "JEDI_CLIN_2": 200},
"total": {"JEDI_CLIN_1": 1000, "JEDI_CLIN_2": 1000},
},
},
],
}
totals = MockReportingProvider._get_application_monthly_totals(application)
assert totals["name"] == "Test Application"
assert totals["this_month"] == 300
assert totals["last_month"] == 700
assert totals["total"] == 2500
assert [env["name"] for env in totals["environments"]] == ["A", "Z"]

View File

@ -1,22 +1,8 @@
from atst.domain.reports import Reports
from tests.factories import *
# this is sketched out until we do real reporting
def test_monthly_totals():
pass
# this is sketched out until we do real reporting
def test_current_obligated_funds():
pass
# this is sketched out until we do real reporting
# TODO: Implement when we get real reporting data
def test_expired_task_orders():
pass
# this is sketched out until we do real reporting
# TODO: Implement when we get real reporting data
def test_obligated_funds_by_JEDI_clin():
pass