From 6f6d3720bce8a846b26e840974c883f8e22e338d Mon Sep 17 00:00:00 2001 From: Philip Kalinsky Date: Fri, 7 Feb 2020 12:05:02 -0500 Subject: [PATCH 01/10] class method to export portfolio data as dictionary as an arg to state machine --- atst/jobs.py | 2 +- atst/models/portfolio.py | 45 ++++++++++++++++++++++++++ atst/models/portfolio_state_machine.py | 2 ++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/atst/jobs.py b/atst/jobs.py index 6a12d423..46575a3d 100644 --- a/atst/jobs.py +++ b/atst/jobs.py @@ -145,7 +145,7 @@ def do_work(fn, task, csp, **kwargs): def do_provision_portfolio(csp: CloudProviderInterface, portfolio_id=None): portfolio = Portfolios.get_for_update(portfolio_id) fsm = Portfolios.get_or_create_state_machine(portfolio) - fsm.trigger_next_transition() + fsm.trigger_next_transition(csp_data=portfolio.to_dictionary()) @celery.task(bind=True, base=RecordFailure) diff --git a/atst/models/portfolio.py b/atst/models/portfolio.py index 5a8f0f1e..caef5b1b 100644 --- a/atst/models/portfolio.py +++ b/atst/models/portfolio.py @@ -1,3 +1,5 @@ +import re + from sqlalchemy import Column, String from sqlalchemy.orm import relationship from sqlalchemy.types import ARRAY @@ -6,6 +8,7 @@ from itertools import chain from atst.models.base import Base import atst.models.types as types import atst.models.mixins as mixins +from atst.models.task_order import TaskOrder from atst.models.portfolio_role import PortfolioRole, Status as PortfolioRoleStatus from atst.domain.permission_sets import PermissionSets from atst.utils import first_or_none @@ -153,6 +156,48 @@ class Portfolio( def application_id(self): return None + def to_dictionary(self): + ppoc = self.owner + user_id = f"{ppoc.first_name[0]}{ppoc.last_name}".lower() + domain_name = re.sub("[^0-9a-zA-Z]+", "", self.name).lower() + portfolio_data = { + "user_id": user_id, + "password": "jklfsdNCVD83nklds2#202", # pragma: allowlist secret + "domain_name": domain_name, + "first_name": ppoc.first_name, + "last_name": ppoc.last_name, + "country_code": "US", + "password_recovery_email_address": ppoc.email, + "address": { # TODO: TBD if we're sourcing this from data or config + "company_name": "", + "address_line_1": "", + "city": "", + "region": "", + "country": "", + "postal_code": "", + }, + "billing_profile_display_name": "My Billing Profile", + } + + try: + initial_task_order: TaskOrder = self.task_orders[0] + initial_clin = initial_task_order.sorted_clins[0] + portfolio_data.update( + { + "initial_clin_amount": initial_clin.obligated_amount, + "initial_clin_start_date": initial_clin.start_date.strftime( + "%Y/%m/%d" + ), + "initial_clin_end_date": initial_clin.end_date.strftime("%Y/%m/%d"), + "initial_clin_type": initial_clin.number, + "initial_task_order_id": initial_task_order.number, + } + ) + except IndexError: + pass + + return portfolio_data + def __repr__(self): return "".format( self.name, self.user_count, self.id diff --git a/atst/models/portfolio_state_machine.py b/atst/models/portfolio_state_machine.py index f5c1a461..e154b71a 100644 --- a/atst/models/portfolio_state_machine.py +++ b/atst/models/portfolio_state_machine.py @@ -99,6 +99,8 @@ class PortfolioStateMachine( def trigger_next_transition(self, **kwargs): state_obj = self.machine.get_state(self.state) + csp_data = kwargs.get("csp_data", {}) + if state_obj.is_system: if self.current_state in (FSMStates.UNSTARTED, FSMStates.STARTING): # call the first trigger availabe for these two system states From a2201a0edf06e43c1e7abc6ede89612470255b15 Mon Sep 17 00:00:00 2001 From: Philip Kalinsky Date: Fri, 7 Feb 2020 14:00:16 -0500 Subject: [PATCH 02/10] portfolio data dict. domain name uses random string suffix. do not set the password --- atst/models/portfolio.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/atst/models/portfolio.py b/atst/models/portfolio.py index caef5b1b..2d4f8d80 100644 --- a/atst/models/portfolio.py +++ b/atst/models/portfolio.py @@ -1,9 +1,11 @@ import re +from string import ascii_lowercase, digits +from random import choices +from itertools import chain from sqlalchemy import Column, String from sqlalchemy.orm import relationship from sqlalchemy.types import ARRAY -from itertools import chain from atst.models.base import Base import atst.models.types as types @@ -159,10 +161,12 @@ class Portfolio( def to_dictionary(self): ppoc = self.owner user_id = f"{ppoc.first_name[0]}{ppoc.last_name}".lower() - domain_name = re.sub("[^0-9a-zA-Z]+", "", self.name).lower() + + domain_name = re.sub("[^0-9a-zA-Z]+", "", self.name).lower() + \ + ''.join(choices(ascii_lowercase + digits, k=4)) portfolio_data = { "user_id": user_id, - "password": "jklfsdNCVD83nklds2#202", # pragma: allowlist secret + "password": "", "domain_name": domain_name, "first_name": ppoc.first_name, "last_name": ppoc.last_name, @@ -176,7 +180,7 @@ class Portfolio( "country": "", "postal_code": "", }, - "billing_profile_display_name": "My Billing Profile", + "billing_profile_display_name": "ATAT Billing Profile", } try: From 91d3795a92ae6827710c8583cb76130c3000ccee Mon Sep 17 00:00:00 2001 From: tomdds Date: Tue, 11 Feb 2020 13:21:53 -0500 Subject: [PATCH 03/10] Fix formatting issue --- atst/models/portfolio.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/atst/models/portfolio.py b/atst/models/portfolio.py index 8c9694a6..ffa674c3 100644 --- a/atst/models/portfolio.py +++ b/atst/models/portfolio.py @@ -1,5 +1,5 @@ import re -from string import ascii_lowercase, digits +from string import ascii_lowercase, digits from random import choices from itertools import chain @@ -168,8 +168,9 @@ class Portfolio( ppoc = self.owner user_id = f"{ppoc.first_name[0]}{ppoc.last_name}".lower() - domain_name = re.sub("[^0-9a-zA-Z]+", "", self.name).lower() + \ - ''.join(choices(ascii_lowercase + digits, k=4)) + domain_name = re.sub("[^0-9a-zA-Z]+", "", self.name).lower() + "".join( + choices(ascii_lowercase + digits, k=4) + ) portfolio_data = { "user_id": user_id, "password": "", From 6079e01fb074a9df1988540f0749b990572ca27b Mon Sep 17 00:00:00 2001 From: tomdds Date: Tue, 11 Feb 2020 13:23:11 -0500 Subject: [PATCH 04/10] Ensure non-none value for csp_data state machine arg --- atst/models/portfolio_state_machine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/atst/models/portfolio_state_machine.py b/atst/models/portfolio_state_machine.py index 96192d18..24a3fefc 100644 --- a/atst/models/portfolio_state_machine.py +++ b/atst/models/portfolio_state_machine.py @@ -99,7 +99,7 @@ class PortfolioStateMachine( def trigger_next_transition(self, **kwargs): state_obj = self.machine.get_state(self.state) - csp_data = kwargs.get("csp_data", {}) + kwargs["csp_data"] = kwargs.get("csp_data", {}) if state_obj.is_system: if self.current_state in (FSMStates.UNSTARTED, FSMStates.STARTING): From e4e6c4d2777268c500c8a3e2feddfb76ab7e99fe Mon Sep 17 00:00:00 2001 From: leigh-mil Date: Mon, 10 Feb 2020 13:53:29 -0500 Subject: [PATCH 05/10] Add DoD ID tooltip to app member and portfolio member forms --- styles/elements/_tooltip.scss | 5 +++++ templates/applications/fragments/member_form_fields.html | 5 +++-- templates/components/tooltip.html | 2 +- templates/portfolios/fragments/member_form_fields.html | 3 ++- translations.yaml | 1 + 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/styles/elements/_tooltip.scss b/styles/elements/_tooltip.scss index 46f1146b..b86506eb 100644 --- a/styles/elements/_tooltip.scss +++ b/styles/elements/_tooltip.scss @@ -95,4 +95,9 @@ .icon { @include icon-size(16); } + + &--tight { + margin: 0; + padding: 0; + } } diff --git a/templates/applications/fragments/member_form_fields.html b/templates/applications/fragments/member_form_fields.html index dd91dd5d..cb9a5c31 100644 --- a/templates/applications/fragments/member_form_fields.html +++ b/templates/applications/fragments/member_form_fields.html @@ -1,7 +1,8 @@ {% from "components/alert.html" import Alert %} {% from "components/checkbox_input.html" import CheckboxInput %} -{% from "components/text_input.html" import TextInput %} {% from "components/phone_input.html" import PhoneInput %} +{% from "components/text_input.html" import TextInput %} +{% from "components/tooltip.html" import Tooltip %} {% macro EnvRoleInput(sub_form, member_role_id=None) %} {% set role = sub_form.role.data if not sub_form.disabled.data else "Access Suspended" %} @@ -121,6 +122,6 @@ {{ TextInput(member_form.email, validation='email', optional=False) }} {{ PhoneInput(member_form.phone_number, member_form.phone_ext)}} {{ TextInput(member_form.dod_id, validation='dodId', optional=False) }} - {{ "forms.new_member.dod_help" | translate }} + {{ "forms.new_member.dod_help" | translate }} {{ Tooltip("forms.new_member.dod_text"|translate, title="", classes="icon-tooltip--tight") }} {% endmacro %} diff --git a/templates/components/tooltip.html b/templates/components/tooltip.html index 052299f6..bec2a31a 100644 --- a/templates/components/tooltip.html +++ b/templates/components/tooltip.html @@ -2,7 +2,7 @@ {% macro Tooltip(message,title='Help', classes="") %} - diff --git a/templates/portfolios/fragments/member_form_fields.html b/templates/portfolios/fragments/member_form_fields.html index 48642d2b..7e0998c2 100644 --- a/templates/portfolios/fragments/member_form_fields.html +++ b/templates/portfolios/fragments/member_form_fields.html @@ -2,6 +2,7 @@ {% from "components/icon.html" import Icon %} {% from "components/phone_input.html" import PhoneInput %} {% from "components/text_input.html" import TextInput %} +{% from "components/tooltip.html" import Tooltip %} {% macro PermsFields(form, member_role_id=None) %}

Set Portfolio Permissions

@@ -32,6 +33,6 @@ {{ TextInput(member_form.email, validation='email', optional=False) }} {{ PhoneInput(member_form.phone_number, member_form.phone_ext)}} {{ TextInput(member_form.dod_id, validation='dodId', optional=False) }} - {{ "forms.new_member.dod_help" | translate }} + {{ "forms.new_member.dod_help" | translate }} {{ Tooltip("forms.new_member.dod_text"|translate, title="", classes="icon-tooltip--tight") }} {% endmacro %} diff --git a/translations.yaml b/translations.yaml index b80f5dca..c3d73c3a 100644 --- a/translations.yaml +++ b/translations.yaml @@ -246,6 +246,7 @@ forms: description: Add, remove and edit applications in this Portfolio. dod_id_label: DoD ID dod_help: How do I find out the DoD ID? + dod_text: "An individual's DOD ID (formerly known as EDIPI) number can be found on the back of their CAC. Access to this system requires a valid CAC and DOD ID." email_label: Email address first_name_label: First name funding: From cb4bcc593bc8aabccc4247a72604351afc0597e2 Mon Sep 17 00:00:00 2001 From: leigh-mil Date: Tue, 11 Feb 2020 11:19:47 -0500 Subject: [PATCH 06/10] Use .icon-tooltip--tight across the site instead of element specific classes --- styles/components/_portfolio_layout.scss | 4 ---- templates/portfolios/reports/portfolio_summary.html | 6 +++--- templates/task_orders/fragments/task_order_view.html | 6 +++--- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/styles/components/_portfolio_layout.scss b/styles/components/_portfolio_layout.scss index 4a45cc5f..06fecf63 100644 --- a/styles/components/_portfolio_layout.scss +++ b/styles/components/_portfolio_layout.scss @@ -513,10 +513,6 @@ } &__header { margin: 0; - &-icon { - margin: 0; - padding: 0; - } } &__value { font-size: $lead-font-size; diff --git a/templates/portfolios/reports/portfolio_summary.html b/templates/portfolios/reports/portfolio_summary.html index 3395a205..a13ad84a 100644 --- a/templates/portfolios/reports/portfolio_summary.html +++ b/templates/portfolios/reports/portfolio_summary.html @@ -6,14 +6,14 @@
{{ "portfolios.reports.total_value.header" | translate }} - {{Tooltip(("portfolios.reports.total_value.tooltip" | translate), title="", classes="summary-item__header-icon")}} + {{Tooltip(("portfolios.reports.total_value.tooltip" | translate), title="", classes="icon-tooltip--tight")}}

{{ total_portfolio_value | dollars }}

{{ "portfolios.reports.duration.header" | translate }} - {{Tooltip(("portfolios.reports.duration.tooltip" | translate), title="", classes="summary-item__header-icon")}} + {{Tooltip(("portfolios.reports.duration.tooltip" | translate), title="", classes="icon-tooltip--tight")}}
{% set earliest_pop_start_date, latest_pop_end_date = portfolio.funding_duration %} {% if earliest_pop_start_date and latest_pop_end_date %} @@ -29,7 +29,7 @@
{{ "portfolios.reports.days_remaining.header" | translate }} - {{Tooltip(("portfolios.reports.days_remaining.toolip" | translate), title="", classes="summary-item__header-icon")}} + {{Tooltip(("portfolios.reports.days_remaining.toolip" | translate), title="", classes="icon-tooltip--tight")}}

{{ portfolio.days_to_funding_expiration }} days

diff --git a/templates/task_orders/fragments/task_order_view.html b/templates/task_orders/fragments/task_order_view.html index 2ec2a738..796bbfa6 100644 --- a/templates/task_orders/fragments/task_order_view.html +++ b/templates/task_orders/fragments/task_order_view.html @@ -12,7 +12,7 @@

{{ 'task_orders.summary.total' | translate }} - {{ Tooltip(("task_orders.review.tooltip.total_value" | translate), title="", classes="summary-item__header-icon") }} + {{ Tooltip(("task_orders.review.tooltip.total_value" | translate), title="", classes="icon-tooltip--tight") }}

{{ contract_amount | dollars }} @@ -21,7 +21,7 @@

{{ 'task_orders.summary.obligated' | translate }} - {{ Tooltip(("task_orders.review.tooltip.obligated_funds" | translate), title="", classes="summary-item__header-icon") }} + {{ Tooltip(("task_orders.review.tooltip.obligated_funds" | translate), title="", classes="icon-tooltip--tight") }}

{{ obligated_funds | dollars }} @@ -30,7 +30,7 @@

{{ 'task_orders.summary.expended' | translate }} - {{ Tooltip(("task_orders.review.tooltip.expended_funds" | translate), title="", classes="summary-item__header-icon") }} + {{ Tooltip(("task_orders.review.tooltip.expended_funds" | translate), title="", classes="icon-tooltip--tight") }}

{{ expended_funds | dollars }} From 52489b6bcaec4f5ed490b853025967763ccd31c1 Mon Sep 17 00:00:00 2001 From: graham-dds Date: Tue, 11 Feb 2020 11:28:38 -0500 Subject: [PATCH 07/10] Add upcoming_obligated_funds property to portfolio --- atst/models/portfolio.py | 10 +++++ atst/models/task_order.py | 4 ++ tests/models/test_portfolio.py | 70 ++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) diff --git a/atst/models/portfolio.py b/atst/models/portfolio.py index ffa674c3..c006dd37 100644 --- a/atst/models/portfolio.py +++ b/atst/models/portfolio.py @@ -100,6 +100,16 @@ class Portfolio( (task_order.total_obligated_funds for task_order in self.active_task_orders) ) + @property + def upcoming_obligated_funds(self): + return sum( + ( + task_order.total_obligated_funds + for task_order in self.task_orders + if task_order.is_upcoming + ) + ) + @property def funding_duration(self): """ diff --git a/atst/models/task_order.py b/atst/models/task_order.py index 3ab493a9..c6dda237 100644 --- a/atst/models/task_order.py +++ b/atst/models/task_order.py @@ -87,6 +87,10 @@ class TaskOrder(Base, mixins.TimestampsMixin): def is_expired(self): return self.status == Status.EXPIRED + @property + def is_upcoming(self): + return self.status == Status.UPCOMING + @property def clins_are_completed(self): return all([len(self.clins), (clin.is_completed for clin in self.clins)]) diff --git a/tests/models/test_portfolio.py b/tests/models/test_portfolio.py index af082876..033847a4 100644 --- a/tests/models/test_portfolio.py +++ b/tests/models/test_portfolio.py @@ -7,6 +7,51 @@ from tests.factories import ( random_past_date, ) import datetime +import pendulum +from decimal import Decimal +import pytest + + +@pytest.fixture(scope="function") +def upcoming_task_order(): + return dict( + signed_at=pendulum.today().subtract(days=3), + create_clins=[ + dict( + start_date=pendulum.today().add(days=2), + end_date=pendulum.today().add(days=3), + obligated_amount=Decimal(700.0), + ) + ], + ) + + +@pytest.fixture(scope="function") +def current_task_order(): + return dict( + signed_at=pendulum.today().subtract(days=3), + create_clins=[ + dict( + start_date=pendulum.today().subtract(days=1), + end_date=pendulum.today().add(days=1), + obligated_amount=Decimal(1000.0), + ) + ], + ) + + +@pytest.fixture(scope="function") +def past_task_order(): + return dict( + signed_at=pendulum.today().subtract(days=3), + create_clins=[ + dict( + start_date=pendulum.today().subtract(days=3), + end_date=pendulum.today().subtract(days=2), + obligated_amount=Decimal(500.0), + ) + ], + ) def test_portfolio_applications_excludes_deleted(): @@ -85,3 +130,28 @@ def test_active_task_orders(session): portfolio=portfolio, signed_at=random_past_date(), clins=[CLINFactory.create()] ) assert len(portfolio.active_task_orders) == 1 + + +class TestUpcomingObligatedFunds: + """ + Tests the upcoming_obligated_funds property + """ + + def test_no_task_orders(self): + portfolio = PortfolioFactory() + assert portfolio.upcoming_obligated_funds == Decimal(0) + + def test_with_upcoming(self, upcoming_task_order): + portfolio = PortfolioFactory( + task_orders=[upcoming_task_order, upcoming_task_order] + ) + assert portfolio.upcoming_obligated_funds == Decimal(1400.0) + + def test_with_others( + self, past_task_order, current_task_order, upcoming_task_order + ): + portfolio = PortfolioFactory( + task_orders=[past_task_order, current_task_order, upcoming_task_order] + ) + # Only sums the upcoming task order + assert portfolio.upcoming_obligated_funds == Decimal(700.0) From e553a673b1f2a724fdd9edfe7ac0cb2e278df06a Mon Sep 17 00:00:00 2001 From: graham-dds Date: Tue, 11 Feb 2020 11:30:48 -0500 Subject: [PATCH 08/10] Tests for total_obligated_funds portfolio prop --- tests/models/test_portfolio.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/models/test_portfolio.py b/tests/models/test_portfolio.py index 033847a4..71e11bb3 100644 --- a/tests/models/test_portfolio.py +++ b/tests/models/test_portfolio.py @@ -132,6 +132,31 @@ def test_active_task_orders(session): assert len(portfolio.active_task_orders) == 1 +class TestCurrentObligatedFunds: + """ + Tests the current_obligated_funds property + """ + + def test_no_task_orders(self): + portfolio = PortfolioFactory() + assert portfolio.total_obligated_funds == Decimal(0) + + def test_with_current(self, current_task_order): + portfolio = PortfolioFactory( + task_orders=[current_task_order, current_task_order] + ) + assert portfolio.total_obligated_funds == Decimal(2000.0) + + def test_with_others( + self, past_task_order, current_task_order, upcoming_task_order + ): + portfolio = PortfolioFactory( + task_orders=[past_task_order, current_task_order, upcoming_task_order,] + ) + # Only sums the current task order + assert portfolio.total_obligated_funds == Decimal(1000.0) + + class TestUpcomingObligatedFunds: """ Tests the upcoming_obligated_funds property From 68e7c27aedc59d72f8b97a43fda897ce47f913c6 Mon Sep 17 00:00:00 2001 From: graham-dds Date: Tue, 11 Feb 2020 11:33:23 -0500 Subject: [PATCH 09/10] portfolio value should include upcoming $obligated Previously, we were displaying the total_portfolio_value as just currently obligated funds. Instead, this value should be obligated funds in current task orders and signed, upcoming task orders --- atst/routes/portfolios/index.py | 6 ++++-- translations.yaml | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/atst/routes/portfolios/index.py b/atst/routes/portfolios/index.py index 795d4b70..44cac768 100644 --- a/atst/routes/portfolios/index.py +++ b/atst/routes/portfolios/index.py @@ -50,8 +50,10 @@ def reports(portfolio_id): return render_template( "portfolios/reports/index.html", portfolio=portfolio, - # wrapped in str() because the sum of obligated funds returns a Decimal object - total_portfolio_value=str(portfolio.total_obligated_funds), + # wrapped in str() because this sum returns a Decimal object + total_portfolio_value=str( + portfolio.total_obligated_funds + portfolio.upcoming_obligated_funds + ), current_obligated_funds=current_obligated_funds, expired_task_orders=Reports.expired_task_orders(portfolio), retrieved=datetime.now(), # mocked datetime of reporting data retrival diff --git a/translations.yaml b/translations.yaml index c3d73c3a..6fe84865 100644 --- a/translations.yaml +++ b/translations.yaml @@ -508,7 +508,7 @@ portfolios: estimate_warning: Reports displayed in JEDI are estimates and not a system of record. total_value: header: Total Portfolio Value - tooltip: Total portfolio value is all obligated and projected funds for all task orders in this portfolio. + tooltip: Total portfolio value is all obligated funds for current and upcoming task orders in this portfolio. task_orders: add_new_button: Add New Task Order review: From 321b0a9cd4f28d0cd2dc53c3e6ae91af2195c16e Mon Sep 17 00:00:00 2001 From: graham-dds Date: Tue, 11 Feb 2020 12:04:30 -0500 Subject: [PATCH 10/10] Fix upcoming TO PoP bug in seed_sample Some kwargs were in the wrong places --- script/seed_sample.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/seed_sample.py b/script/seed_sample.py index 72c16c6c..d1cf1c9e 100644 --- a/script/seed_sample.py +++ b/script/seed_sample.py @@ -195,7 +195,7 @@ def add_task_orders_to_portfolio(portfolio): task_order=unsigned_to, start_date=(today - five_days), end_date=today ), CLINFactory.build( - task_order=upcoming_to, start_date=future, end_date=(today + five_days) + task_order=upcoming_to, start_date=(today + five_days), end_date=future ), CLINFactory.build( task_order=expired_to, start_date=(today - five_days), end_date=yesterday