From 05f6b36ece7d875e4e1e2e6ad515abac20b86493 Mon Sep 17 00:00:00 2001 From: dandds Date: Tue, 4 Feb 2020 10:16:02 -0500 Subject: [PATCH 1/8] Update SQL query to find pending portfolios. The query to find portfolios that are pending provisioning is updated to check for: - a period of performance that has started - a portfolio state machine that has an UNSTARTED or one of the CREATED states I left several TODOs to ensure that the orchestration functions correctly for portfolio. --- atst/domain/portfolios/portfolios.py | 22 ++++++------ atst/jobs.py | 2 +- atst/models/portfolio_state_machine.py | 3 ++ tests/domain/test_environments.py | 36 +------------------ tests/domain/test_portfolios.py | 49 ++++++++++++++++++++++---- tests/test_jobs.py | 16 +++++++-- tests/utils.py | 40 +++++++++++++++++++++ 7 files changed, 110 insertions(+), 58 deletions(-) diff --git a/atst/domain/portfolios/portfolios.py b/atst/domain/portfolios/portfolios.py index 1254ac71..b8663730 100644 --- a/atst/domain/portfolios/portfolios.py +++ b/atst/domain/portfolios/portfolios.py @@ -15,6 +15,8 @@ from atst.models import ( Permissions, PortfolioRole, PortfolioRoleStatus, + TaskOrder, + CLIN, ) from .query import PortfoliosQuery, PortfolioStateMachinesQuery @@ -144,7 +146,7 @@ class Portfolios(object): return db.session.query(Portfolio.id) @classmethod - def get_portfolios_pending_provisioning(cls) -> List[UUID]: + def get_portfolios_pending_provisioning(cls, now) -> List[UUID]: """ Any portfolio with a corresponding State Machine that is either: not started yet, @@ -153,22 +155,18 @@ class Portfolios(object): """ results = ( - cls.base_provision_query() + db.session.query(Portfolio.id) .join(PortfolioStateMachine) + .join(TaskOrder) + .join(CLIN) + .filter(Portfolio.deleted == False) + .filter(CLIN.start_date <= now) + .filter(CLIN.end_date > now) .filter( or_( PortfolioStateMachine.state == FSMStates.UNSTARTED, - PortfolioStateMachine.state == FSMStates.FAILED, - PortfolioStateMachine.state == FSMStates.TENANT_FAILED, + PortfolioStateMachine.state.like("%CREATED"), ) ) ) return [id_ for id_, in results] - - # db.session.query(PortfolioStateMachine).\ - # filter( - # or_( - # PortfolioStateMachine.state==FSMStates.UNSTARTED, - # PortfolioStateMachine.state==FSMStates.UNSTARTED, - # ) - # ).all() diff --git a/atst/jobs.py b/atst/jobs.py index 986b2004..ee820066 100644 --- a/atst/jobs.py +++ b/atst/jobs.py @@ -203,7 +203,7 @@ def dispatch_provision_portfolio(self): """ Iterate over portfolios with a corresponding State Machine that have not completed. """ - for portfolio_id in Portfolios.get_portfolios_pending_provisioning(): + for portfolio_id in Portfolios.get_portfolios_pending_provisioning(pendulum.now()): provision_portfolio.delay(portfolio_id=portfolio_id) diff --git a/atst/models/portfolio_state_machine.py b/atst/models/portfolio_state_machine.py index 14e9c01d..f5c1a461 100644 --- a/atst/models/portfolio_state_machine.py +++ b/atst/models/portfolio_state_machine.py @@ -175,11 +175,14 @@ class PortfolioStateMachine( app.logger.info(exc.json()) print(exc.json()) app.logger.info(payload_data) + # TODO: Ensure that failing the stage does not preclude a Celery retry self.fail_stage(stage) + # TODO: catch and handle general CSP exception here except (ConnectionException, UnknownServerException) as exc: app.logger.error( f"CSP api call. Caught exception for {self.__repr__()}.", exc_info=1, ) + # TODO: Ensure that failing the stage does not preclude a Celery retry self.fail_stage(stage) self.finish_stage(stage) diff --git a/tests/domain/test_environments.py b/tests/domain/test_environments.py index ff4b8605..9144c68a 100644 --- a/tests/domain/test_environments.py +++ b/tests/domain/test_environments.py @@ -1,5 +1,4 @@ import pytest -import pendulum from uuid import uuid4 from atst.domain.environments import Environments @@ -14,6 +13,7 @@ from tests.factories import ( EnvironmentRoleFactory, ApplicationRoleFactory, ) +from tests.utils import EnvQueryTest def test_create_environments(): @@ -119,40 +119,6 @@ def test_update_does_not_duplicate_names_within_application(): Environments.update(dupe_env, name) -class EnvQueryTest: - @property - def NOW(self): - return pendulum.now() - - @property - def YESTERDAY(self): - return self.NOW.subtract(days=1) - - @property - def TOMORROW(self): - return self.NOW.add(days=1) - - def create_portfolio_with_clins(self, start_and_end_dates, env_data=None): - env_data = env_data or {} - return PortfolioFactory.create( - applications=[ - { - "name": "Mos Eisley", - "description": "Where Han shot first", - "environments": [{"name": "thebar", **env_data}], - } - ], - task_orders=[ - { - "create_clins": [ - {"start_date": start_date, "end_date": end_date} - for (start_date, end_date) in start_and_end_dates - ] - } - ], - ) - - class TestGetEnvironmentsPendingCreate(EnvQueryTest): def test_with_expired_clins(self, session): self.create_portfolio_with_clins([(self.YESTERDAY, self.YESTERDAY)]) diff --git a/tests/domain/test_portfolios.py b/tests/domain/test_portfolios.py index ff8ccacb..1093253b 100644 --- a/tests/domain/test_portfolios.py +++ b/tests/domain/test_portfolios.py @@ -26,6 +26,7 @@ from tests.factories import ( PortfolioStateMachineFactory, get_all_portfolio_permission_sets, ) +from tests.utils import EnvQueryTest @pytest.fixture(scope="function") @@ -263,10 +264,44 @@ def test_create_state_machine(portfolio): assert fsm -def test_get_portfolios_pending_provisioning(session): - for x in range(5): - portfolio = PortfolioFactory.create() - sm = PortfolioStateMachineFactory.create(portfolio=portfolio) - if x == 2: - sm.state = FSMStates.COMPLETED - assert len(Portfolios.get_portfolios_pending_provisioning()) == 4 +class TestGetPortfoliosPendingCreate(EnvQueryTest): + def test_finds_unstarted(self): + for x in range(5): + if x == 2: + state = "COMPLETED" + else: + state = "UNSTARTED" + self.create_portfolio_with_clins( + [(self.YESTERDAY, self.TOMORROW)], state_machine_status=state + ) + assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 4 + + def test_finds_created(self): + self.create_portfolio_with_clins( + [(self.YESTERDAY, self.TOMORROW)], state_machine_status="TENANT_CREATED" + ) + assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 1 + + def test_does_not_find_failed(self): + self.create_portfolio_with_clins( + [(self.YESTERDAY, self.TOMORROW)], state_machine_status="TENANT_FAILED" + ) + assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 0 + + def test_with_expired_clins(self): + self.create_portfolio_with_clins([(self.YESTERDAY, self.YESTERDAY)]) + assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 0 + + def test_with_active_clins(self): + portfolio = self.create_portfolio_with_clins([(self.YESTERDAY, self.TOMORROW)]) + Portfolios.get_portfolios_pending_provisioning(self.NOW) == [portfolio.id] + + def test_with_future_clins(self): + self.create_portfolio_with_clins([(self.TOMORROW, self.TOMORROW)]) + assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 0 + + def test_with_already_provisioned_env(self): + self.create_portfolio_with_clins( + [(self.YESTERDAY, self.TOMORROW)], env_data={"cloud_id": uuid4().hex} + ) + assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 0 diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 54f4c5dc..f15e9da0 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -286,9 +286,19 @@ def test_create_environment_no_dupes(session, celery_app, celery_worker): assert environment.claimed_until == None -def test_dispatch_provision_portfolio( - csp, session, portfolio, celery_app, celery_worker, monkeypatch -): +def test_dispatch_provision_portfolio(csp, monkeypatch): + portfolio = PortfolioFactory.create( + task_orders=[ + { + "create_clins": [ + { + "start_date": pendulum.now().subtract(days=1), + "end_date": pendulum.now().add(days=1), + } + ] + } + ], + ) sm = PortfolioStateMachineFactory.create(portfolio=portfolio) mock = Mock() monkeypatch.setattr("atst.jobs.provision_portfolio", mock) diff --git a/tests/utils.py b/tests/utils.py index 66bf2b18..665910af 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -5,9 +5,12 @@ from unittest.mock import Mock from OpenSSL import crypto from cryptography.hazmat.backends import default_backend from flask import template_rendered +import pendulum from atst.utils.notification_sender import NotificationSender +import tests.factories as factories + @contextmanager def captured_templates(app): @@ -62,3 +65,40 @@ def make_crl_list(x509_obj, x509_path): issuer = x509_obj.issuer.public_bytes(default_backend()) filename = os.path.basename(x509_path) return [(filename, issuer.hex())] + + +class EnvQueryTest: + @property + def NOW(self): + return pendulum.now() + + @property + def YESTERDAY(self): + return self.NOW.subtract(days=1) + + @property + def TOMORROW(self): + return self.NOW.add(days=1) + + def create_portfolio_with_clins( + self, start_and_end_dates, env_data=None, state_machine_status=None + ): + env_data = env_data or {} + return factories.PortfolioFactory.create( + state=state_machine_status, + applications=[ + { + "name": "Mos Eisley", + "description": "Where Han shot first", + "environments": [{"name": "thebar", **env_data}], + } + ], + task_orders=[ + { + "create_clins": [ + {"start_date": start_date, "end_date": end_date} + for (start_date, end_date) in start_and_end_dates + ] + } + ], + ) From ff842f505177a7a0894134cc4fc1bfa8781f4601 Mon Sep 17 00:00:00 2001 From: graham-dds Date: Tue, 4 Feb 2020 14:16:12 -0500 Subject: [PATCH 2/8] Add cloud method to get reporting data Adds a method to `azure_cloud_provider` to query the Cost Management API for usage data per invoice. For now, this query is relatively static. We're always calling the API at the billing invoice section scope, with the widest timeframe possible (one year), and with the same requested dataset. As the scope of the application's reporting needs changes, this function may change to be more general and/or revert back to the SDK. --- atst/domain/csp/cloud/azure_cloud_provider.py | 40 ++++++++++ atst/domain/csp/cloud/mock_cloud_provider.py | 25 ++++++ atst/domain/csp/cloud/models.py | 31 ++++++++ tests/domain/cloud/test_azure_csp.py | 78 +++++++++++++++++++ 4 files changed, 174 insertions(+) diff --git a/atst/domain/csp/cloud/azure_cloud_provider.py b/atst/domain/csp/cloud/azure_cloud_provider.py index d5ef5204..c54ac7d8 100644 --- a/atst/domain/csp/cloud/azure_cloud_provider.py +++ b/atst/domain/csp/cloud/azure_cloud_provider.py @@ -24,6 +24,7 @@ from .models import ( BillingProfileTenantAccessCSPResult, BillingProfileVerificationCSPPayload, BillingProfileVerificationCSPResult, + CostManagementQueryCSPResult, KeyVaultCredentials, ManagementGroupCSPResponse, ProductPurchaseCSPPayload, @@ -32,6 +33,7 @@ from .models import ( ProductPurchaseVerificationCSPResult, PrincipalAdminRoleCSPPayload, PrincipalAdminRoleCSPResult, + ReportingCSPPayload, TaskOrderBillingCreationCSPPayload, TaskOrderBillingCreationCSPResult, TaskOrderBillingVerificationCSPPayload, @@ -1070,3 +1072,41 @@ class AzureCloudProvider(CloudProviderInterface): hashed = sha256_hex(tenant_id) raw_creds = self.get_secret(hashed) return KeyVaultCredentials(**json.loads(raw_creds)) + + def get_reporting_data(self, payload: ReportingCSPPayload): + """ + Queries the Cost Management API for an invoice section's raw reporting data + + We query at the invoiceSection scope. The full scope path is passed in + with the payload at the `invoice_section_id` key. + """ + creds = self._source_tenant_creds(payload.tenant_id) + token = self._get_sp_token( + payload.tenant_id, creds.tenant_sp_client_id, creds.tenant_sp_key + ) + + if not token: + raise AuthenticationException("Could not retrieve tenant access token") + + headers = {"Authorization": f"Bearer {token}"} + + request_body = { + "type": "Usage", + "timeframe": "Custom", + "timePeriod": {"from": payload.from_date, "to": payload.to_date,}, + "dataset": { + "granularity": "Daily", + "aggregation": {"totalCost": {"name": "PreTaxCost", "function": "Sum"}}, + "grouping": [{"type": "Dimension", "name": "InvoiceId"}], + }, + } + cost_mgmt_url = ( + f"/providers/Microsoft.CostManagement/query?api-version=2019-11-01" + ) + result = self.sdk.requests.post( + f"{self.sdk.cloud.endpoints.resource_manager}{payload.invoice_section_id}{cost_mgmt_url}", + json=request_body, + headers=headers, + ) + if result.ok: + return CostManagementQueryCSPResult(**result.json()) diff --git a/atst/domain/csp/cloud/mock_cloud_provider.py b/atst/domain/csp/cloud/mock_cloud_provider.py index ec730a3b..9ae78e65 100644 --- a/atst/domain/csp/cloud/mock_cloud_provider.py +++ b/atst/domain/csp/cloud/mock_cloud_provider.py @@ -25,12 +25,15 @@ from .models import ( BillingProfileTenantAccessCSPResult, BillingProfileVerificationCSPPayload, BillingProfileVerificationCSPResult, + CostManagementQueryCSPResult, + CostManagementQueryProperties, ProductPurchaseCSPPayload, ProductPurchaseCSPResult, ProductPurchaseVerificationCSPPayload, ProductPurchaseVerificationCSPResult, PrincipalAdminRoleCSPPayload, PrincipalAdminRoleCSPResult, + ReportingCSPPayload, SubscriptionCreationCSPPayload, SubscriptionCreationCSPResult, SubscriptionVerificationCSPPayload, @@ -487,3 +490,25 @@ class MockCloudProvider(CloudProviderInterface): def update_tenant_creds(self, tenant_id, secret): return secret + + def get_reporting_data(self, payload: ReportingCSPPayload): + self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION) + self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION) + self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION) + object_id = str(uuid4()) + + properties = CostManagementQueryProperties( + **dict( + columns=[ + {"name": "PreTaxCost", "type": "Number"}, + {"name": "UsageDate", "type": "Number"}, + {"name": "InvoiceId", "type": "String"}, + {"name": "Currency", "type": "String"}, + ], + rows=[], + ) + ) + + return CostManagementQueryCSPResult( + **dict(name=object_id, properties=properties,) + ) diff --git a/atst/domain/csp/cloud/models.py b/atst/domain/csp/cloud/models.py index 188c2cc7..c31c0704 100644 --- a/atst/domain/csp/cloud/models.py +++ b/atst/domain/csp/cloud/models.py @@ -499,3 +499,34 @@ class UserCSPPayload(BaseCSPPayload): class UserCSPResult(AliasModel): id: str + + +class QueryColumn(AliasModel): + name: str + type: str + + +class CostManagementQueryProperties(AliasModel): + columns: List[QueryColumn] + rows: List[Optional[list]] + + +class CostManagementQueryCSPResult(AliasModel): + name: str + properties: CostManagementQueryProperties + + +class ReportingCSPPayload(BaseCSPPayload): + invoice_section_id: str + from_date: str + to_date: str + + @root_validator(pre=True) + def extract_invoice_section(cls, values): + try: + values["invoice_section_id"] = values["billing_profile_properties"][ + "invoice_sections" + ][0]["invoice_section_id"] + return values + except (KeyError, IndexError): + raise ValueError("Invoice section ID not present in payload") diff --git a/tests/domain/cloud/test_azure_csp.py b/tests/domain/cloud/test_azure_csp.py index eef5620e..2209a27a 100644 --- a/tests/domain/cloud/test_azure_csp.py +++ b/tests/domain/cloud/test_azure_csp.py @@ -5,6 +5,8 @@ from uuid import uuid4 import pytest from tests.factories import ApplicationFactory, EnvironmentFactory from tests.mock_azure import AUTH_CREDENTIALS, mock_azure +import pendulum +import pydantic from atst.domain.csp.cloud import AzureCloudProvider from atst.domain.csp.cloud.models import ( @@ -20,10 +22,12 @@ from atst.domain.csp.cloud.models import ( BillingProfileTenantAccessCSPResult, BillingProfileVerificationCSPPayload, BillingProfileVerificationCSPResult, + CostManagementQueryCSPResult, ProductPurchaseCSPPayload, ProductPurchaseCSPResult, ProductPurchaseVerificationCSPPayload, ProductPurchaseVerificationCSPResult, + ReportingCSPPayload, SubscriptionCreationCSPPayload, SubscriptionCreationCSPResult, SubscriptionVerificationCSPPayload, @@ -718,3 +722,77 @@ def test_create_subscription_verification(mock_azure: AzureCloudProvider): payload ) assert result.subscription_id == "60fbbb72-0516-4253-ab18-c92432ba3230" + + +def test_get_reporting_data(mock_azure: AzureCloudProvider): + mock_result = Mock() + mock_result.json.return_value = { + "eTag": None, + "id": "providers/Microsoft.Billing/billingAccounts/52865e4c-52e8-5a6c-da6b-c58f0814f06f:7ea5de9d-b8ce-4901-b1c5-d864320c7b03_2019-05-31/billingProfiles/XQDJ-6LB4-BG7-TGB/invoiceSections/P73M-XC7J-PJA-TGB/providers/Microsoft.CostManagement/query/e82d0cda-2ffb-4476-a98a-425c83c216f9", + "location": None, + "name": "e82d0cda-2ffb-4476-a98a-425c83c216f9", + "properties": { + "columns": [ + {"name": "PreTaxCost", "type": "Number"}, + {"name": "UsageDate", "type": "Number"}, + {"name": "InvoiceId", "type": "String"}, + {"name": "Currency", "type": "String"}, + ], + "nextLink": None, + "rows": [], + }, + "sku": None, + "type": "Microsoft.CostManagement/query", + } + mock_result.ok = True + mock_azure.sdk.requests.post.return_value = mock_result + mock_azure = mock_get_secret(mock_azure) + + # Subset of a profile's CSP data that we care about for reporting + csp_data = { + "tenant_id": "6d2d2d6c-a6d6-41e1-8bb1-73d11475f8f4", + "billing_profile_properties": { + "invoice_sections": [ + { + "invoice_section_id": "providers/Microsoft.Billing/billingAccounts/52865e4c-52e8-5a6c-da6b-c58f0814f06f:7ea5de9d-b8ce-4901-b1c5-d864320c7b03_2019-05-31/billingProfiles/XQDJ-6LB4-BG7-TGB/invoiceSections/P73M-XC7J-PJA-TGB", + } + ], + }, + } + + data: CostManagementQueryCSPResult = mock_azure.get_reporting_data( + ReportingCSPPayload( + from_date=pendulum.now().subtract(years=1).add(days=1).format("YYYY-MM-DD"), + to_date=pendulum.now().format("YYYY-MM-DD"), + **csp_data, + ) + ) + + assert isinstance(data, CostManagementQueryCSPResult) + assert data.name == "e82d0cda-2ffb-4476-a98a-425c83c216f9" + assert len(data.properties.columns) == 4 + + +def test_get_reporting_data_malformed_payload(mock_azure: AzureCloudProvider): + mock_result = Mock() + mock_result.ok = True + mock_azure.sdk.requests.post.return_value = mock_result + mock_azure = mock_get_secret(mock_azure) + + # Malformed csp_data payloads that should throw pydantic validation errors + index_error = { + "tenant_id": "6d2d2d6c-a6d6-41e1-8bb1-73d11475f8f4", + "billing_profile_properties": {"invoice_sections": [],}, + } + key_error = { + "tenant_id": "6d2d2d6c-a6d6-41e1-8bb1-73d11475f8f4", + "billing_profile_properties": {"invoice_sections": [{}],}, + } + + for malformed_payload in [key_error, index_error]: + with pytest.raises(pydantic.ValidationError): + assert mock_azure.get_reporting_data( + ReportingCSPPayload( + from_date="foo", to_date="bar", **malformed_payload, + ) + ) From 8e2870b62f77359a88cb831c332a45cc5dd4b59b Mon Sep 17 00:00:00 2001 From: leigh-mil Date: Tue, 4 Feb 2020 12:02:04 -0500 Subject: [PATCH 3/8] Add max width to CTA footer in the TO builder form - Created a new mixin (ExpandSidenavMixin) that sets the defaultVisible prop that can be used in both the root component and the SidenavToggler. This makes it so that the Root knows whether or not the sidenav is collapsed or expanded, so then child components can use this to calculate margins/paddings/offsets/etc. - Added new classes for the .action-group-footer that set the elements padding based on whether or not the sidenav is expanded. - Added a nested div (.action-group-footer--container) inside .action-group-footer which sets the max width --- js/components/sidenav_toggler.js | 21 +++++---------- js/index.js | 12 +++++++++ js/mixins/expand_sidenav.js | 15 +++++++++++ styles/elements/_action_group.scss | 35 +++++++++++++++++-------- templates/task_orders/builder_base.html | 10 ++++--- 5 files changed, 63 insertions(+), 30 deletions(-) create mode 100644 js/mixins/expand_sidenav.js diff --git a/js/components/sidenav_toggler.js b/js/components/sidenav_toggler.js index faba4c3b..11717849 100644 --- a/js/components/sidenav_toggler.js +++ b/js/components/sidenav_toggler.js @@ -1,30 +1,21 @@ +import ExpandSidenavMixin from '../mixins/expand_sidenav' import ToggleMixin from '../mixins/toggle' -const cookieName = 'expandSidenav' - export default { name: 'sidenav-toggler', - mixins: [ToggleMixin], + mixins: [ExpandSidenavMixin, ToggleMixin], - props: { - defaultVisible: { - type: Boolean, - default: function() { - if (document.cookie.match(cookieName)) { - return !!document.cookie.match(cookieName + ' *= *true') - } else { - return true - } - }, - }, + mounted: function() { + this.$parent.$emit('sidenavToggle', this.isVisible) }, methods: { toggle: function(e) { e.preventDefault() this.isVisible = !this.isVisible - document.cookie = cookieName + '=' + this.isVisible + '; path=/' + document.cookie = this.cookieName + '=' + this.isVisible + '; path=/' + this.$parent.$emit('sidenavToggle', this.isVisible) }, }, } diff --git a/js/index.js b/js/index.js index fb5cdd6e..6495268b 100644 --- a/js/index.js +++ b/js/index.js @@ -32,12 +32,14 @@ import ToForm from './components/forms/to_form' import ClinFields from './components/clin_fields' import PopDateRange from './components/pop_date_range' import ToggleMenu from './components/toggle_menu' +import ExpandSidenav from './mixins/expand_sidenav' Vue.config.productionTip = false Vue.use(VTooltip) Vue.mixin(Modal) +Vue.mixin(ExpandSidenav) const app = new Vue({ el: '#app-root', @@ -67,6 +69,12 @@ const app = new Vue({ ToggleMenu, }, + data: function() { + return { + sidenavExpanded: this.defaultVisible, + } + }, + mounted: function() { this.$on('modalOpen', data => { if (data['isOpen']) { @@ -105,6 +113,10 @@ const app = new Vue({ } }) }) + + this.$on('sidenavToggle', data => { + this.sidenavExpanded = data + }) }, delimiters: ['!{', '}'], diff --git a/js/mixins/expand_sidenav.js b/js/mixins/expand_sidenav.js new file mode 100644 index 00000000..7553b7d4 --- /dev/null +++ b/js/mixins/expand_sidenav.js @@ -0,0 +1,15 @@ +export default { + props: { + cookieName: 'expandSidenav', + defaultVisible: { + type: Boolean, + default: function() { + if (document.cookie.match(this.cookieName)) { + return !!document.cookie.match(this.cookieName + ' *= *true') + } else { + return true + } + }, + }, + }, +} diff --git a/styles/elements/_action_group.scss b/styles/elements/_action_group.scss index fe375f67..c2d11049 100644 --- a/styles/elements/_action_group.scss +++ b/styles/elements/_action_group.scss @@ -32,22 +32,35 @@ } .action-group-footer { - @extend .action-group; - - &:last-child { - margin-bottom: 0; - } - margin-top: 0; - margin-bottom: 0; padding-top: $gap; padding-bottom: $gap; - + padding-right: $gap * 4; position: fixed; bottom: $footer-height; + left: 0; background: white; - right: 0; - padding-right: $gap * 4; border-top: 1px solid $color-gray-lighter; - width: 100%; z-index: 1; + width: 100%; + + &.action-group-footer--expand-offset { + padding-left: $sidenav-expanded-width; + } + + &.action-group-footer--collapse-offset { + padding-left: $sidenav-collapsed-width; + } + + .action-group-footer--container { + @extend .action-group; + + margin-top: 0; + margin-bottom: 0; + margin-left: $large-spacing; + max-width: $max-panel-width; + + &:last-child { + margin-bottom: 0; + } + } } diff --git a/templates/task_orders/builder_base.html b/templates/task_orders/builder_base.html index 9ee8dd0c..66e84d53 100644 --- a/templates/task_orders/builder_base.html +++ b/templates/task_orders/builder_base.html @@ -31,7 +31,10 @@
{% block to_builder_form_field %}{% endblock %}
- + {% endblock %} From eeb174af351efc0a0c4e10f9dcdfcbd2d1d4cc55 Mon Sep 17 00:00:00 2001 From: leigh-mil Date: Tue, 4 Feb 2020 13:42:00 -0500 Subject: [PATCH 4/8] Set max width for alerts and delete unused classes --- styles/components/_alerts.scss | 36 +--------------------------------- 1 file changed, 1 insertion(+), 35 deletions(-) diff --git a/styles/components/_alerts.scss b/styles/components/_alerts.scss index be326807..eb62a756 100644 --- a/styles/components/_alerts.scss +++ b/styles/components/_alerts.scss @@ -11,6 +11,7 @@ .usa-alert { padding-bottom: 2.4rem; + max-width: $max-panel-width; } @mixin alert { @@ -97,38 +98,3 @@ } } } - -.alert { - @include alert; - @include alert-level("info"); - - &.alert--success { - @include alert-level("success"); - - .alert__actions { - .icon-link { - @include icon-link-color($color-green, $color-white); - } - } - } - - &.alert--warning { - @include alert-level("warning"); - - .alert__actions { - .icon-link { - @include icon-link-color($color-gold-dark, $color-white); - } - } - } - - &.alert--error { - @include alert-level("error"); - - .alert__actions { - .icon-link { - @include icon-link-color($color-red, $color-white); - } - } - } -} From a6e7dfda133e56370d018609f9fc07a972586e1f Mon Sep 17 00:00:00 2001 From: leigh-mil Date: Tue, 4 Feb 2020 13:51:28 -0500 Subject: [PATCH 5/8] Apply .action-group-footer updates to the New application builder --- templates/applications/new/step_1.html | 20 ++++++++++-------- templates/applications/new/step_2.html | 28 +++++++++++++++----------- templates/applications/new/step_3.html | 26 ++++++++++++++---------- 3 files changed, 43 insertions(+), 31 deletions(-) diff --git a/templates/applications/new/step_1.html b/templates/applications/new/step_1.html index d06ddee0..3841bf96 100644 --- a/templates/applications/new/step_1.html +++ b/templates/applications/new/step_1.html @@ -39,14 +39,18 @@ - - {% block next_button %} - {{ SaveButton(text=('portfolios.applications.new.step_1_button_text' | translate)) }} - {% endblock %} - - Cancel - - + diff --git a/templates/applications/new/step_2.html b/templates/applications/new/step_2.html index 462c0f46..2cd5cf98 100644 --- a/templates/applications/new/step_2.html +++ b/templates/applications/new/step_2.html @@ -58,20 +58,24 @@ {{ Icon("plus") }} + + + + - - - {% block next_button %} - {{ SaveButton(text=('portfolios.applications.new.step_2_button_text' | translate)) }} - {% endblock %} - - Previous - - - Cancel - - diff --git a/templates/applications/new/step_3.html b/templates/applications/new/step_3.html index d88a704c..35af10fa 100644 --- a/templates/applications/new/step_3.html +++ b/templates/applications/new/step_3.html @@ -25,16 +25,20 @@ action_update="applications.update_new_application_step_3") }} - - - {{ "portfolios.applications.new.step_3_button_text" | translate }} - - - {{ "common.previous" | translate }} - - - {{ "common.cancel" | translate }} - - + {% endblock %} From 68c7a70082540c4edf29df9ff76cdb95dded3733 Mon Sep 17 00:00:00 2001 From: leigh-mil Date: Tue, 4 Feb 2020 14:21:08 -0500 Subject: [PATCH 6/8] Set max width on error page and include Last login on error page - Updated error_base template so that it contained the Root Vue component, which was the reason that the last login was not previously displaying - Deleted unused css - Created css variable max-page-width for use on the error page, topbar, and other full width elements --- styles/components/_error_page.scss | 20 ++++++----------- styles/components/_topbar.scss | 2 +- styles/core/_variables.scss | 1 + templates/error.html | 2 ++ templates/error_base.html | 35 +++++++++++++++--------------- 5 files changed, 29 insertions(+), 31 deletions(-) diff --git a/styles/components/_error_page.scss b/styles/components/_error_page.scss index 19ae7531..683b527f 100644 --- a/styles/components/_error_page.scss +++ b/styles/components/_error_page.scss @@ -1,8 +1,13 @@ .error-page { - max-width: 475px; - margin: auto; + max-width: $max-page-width; .panel { + box-shadow: none; + background-color: unset; + border: none; + max-width: 475px; + margin: auto; + &__heading { text-align: center; padding: $gap 0; @@ -15,17 +20,6 @@ margin-bottom: $gap; } } - - &__body { - padding: $gap * 2; - margin: 0; - - hr { - width: 80%; - margin: auto; - margin-bottom: $gap * 3; - } - } } .icon { diff --git a/styles/components/_topbar.scss b/styles/components/_topbar.scss index 6d84f426..feca57b6 100644 --- a/styles/components/_topbar.scss +++ b/styles/components/_topbar.scss @@ -12,7 +12,7 @@ flex-direction: row; align-items: stretch; justify-content: space-between; - max-width: 1190px; + max-width: $max-page-width; a { color: $color-white; diff --git a/styles/core/_variables.scss b/styles/core/_variables.scss index 12657ca4..372fa868 100644 --- a/styles/core/_variables.scss +++ b/styles/core/_variables.scss @@ -19,6 +19,7 @@ $sidenav-collapsed-width: 10rem; $max-panel-width: 90rem; $home-pg-icon-width: 6rem; $large-spacing: 4rem; +$max-page-width: $max-panel-width + $sidenav-expanded-width + $large-spacing; /* * USWDS Variables diff --git a/templates/error.html b/templates/error.html index 449c9a88..45ff12a3 100644 --- a/templates/error.html +++ b/templates/error.html @@ -5,6 +5,7 @@ {% block content %}
+
{{ Icon('cloud', classes="icon--red icon--large")}}
@@ -17,6 +18,7 @@ {%- endif %}

+
{% endblock %} diff --git a/templates/error_base.html b/templates/error_base.html index 92be8e60..b5751e81 100644 --- a/templates/error_base.html +++ b/templates/error_base.html @@ -10,29 +10,30 @@ +
+ {% block template_vars %}{% endblock %} - {% block template_vars %}{% endblock %} + {% include 'components/usa_header.html' %} - {% include 'components/usa_header.html' %} + {% include 'navigation/topbar.html' %} - {% include 'navigation/topbar.html' %} +
-
+
+ {% block sidenav %}{% endblock %} -
- {% block sidenav %}{% endblock %} - - {% block content %} - these are not the droids you are looking for - {% endblock %} + {% block content %} + these are not the droids you are looking for + {% endblock %} +
+ + {% include 'footer.html' %} + + {% block modal %}{% endblock %} + {% assets "js_all" %} + + {% endassets %}
- - {% include 'footer.html' %} - - {% block modal %}{% endblock %} - {% assets "js_all" %} - - {% endassets %} From e7487aa1149a3fd6ec8dec34532068b3ef73531b Mon Sep 17 00:00:00 2001 From: leigh-mil Date: Tue, 4 Feb 2020 14:43:59 -0500 Subject: [PATCH 7/8] Set max width on ccpo admin pages --- styles/atat.scss | 1 + styles/sections/_ccpo.scss | 3 + templates/ccpo/add_user.html | 32 ++++---- templates/ccpo/confirm_user.html | 56 +++++++------ templates/ccpo/users.html | 136 ++++++++++++++++--------------- 5 files changed, 119 insertions(+), 109 deletions(-) create mode 100644 styles/sections/_ccpo.scss diff --git a/styles/atat.scss b/styles/atat.scss index 72c7af40..8eb73473 100644 --- a/styles/atat.scss +++ b/styles/atat.scss @@ -47,3 +47,4 @@ @import "sections/application_edit"; @import "sections/reports"; @import "sections/task_order"; +@import "sections/ccpo"; diff --git a/styles/sections/_ccpo.scss b/styles/sections/_ccpo.scss new file mode 100644 index 00000000..4033ac2e --- /dev/null +++ b/styles/sections/_ccpo.scss @@ -0,0 +1,3 @@ +.ccpo-panel-container { + max-width: $max-panel-width; +} diff --git a/templates/ccpo/add_user.html b/templates/ccpo/add_user.html index e8544a2b..eccd27a9 100644 --- a/templates/ccpo/add_user.html +++ b/templates/ccpo/add_user.html @@ -4,21 +4,23 @@ {% from "components/text_input.html" import TextInput %} {% block content %} - -
- {{ form.csrf_token }} -

{{ "ccpo.form.add_user_title" | translate }}

-
-
- {{ TextInput(form.dod_id, validation='dodId', optional=False) }} -
-
-
- {{ SaveButton(text="common.next"|translate, element="input", additional_classes="action-group__action", form="add-ccpo-user-form") }} - {{ "common.cancel" | translate }} +
+ + + {{ form.csrf_token }} +

{{ "ccpo.form.add_user_title" | translate }}

+
+
+ {{ TextInput(form.dod_id, validation='dodId', optional=False) }} +
+
+
+ {{ SaveButton(text="common.next"|translate, element="input", additional_classes="action-group__action", form="add-ccpo-user-form") }} + {{ "common.cancel" | translate }} +
-
- - + + +
{% endblock %} diff --git a/templates/ccpo/confirm_user.html b/templates/ccpo/confirm_user.html index dfe30bca..a45abd3c 100644 --- a/templates/ccpo/confirm_user.html +++ b/templates/ccpo/confirm_user.html @@ -3,31 +3,33 @@ {% from "components/text_input.html" import TextInput %} {% block content %} - {% if new_user %} -

{{ 'ccpo.form.confirm_user_title' | translate }}

-
- {{ form.csrf_token }} - -
-

- {{ "ccpo.form.confirm_user_text" | translate }} -

-

- {{ new_user.full_name }} -

-

- {{ new_user.email }} -

-
- -
- {% endif %} +
+ {% if new_user %} +

{{ 'ccpo.form.confirm_user_title' | translate }}

+
+ {{ form.csrf_token }} + +
+

+ {{ "ccpo.form.confirm_user_text" | translate }} +

+

+ {{ new_user.full_name }} +

+

+ {{ new_user.email }} +

+
+ +
+ {% endif %} +
{% endblock %} diff --git a/templates/ccpo/users.html b/templates/ccpo/users.html index c5c8cc3b..15e5d9fe 100644 --- a/templates/ccpo/users.html +++ b/templates/ccpo/users.html @@ -6,78 +6,80 @@ {% from "components/modal.html" import Modal %} {% block content %} -
-
- {{ "ccpo.users_title" | translate }} -
+
+
+
+ {{ "ccpo.users_title" | translate }} +
- {% include "fragments/flash.html" %} - - - - - - - - {% if user_can(permissions.DELETE_CCPO_USER) %} - - {% endif %} - - - - {% for user, form in users_info %} - {% set modal_id = "disable_ccpo_user_{}".format(user.dod_id) %} - {% set disable_button_class = 'button-danger-outline' %} - {% if user == g.current_user %} - {% set disable_button_class = "usa-button-disabled" %} - {% endif %} + {% include "fragments/flash.html" %} +
{{ "common.name" | translate }}{{ "common.email" | translate }}{{ "common.dod_id" | translate }}
+ - - - + + + {% if user_can(permissions.DELETE_CCPO_USER) %} - + {% endif %} - {% endfor %} - -
{{ user.full_name }}{{ user.email }}{{ user.dod_id }}{{ "common.name" | translate }}{{ "common.email" | translate }}{{ "common.dod_id" | translate }} - - {{ "common.disable" | translate }} - -
+ + + {% for user, form in users_info %} + {% set modal_id = "disable_ccpo_user_{}".format(user.dod_id) %} + {% set disable_button_class = 'button-danger-outline' %} + {% if user == g.current_user %} + {% set disable_button_class = "usa-button-disabled" %} + {% endif %} + + + {{ user.full_name }} + {{ user.email }} + {{ user.dod_id }} + {% if user_can(permissions.DELETE_CCPO_USER) %} + + + {{ "common.disable" | translate }} + + + {% endif %} + + {% endfor %} + + +
+ + {% if user_can(permissions.CREATE_CCPO_USER) %} + + {{ "ccpo.add_user" | translate }} {{ Icon("plus") }} + + {% endif %} + + {% if user_can(permissions.DELETE_CCPO_USER) %} + {% for user, form in users_info %} + {% set modal_id = "disable_ccpo_user_{}".format(user.dod_id) %} + {% call Modal(name=modal_id) %} +

Disable CCPO User

+
+ {{ + Alert( + title=("components.modal.destructive_title" | translate), + message=("ccpo.disable_user.alert_message" | translate({"user_name": user.full_name})), + level="warning" + ) + }} + {{ + DeleteConfirmation( + modal_id=modal_id, + delete_text='Remove Access', + delete_action=(url_for('ccpo.remove_access', user_id=user.id)), + form=form, + confirmation_text='remove' + ) + }} + {% endcall %} + {% endfor %} + {% endif %}
- - {% if user_can(permissions.CREATE_CCPO_USER) %} - - {{ "ccpo.add_user" | translate }} {{ Icon("plus") }} - - {% endif %} - - {% if user_can(permissions.DELETE_CCPO_USER) %} - {% for user, form in users_info %} - {% set modal_id = "disable_ccpo_user_{}".format(user.dod_id) %} - {% call Modal(name=modal_id) %} -

Disable CCPO User

-
- {{ - Alert( - title=("components.modal.destructive_title" | translate), - message=("ccpo.disable_user.alert_message" | translate({"user_name": user.full_name})), - level="warning" - ) - }} - {{ - DeleteConfirmation( - modal_id=modal_id, - delete_text='Remove Access', - delete_action=(url_for('ccpo.remove_access', user_id=user.id)), - form=form, - confirmation_text='remove' - ) - }} - {% endcall %} - {% endfor %} - {% endif %} {% endblock %} From 6cf39ca1e4a14a02c61e6ade42848c3fc8e12e84 Mon Sep 17 00:00:00 2001 From: leigh-mil Date: Tue, 4 Feb 2020 15:31:48 -0500 Subject: [PATCH 8/8] Set max width on new portfolio form --- templates/portfolios/new/step_1.html | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/templates/portfolios/new/step_1.html b/templates/portfolios/new/step_1.html index 3305d924..940becee 100644 --- a/templates/portfolios/new/step_1.html +++ b/templates/portfolios/new/step_1.html @@ -15,6 +15,7 @@

{{ "portfolios.header" | translate }}

{{ 'portfolios.new.title' | translate }}

+
{{ StickyCTA(text="portfolios.new.cta_step_1"|translate, context=("portfolios.new.sticky_header_context"|translate({"step": "1"}) )) }}
@@ -38,13 +39,18 @@ {{ "forms.portfolio.defense_component.help_text" | translate | safe }}
-