diff --git a/.circleci/config.yml b/.circleci/config.yml index c28b28a7..c1d2d810 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -96,6 +96,7 @@ commands: apk del --purge build - run: name: Login to Azure CLI + shell: /bin/sh -o pipefail command: | az login \ --service-principal \ diff --git a/.secrets.baseline b/.secrets.baseline index 45f10336..d2fd7baf 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "^.secrets.baseline$|^.*pgsslrootcert.yml$", "lines": null }, - "generated_at": "2020-02-12T18:51:01Z", + "generated_at": "2020-02-17T20:49:33Z", "plugins_used": [ { "base64_limit": 4.5, @@ -82,7 +82,7 @@ "hashed_secret": "afc848c316af1a89d49826c5ae9d00ed769415f3", "is_secret": false, "is_verified": false, - "line_number": 44, + "line_number": 48, "type": "Secret Keyword" } ], diff --git a/README.md b/README.md index accecd38..2982ff33 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,10 @@ To generate coverage reports for the Javascript tests: - `ASSETS_URL`: URL to host which serves static assets (such as a CDN). - `AZURE_ACCOUNT_NAME`: The name for the Azure blob storage account +- `AZURE_CALC_CLIENT_ID`: The client id used to generate a token for the Azure pricing calculator +- `AZURE_CALC_RESOURCE`: The resource URL used to generate a token for the Azure pricing calculator +- `AZURE_CALC_SECRET`: The secret key used to generate a token for the Azure pricing calculator +- `AZURE_CALC_URL`: The redirect URL for the Azure pricing calculator - `AZURE_LOGIN_URL`: The URL used to login for an Azure instance. - `AZURE_STORAGE_KEY`: A valid secret key for the Azure blob storage account - `AZURE_TO_BUCKET_NAME`: The Azure blob storage container name for task order uploads diff --git a/atst/domain/csp/cloud/azure_cloud_provider.py b/atst/domain/csp/cloud/azure_cloud_provider.py index 679d5cbd..39c2e83a 100644 --- a/atst/domain/csp/cloud/azure_cloud_provider.py +++ b/atst/domain/csp/cloud/azure_cloud_provider.py @@ -1738,7 +1738,6 @@ class AzureCloudProvider(CloudProviderInterface): cost_mgmt_url = ( f"/providers/Microsoft.CostManagement/query?api-version=2019-11-01" ) - try: result = self.sdk.requests.post( f"{self.sdk.cloud.endpoints.resource_manager}{payload.invoice_section_id}{cost_mgmt_url}", @@ -1770,3 +1769,17 @@ class AzureCloudProvider(CloudProviderInterface): result.status_code, f"azure application error getting reporting data. {str(exc)}", ) + + def _get_calculator_creds(self): + authority = f"{self.sdk.cloud.endpoints.active_directory}/{self.tenant_id}" + context = self.sdk.adal.AuthenticationContext(authority=authority) + response = context.acquire_token_with_client_credentials( + self.config.get("AZURE_CALC_RESOURCE"), + self.config.get("AZURE_CALC_CLIENT_ID"), + self.config.get("AZURE_CALC_SECRET"), + ) + return response.get("accessToken") + + def get_calculator_url(self): + calc_access_token = self._get_calculator_creds() + return f"{self.config.get('AZURE_CALC_URL')}?access_token={calc_access_token}" diff --git a/atst/domain/task_orders.py b/atst/domain/task_orders.py index 44d6b47e..7a538c2d 100644 --- a/atst/domain/task_orders.py +++ b/atst/domain/task_orders.py @@ -90,3 +90,13 @@ class TaskOrders(BaseDomainClass): ) .all() ) + + @classmethod + def get_clins_for_create_billing_instructions(cls): + return ( + db.session.query(CLIN) + .filter( + CLIN.last_sent_at.is_(None), CLIN.start_date < pendulum.now(tz="UTC") + ) + .all() + ) diff --git a/atst/forms/portfolio.py b/atst/forms/portfolio.py index 7c4e6644..74f5330d 100644 --- a/atst/forms/portfolio.py +++ b/atst/forms/portfolio.py @@ -33,6 +33,7 @@ class PortfolioForm(BaseForm): class PortfolioCreationForm(PortfolioForm): defense_component = SelectMultipleField( translate("forms.portfolio.defense_component.title"), + description=translate("forms.portfolio.defense_component.help_text"), choices=SERVICE_BRANCHES, widget=ListWidget(prefix_label=False), option_widget=CheckboxInput(), diff --git a/atst/jobs.py b/atst/jobs.py index 77a8c2f6..855af5b2 100644 --- a/atst/jobs.py +++ b/atst/jobs.py @@ -10,6 +10,7 @@ from atst.domain.csp.cloud import CloudProviderInterface from atst.domain.csp.cloud.exceptions import GeneralCSPException from atst.domain.csp.cloud.models import ( ApplicationCSPPayload, + BillingInstructionCSPPayload, EnvironmentCSPPayload, UserCSPPayload, UserRoleCSPPayload, @@ -111,6 +112,18 @@ def do_create_user(csp: CloudProviderInterface, application_role_ids=None): db.session.add(app_role) db.session.commit() + username = payload.user_principal_name + send_mail( + recipients=[user.email], + subject=translate("email.app_role_created.subject"), + body=translate( + "email.app_role_created.body", + {"url": app.config.get("AZURE_LOGIN_URL"), "username": username}, + ), + ) + app.logger.info( + f"Application role created notification email sent. User id: {user.id}" + ) def do_create_environment(csp: CloudProviderInterface, environment_id=None): @@ -280,7 +293,7 @@ def dispatch_create_atat_admin_user(self): @celery.task(bind=True) -def dispatch_send_task_order_files(self): +def send_task_order_files(self): task_orders = TaskOrders.get_for_send_task_order_files() recipients = [app.config.get("MICROSOFT_TASK_ORDER_EMAIL_ADDRESS")] @@ -301,7 +314,36 @@ def dispatch_send_task_order_files(self): app.logger.exception(err) continue - task_order.pdf_last_sent_at = pendulum.now() + task_order.pdf_last_sent_at = pendulum.now(tz="UTC") db.session.add(task_order) db.session.commit() + + +@celery.task(bind=True) +def create_billing_instruction(self): + clins = TaskOrders.get_clins_for_create_billing_instructions() + for clin in clins: + portfolio = clin.task_order.portfolio + + payload = BillingInstructionCSPPayload( + tenant_id=portfolio.csp_data.get("tenant_id"), + billing_account_name=portfolio.csp_data.get("billing_account_name"), + billing_profile_name=portfolio.csp_data.get("billing_profile_name"), + initial_clin_amount=clin.obligated_amount, + initial_clin_start_date=str(clin.start_date), + initial_clin_end_date=str(clin.end_date), + initial_clin_type=clin.number, + initial_task_order_id=str(clin.task_order_id), + ) + + try: + app.csp.cloud.create_billing_instruction(payload) + except (AzureError) as err: + app.logger.exception(err) + continue + + clin.last_sent_at = pendulum.now(tz="UTC") + db.session.add(clin) + + db.session.commit() diff --git a/atst/models/clin.py b/atst/models/clin.py index accab107..a96e907f 100644 --- a/atst/models/clin.py +++ b/atst/models/clin.py @@ -66,12 +66,14 @@ class CLIN(Base, mixins.TimestampsMixin): ) def to_dictionary(self): - return { + data = { c.name: getattr(self, c.name) for c in self.__table__.columns if c.name not in ["id"] } + return data + @property def is_active(self): return ( diff --git a/atst/queue.py b/atst/queue.py index 10bcb350..dcd123d1 100644 --- a/atst/queue.py +++ b/atst/queue.py @@ -27,6 +27,14 @@ def update_celery(celery, app): "task": "atst.jobs.dispatch_create_environment_role", "schedule": 60, }, + "beat-send_task_order_files": { + "task": "atst.jobs.send_task_order_files", + "schedule": 60, + }, + "beat-create_billing_instruction": { + "task": "atst.jobs.create_billing_instruction", + "schedule": 60, + }, } class ContextTask(celery.Task): diff --git a/atst/routes/portfolios/index.py b/atst/routes/portfolios/index.py index 6472fca1..02805480 100644 --- a/atst/routes/portfolios/index.py +++ b/atst/routes/portfolios/index.py @@ -50,9 +50,7 @@ def reports(portfolio_id): "portfolios/reports/index.html", portfolio=portfolio, # wrapped in str() because this sum returns a Decimal object - total_portfolio_value=str( - portfolio.total_obligated_funds + portfolio.upcoming_obligated_funds - ), + total_portfolio_value=str(portfolio.total_obligated_funds), current_obligated_funds=current_obligated_funds, expired_task_orders=Reports.expired_task_orders(portfolio), retrieved=pendulum.now(), # mocked datetime of reporting data retrival diff --git a/config/base.ini b/config/base.ini index 3aa9ad86..a587c74f 100644 --- a/config/base.ini +++ b/config/base.ini @@ -3,6 +3,10 @@ ASSETS_URL AZURE_AADP_QTY=5 AZURE_ACCOUNT_NAME AZURE_CLIENT_ID +AZURE_CALC_CLIENT_ID +AZURE_CALC_RESOURCE="http://azurecom.onmicrosoft.com/acom-prod/" +AZURE_CALC_SECRET +AZURE_CALC_URL="https://azure.microsoft.com/en-us/pricing/calculator/" AZURE_GRAPH_RESOURCE="https://graph.microsoft.com/" AZURE_LOGIN_URL="https://portal.azure.com/" AZURE_POLICY_LOCATION=policies diff --git a/styles/components/_forms.scss b/styles/components/_forms.scss index f11af8f8..6921eb01 100644 --- a/styles/components/_forms.scss +++ b/styles/components/_forms.scss @@ -2,10 +2,6 @@ .form-row { margin: ($gap * 4) 0; - &--bordered { - border-bottom: $color-gray-lighter 1px solid; - } - .form-col { flex-grow: 1; @@ -22,8 +18,9 @@ } .usa-input { - input { - max-width: none; + input, + textarea { + max-width: $max-input-width; } } } @@ -204,6 +201,10 @@ } } -.form-container__half { - max-width: 46rem; +.form-container { + margin-bottom: $action-footer-height + $large-spacing; + + &--narrow { + max-width: $max-input-width; + } } diff --git a/styles/components/_member_form.scss b/styles/components/_member_form.scss index 5cbeaf87..3644fce2 100644 --- a/styles/components/_member_form.scss +++ b/styles/components/_member_form.scss @@ -5,17 +5,11 @@ margin-left: 0; } - .input__inline-fields { - text-align: left; - - .usa-input__choices label { - font-weight: $font-bold; - } - } - .input__inline-fields { padding: $gap * 2; border: 1px solid $color-gray-lighter; + text-align: left; + max-width: 100%; &.checked { border: 1px solid $color-blue; @@ -33,7 +27,7 @@ .user-info { .usa-input { - width: 45rem; + max-width: $max-input-width; input, label, @@ -53,8 +47,7 @@ } } -#modal--add-app-mem, -#modal--add-portfolio-manager { +.form-content--member-form { .modal__body { min-width: 75rem; } diff --git a/styles/components/_portfolio_layout.scss b/styles/components/_portfolio_layout.scss index e9af6d5e..8aae2ca5 100644 --- a/styles/components/_portfolio_layout.scss +++ b/styles/components/_portfolio_layout.scss @@ -519,3 +519,15 @@ } } } + +#portfolio-create { + .usa-input__choices { + .usa-input__title { + font-weight: $font-bold; + } + + label { + font-size: $base-font-size; + } + } +} diff --git a/styles/core/_util.scss b/styles/core/_util.scss index 7fd8e152..0790a121 100644 --- a/styles/core/_util.scss +++ b/styles/core/_util.scss @@ -98,7 +98,3 @@ hr { .usa-section { padding: 0; } - -.form { - margin-bottom: $action-footer-height + $large-spacing; -} diff --git a/styles/core/_variables.scss b/styles/core/_variables.scss index 09b838f3..f98a3663 100644 --- a/styles/core/_variables.scss +++ b/styles/core/_variables.scss @@ -21,6 +21,7 @@ $home-pg-icon-width: 6rem; $large-spacing: 4rem; $max-page-width: $max-panel-width + $sidenav-expanded-width + $large-spacing; $action-footer-height: 6rem; +$max-input-width: 46rem; /* * USWDS Variables diff --git a/styles/elements/_inputs.scss b/styles/elements/_inputs.scss index c424b723..6bd3acbb 100644 --- a/styles/elements/_inputs.scss +++ b/styles/elements/_inputs.scss @@ -58,7 +58,7 @@ .usa-input { margin: ($gap * 2) 0; - max-width: 75rem; + max-width: $max-input-width; &-label-helper { font-size: $small-font-size; @@ -111,8 +111,7 @@ @include h5; font-weight: normal; - - @include line-max; + max-width: $max-input-width; .icon-link { padding: 0 ($gap / 2); @@ -180,6 +179,10 @@ left: -3rem; } } + + .usa-input__title { + margin-bottom: $gap; + } } select { @@ -372,19 +375,15 @@ select { .phone-input { display: flex; flex-direction: row; + justify-content: flex-start; &__phone { margin-right: $gap * 4; + flex-grow: 1; .usa-input { margin: 0; - input, - label, - .usa-input__message { - max-width: 20rem; - } - .icon-validation { left: 20rem; } @@ -392,7 +391,8 @@ select { } &__extension { - margin-left: $gap * 4; + margin-right: $gap * 4; + flex-grow: 0; .usa-input { margin: 0; diff --git a/styles/sections/_task_order.scss b/styles/sections/_task_order.scss index 79f391e0..1edc5b66 100644 --- a/styles/sections/_task_order.scss +++ b/styles/sections/_task_order.scss @@ -140,6 +140,10 @@ &__confirmation { margin-left: $gap * 8; } + + .usa-input { + max-width: 100%; + } } .task-order__modal-cancel { diff --git a/templates/applications/fragments/members.html b/templates/applications/fragments/members.html index f60fcba8..5fbecd35 100644 --- a/templates/applications/fragments/members.html +++ b/templates/applications/fragments/members.html @@ -36,7 +36,7 @@ {% set invite_expired = member.role_status == 'invite_expired' %} {%- if user_can(permissions.EDIT_APPLICATION_MEMBER) %} {% set modal_name = "edit_member-{}".format(loop.index) %} - {% call Modal(modal_name, classes="form-content--app-mem") %} + {% call Modal(modal_name, classes="form-content--member-form") %} @@ -56,7 +56,7 @@ {%- if invite_pending or invite_expired %} {% set resend_invite_modal = "resend_invite-{}".format(member.role_id) %} - {% call Modal(resend_invite_modal, classes="form-content--app-mem") %} + {% call Modal(resend_invite_modal, classes="form-content--member-form") %} @@ -183,6 +183,7 @@ modal=new_member_modal_name, ) ], + classes="form-content--member-form", ) }} {% endif %} diff --git a/templates/applications/new/step_1.html b/templates/applications/new/step_1.html index 39656257..688aa5a3 100644 --- a/templates/applications/new/step_1.html +++ b/templates/applications/new/step_1.html @@ -22,7 +22,7 @@ {% include "fragments/flash.html" %} -
+ {{ form.csrf_token }}
diff --git a/templates/applications/new/step_2.html b/templates/applications/new/step_2.html index fe07b44d..5fa6be34 100644 --- a/templates/applications/new/step_2.html +++ b/templates/applications/new/step_2.html @@ -21,7 +21,7 @@


- +
{{ 'portfolios.applications.environments_heading' | translate }}
diff --git a/templates/applications/settings.html b/templates/applications/settings.html index 1ec7be37..9fc42a17 100644 --- a/templates/applications/settings.html +++ b/templates/applications/settings.html @@ -20,7 +20,7 @@ {% if user_can(permissions.EDIT_APPLICATION) %} - + {{ application_form.csrf_token }} {{ TextInput(application_form.name, validation="applicationName", optional=False) }} {{ TextInput(application_form.description, validation="defaultTextAreaField", paragraph=True, optional=True, showOptional=False) }} diff --git a/templates/components/multi_step_modal_form.html b/templates/components/multi_step_modal_form.html index a7f71edf..d3c9b520 100644 --- a/templates/components/multi_step_modal_form.html +++ b/templates/components/multi_step_modal_form.html @@ -25,10 +25,10 @@
{% endmacro %} -{% macro MultiStepModalForm(name, form, form_action, steps, dismissable=False) -%} +{% macro MultiStepModalForm(name, form, form_action, steps, dismissable=False, classes="") -%} {% set step_count = steps|length %} - {% call Modal(name=name, dismissable=dismissable) %} + {% call Modal(name=name, dismissable=dismissable, classes=classes) %} {{ form.csrf_token }}
diff --git a/templates/portfolios/admin.html b/templates/portfolios/admin.html index 40721c0d..6e0e795d 100644 --- a/templates/portfolios/admin.html +++ b/templates/portfolios/admin.html @@ -13,7 +13,7 @@
{% include "fragments/flash.html" %} -
+

Portfolio name and component

{% if user_can(permissions.EDIT_PORTFOLIO_NAME) %} diff --git a/templates/portfolios/fragments/portfolio_members.html b/templates/portfolios/fragments/portfolio_members.html index 91e99d24..6e9c4be2 100644 --- a/templates/portfolios/fragments/portfolio_members.html +++ b/templates/portfolios/fragments/portfolio_members.html @@ -14,7 +14,7 @@ {% set invite_expired = member.status == 'invite_expired' %} {% set modal_name = "edit_member-{}".format(loop.index) %} - {% call Modal(modal_name, classes="form-content--app-mem") %} + {% call Modal(modal_name, classes="form-content--member-form") %} @@ -34,7 +34,7 @@ {% if invite_pending or invite_expired -%} {% set resend_invite_modal = "resend_invite-{}".format(member.role_id) %} - {% call Modal(resend_invite_modal, classes="form-content--app-mem") %} + {% call Modal(resend_invite_modal, classes="form-content--member-form") %} @@ -182,6 +182,7 @@ modal=new_manager_modal, ) ], + classes="form-content--member-form", ) }} {% endif %}
diff --git a/templates/portfolios/new/step_1.html b/templates/portfolios/new/step_1.html index d7d4f522..0173d031 100644 --- a/templates/portfolios/new/step_1.html +++ b/templates/portfolios/new/step_1.html @@ -19,24 +19,25 @@ {{ StickyCTA(text="portfolios.new.cta_step_1"|translate, context=("portfolios.new.sticky_header_context"|translate({"step": "1"}) )) }}
- + {{ form.csrf_token }} -
+
{{ TextInput(form.name, validation="portfolioName", optional=False, classes="form-col") }} {{"forms.portfolio.name.help_text" | translate | safe }}
-
+
+
{{ TextInput(form.description, validation="defaultTextAreaField", paragraph=True) }} {{"forms.portfolio.description.help_text" | translate | safe }}
+
{{ MultiCheckboxInput(form.defense_component, optional=False) }} - {{ "forms.portfolio.defense_component.help_text" | translate | safe }}
+
{% block to_builder_form_field %}{% endblock %}
+

Naming can be difficult. Choose a name that is descriptive enough for users to identify the Portfolio. You may consider naming based on your organization.

@@ -282,7 +285,7 @@ forms: description: label: Portfolio Description help_text: | -
+

Add a brief one to two sentence description of your Portfolio. Consider this your statement of work.

@@ -307,11 +310,9 @@ forms: title: Select DoD component(s) funding your Portfolio validation_message: You must select at least one defense component. help_text: | -

Select the DOD component(s) that will fund all Applications within this Portfolio. In JEDI, multiple DoD organizations can fund the same Portfolio.
- Select all that apply.
-

+ Select all that apply. attachment: object_name: length_error: Object name may be no longer than 40 characters. @@ -515,7 +516,7 @@ portfolios: estimate_warning: Reports displayed in JEDI are estimates and not a system of record. To manage your costs, go to Azure by selecting the Login to Azure button above. total_value: header: Total Portfolio Value - tooltip: Total portfolio value is all obligated funds for current and upcoming task orders in this portfolio. + tooltip: Total portfolio value is all obligated funds for active task orders in this portfolio. task_orders: add_new_button: Add New Task Order review: