Merge branch 'staging' into ci-fix-again

This commit is contained in:
dandds 2020-02-19 13:53:50 -05:00 committed by GitHub
commit 478a5b3e0b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 250 additions and 150 deletions

View File

@ -3,7 +3,7 @@
"files": "^.secrets.baseline$|^.*pgsslrootcert.yml$", "files": "^.secrets.baseline$|^.*pgsslrootcert.yml$",
"lines": null "lines": null
}, },
"generated_at": "2020-02-12T18:51:01Z", "generated_at": "2020-02-17T20:49:33Z",
"plugins_used": [ "plugins_used": [
{ {
"base64_limit": 4.5, "base64_limit": 4.5,
@ -82,7 +82,7 @@
"hashed_secret": "afc848c316af1a89d49826c5ae9d00ed769415f3", "hashed_secret": "afc848c316af1a89d49826c5ae9d00ed769415f3",
"is_secret": false, "is_secret": false,
"is_verified": false, "is_verified": false,
"line_number": 44, "line_number": 48,
"type": "Secret Keyword" "type": "Secret Keyword"
} }
], ],

View File

@ -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). - `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_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_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_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 - `AZURE_TO_BUCKET_NAME`: The Azure blob storage container name for task order uploads

View File

@ -1738,7 +1738,6 @@ class AzureCloudProvider(CloudProviderInterface):
cost_mgmt_url = ( cost_mgmt_url = (
f"/providers/Microsoft.CostManagement/query?api-version=2019-11-01" f"/providers/Microsoft.CostManagement/query?api-version=2019-11-01"
) )
try: try:
result = self.sdk.requests.post( result = self.sdk.requests.post(
f"{self.sdk.cloud.endpoints.resource_manager}{payload.invoice_section_id}{cost_mgmt_url}", f"{self.sdk.cloud.endpoints.resource_manager}{payload.invoice_section_id}{cost_mgmt_url}",
@ -1770,3 +1769,17 @@ class AzureCloudProvider(CloudProviderInterface):
result.status_code, result.status_code,
f"azure application error getting reporting data. {str(exc)}", 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}"

View File

@ -33,6 +33,7 @@ class PortfolioForm(BaseForm):
class PortfolioCreationForm(PortfolioForm): class PortfolioCreationForm(PortfolioForm):
defense_component = SelectMultipleField( defense_component = SelectMultipleField(
translate("forms.portfolio.defense_component.title"), translate("forms.portfolio.defense_component.title"),
description=translate("forms.portfolio.defense_component.help_text"),
choices=SERVICE_BRANCHES, choices=SERVICE_BRANCHES,
widget=ListWidget(prefix_label=False), widget=ListWidget(prefix_label=False),
option_widget=CheckboxInput(), option_widget=CheckboxInput(),

View File

@ -111,6 +111,18 @@ def do_create_user(csp: CloudProviderInterface, application_role_ids=None):
db.session.add(app_role) db.session.add(app_role)
db.session.commit() 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): def do_create_environment(csp: CloudProviderInterface, environment_id=None):
@ -280,7 +292,7 @@ def dispatch_create_atat_admin_user(self):
@celery.task(bind=True) @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() task_orders = TaskOrders.get_for_send_task_order_files()
recipients = [app.config.get("MICROSOFT_TASK_ORDER_EMAIL_ADDRESS")] recipients = [app.config.get("MICROSOFT_TASK_ORDER_EMAIL_ADDRESS")]
@ -301,7 +313,7 @@ def dispatch_send_task_order_files(self):
app.logger.exception(err) app.logger.exception(err)
continue 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.add(task_order)
db.session.commit() db.session.commit()

View File

@ -27,6 +27,10 @@ def update_celery(celery, app):
"task": "atst.jobs.dispatch_create_environment_role", "task": "atst.jobs.dispatch_create_environment_role",
"schedule": 60, "schedule": 60,
}, },
"beat-send_task_order_files": {
"task": "atst.jobs.send_task_order_files",
"schedule": 60,
},
} }
class ContextTask(celery.Task): class ContextTask(celery.Task):

View File

@ -50,9 +50,7 @@ def reports(portfolio_id):
"portfolios/reports/index.html", "portfolios/reports/index.html",
portfolio=portfolio, portfolio=portfolio,
# wrapped in str() because this sum returns a Decimal object # wrapped in str() because this sum returns a Decimal object
total_portfolio_value=str( total_portfolio_value=str(portfolio.total_obligated_funds),
portfolio.total_obligated_funds + portfolio.upcoming_obligated_funds
),
current_obligated_funds=current_obligated_funds, current_obligated_funds=current_obligated_funds,
expired_task_orders=Reports.expired_task_orders(portfolio), expired_task_orders=Reports.expired_task_orders(portfolio),
retrieved=pendulum.now(), # mocked datetime of reporting data retrival retrieved=pendulum.now(), # mocked datetime of reporting data retrival

View File

@ -3,6 +3,10 @@ ASSETS_URL
AZURE_AADP_QTY=5 AZURE_AADP_QTY=5
AZURE_ACCOUNT_NAME AZURE_ACCOUNT_NAME
AZURE_CLIENT_ID 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_GRAPH_RESOURCE="https://graph.microsoft.com/"
AZURE_LOGIN_URL="https://portal.azure.com/" AZURE_LOGIN_URL="https://portal.azure.com/"
AZURE_POLICY_LOCATION=policies AZURE_POLICY_LOCATION=policies

View File

@ -2,10 +2,6 @@
.form-row { .form-row {
margin: ($gap * 4) 0; margin: ($gap * 4) 0;
&--bordered {
border-bottom: $color-gray-lighter 1px solid;
}
.form-col { .form-col {
flex-grow: 1; flex-grow: 1;
@ -22,8 +18,9 @@
} }
.usa-input { .usa-input {
input { input,
max-width: none; textarea {
max-width: $max-input-width;
} }
} }
} }
@ -204,6 +201,10 @@
} }
} }
.form-container__half { .form-container {
max-width: 46rem; margin-bottom: $action-footer-height + $large-spacing;
&--narrow {
max-width: $max-input-width;
}
} }

View File

@ -5,17 +5,11 @@
margin-left: 0; margin-left: 0;
} }
.input__inline-fields {
text-align: left;
.usa-input__choices label {
font-weight: $font-bold;
}
}
.input__inline-fields { .input__inline-fields {
padding: $gap * 2; padding: $gap * 2;
border: 1px solid $color-gray-lighter; border: 1px solid $color-gray-lighter;
text-align: left;
max-width: 100%;
&.checked { &.checked {
border: 1px solid $color-blue; border: 1px solid $color-blue;
@ -33,7 +27,7 @@
.user-info { .user-info {
.usa-input { .usa-input {
width: 45rem; max-width: $max-input-width;
input, input,
label, label,
@ -53,8 +47,7 @@
} }
} }
#modal--add-app-mem, .form-content--member-form {
#modal--add-portfolio-manager {
.modal__body { .modal__body {
min-width: 75rem; min-width: 75rem;
} }

View File

@ -519,3 +519,15 @@
} }
} }
} }
#portfolio-create {
.usa-input__choices {
.usa-input__title {
font-weight: $font-bold;
}
label {
font-size: $base-font-size;
}
}
}

View File

@ -98,7 +98,3 @@ hr {
.usa-section { .usa-section {
padding: 0; padding: 0;
} }
.form {
margin-bottom: $action-footer-height + $large-spacing;
}

View File

@ -21,6 +21,7 @@ $home-pg-icon-width: 6rem;
$large-spacing: 4rem; $large-spacing: 4rem;
$max-page-width: $max-panel-width + $sidenav-expanded-width + $large-spacing; $max-page-width: $max-panel-width + $sidenav-expanded-width + $large-spacing;
$action-footer-height: 6rem; $action-footer-height: 6rem;
$max-input-width: 46rem;
/* /*
* USWDS Variables * USWDS Variables

View File

@ -58,7 +58,7 @@
.usa-input { .usa-input {
margin: ($gap * 2) 0; margin: ($gap * 2) 0;
max-width: 75rem; max-width: $max-input-width;
&-label-helper { &-label-helper {
font-size: $small-font-size; font-size: $small-font-size;
@ -111,8 +111,7 @@
@include h5; @include h5;
font-weight: normal; font-weight: normal;
max-width: $max-input-width;
@include line-max;
.icon-link { .icon-link {
padding: 0 ($gap / 2); padding: 0 ($gap / 2);
@ -180,6 +179,10 @@
left: -3rem; left: -3rem;
} }
} }
.usa-input__title {
margin-bottom: $gap;
}
} }
select { select {
@ -372,19 +375,15 @@ select {
.phone-input { .phone-input {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-start;
&__phone { &__phone {
margin-right: $gap * 4; margin-right: $gap * 4;
flex-grow: 1;
.usa-input { .usa-input {
margin: 0; margin: 0;
input,
label,
.usa-input__message {
max-width: 20rem;
}
.icon-validation { .icon-validation {
left: 20rem; left: 20rem;
} }
@ -392,7 +391,8 @@ select {
} }
&__extension { &__extension {
margin-left: $gap * 4; margin-right: $gap * 4;
flex-grow: 0;
.usa-input { .usa-input {
margin: 0; margin: 0;

View File

@ -140,6 +140,10 @@
&__confirmation { &__confirmation {
margin-left: $gap * 8; margin-left: $gap * 8;
} }
.usa-input {
max-width: 100%;
}
} }
.task-order__modal-cancel { .task-order__modal-cancel {

View File

@ -36,7 +36,7 @@
{% set invite_expired = member.role_status == 'invite_expired' %} {% set invite_expired = member.role_status == 'invite_expired' %}
{%- if user_can(permissions.EDIT_APPLICATION_MEMBER) %} {%- if user_can(permissions.EDIT_APPLICATION_MEMBER) %}
{% set modal_name = "edit_member-{}".format(loop.index) %} {% 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") %}
<div class="modal__form--header"> <div class="modal__form--header">
<h1>{{ Icon('avatar') }} {{ "portfolios.applications.members.form.edit_access_header" | translate({ "user": member.user_name }) }}</h1> <h1>{{ Icon('avatar') }} {{ "portfolios.applications.members.form.edit_access_header" | translate({ "user": member.user_name }) }}</h1>
</div> </div>
@ -56,7 +56,7 @@
{%- if invite_pending or invite_expired %} {%- if invite_pending or invite_expired %}
{% set resend_invite_modal = "resend_invite-{}".format(member.role_id) %} {% 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") %}
<div class="modal__form--header"> <div class="modal__form--header">
<h1>{{ "portfolios.applications.members.new.verify" | translate }}</h1> <h1>{{ "portfolios.applications.members.new.verify" | translate }}</h1>
</div> </div>
@ -183,6 +183,7 @@
modal=new_member_modal_name, modal=new_member_modal_name,
) )
], ],
classes="form-content--member-form",
) }} ) }}
{% endif %} {% endif %}
</div> </div>

View File

@ -22,7 +22,7 @@
{% include "fragments/flash.html" %} {% include "fragments/flash.html" %}
<base-form inline-template :enable-save="true"> <base-form inline-template :enable-save="true">
<form method="POST" action="{{ action }}" v-on:submit="handleSubmit" class="form"> <form method="POST" action="{{ action }}" v-on:submit="handleSubmit" class="form-container">
{{ form.csrf_token }} {{ form.csrf_token }}
<div class="form-row"> <div class="form-row">
<div class="form-col"> <div class="form-col">

View File

@ -21,7 +21,7 @@
</p> </p>
<hr> <hr>
<application-environments inline-template v-bind:initial-data='{{ form.data|tojson }}'> <application-environments inline-template v-bind:initial-data='{{ form.data|tojson }}'>
<form method="POST" action="{{ url_for('applications.update_new_application_step_2', portfolio_id=portfolio.id, application_id=application.id) }}" v-on:submit="handleSubmit" class="form"> <form method="POST" action="{{ url_for('applications.update_new_application_step_2', portfolio_id=portfolio.id, application_id=application.id) }}" v-on:submit="handleSubmit" class="form-container">
<div class="subheading">{{ 'portfolios.applications.environments_heading' | translate }}</div> <div class="subheading">{{ 'portfolios.applications.environments_heading' | translate }}</div>
<div class="panel"> <div class="panel">
<div class="panel__content"> <div class="panel__content">

View File

@ -20,7 +20,7 @@
{% if user_can(permissions.EDIT_APPLICATION) %} {% if user_can(permissions.EDIT_APPLICATION) %}
<base-form inline-template> <base-form inline-template>
<form method="POST" action="{{ url_for('applications.update', application_id=application.id) }}" class="col col--half"> <form method="POST" action="{{ url_for('applications.update', application_id=application.id) }}" class="form-container--narrow">
{{ application_form.csrf_token }} {{ application_form.csrf_token }}
{{ TextInput(application_form.name, validation="applicationName", optional=False) }} {{ TextInput(application_form.name, validation="applicationName", optional=False) }}
{{ TextInput(application_form.description, validation="defaultTextAreaField", paragraph=True, optional=True, showOptional=False) }} {{ TextInput(application_form.description, validation="defaultTextAreaField", paragraph=True, optional=True, showOptional=False) }}

View File

@ -25,10 +25,10 @@
</div> </div>
{% endmacro %} {% 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 %} {% set step_count = steps|length %}
<multi-step-modal-form inline-template :steps={{ step_count }}> <multi-step-modal-form inline-template :steps={{ step_count }}>
{% call Modal(name=name, dismissable=dismissable) %} {% call Modal(name=name, dismissable=dismissable, classes=classes) %}
<form id="{{ name }}" action="{{ form_action }}" method="POST" v-on:submit="handleSubmit"> <form id="{{ name }}" action="{{ form_action }}" method="POST" v-on:submit="handleSubmit">
{{ form.csrf_token }} {{ form.csrf_token }}
<div v-if="$root.activeModal === '{{ name }}'"> <div v-if="$root.activeModal === '{{ name }}'">

View File

@ -13,7 +13,7 @@
<div v-cloak class="portfolio-admin"> <div v-cloak class="portfolio-admin">
{% include "fragments/flash.html" %} {% include "fragments/flash.html" %}
<!-- max width of this section is 460px --> <!-- max width of this section is 460px -->
<section class="form-container__half"> <section class="form-container--narrow">
<h3>Portfolio name and component</h3> <h3>Portfolio name and component</h3>
{% if user_can(permissions.EDIT_PORTFOLIO_NAME) %} {% if user_can(permissions.EDIT_PORTFOLIO_NAME) %}
<base-form inline-template> <base-form inline-template>

View File

@ -14,7 +14,7 @@
{% set invite_expired = member.status == 'invite_expired' %} {% set invite_expired = member.status == 'invite_expired' %}
{% set modal_name = "edit_member-{}".format(loop.index) %} {% 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") %}
<div class="modal__form--header"> <div class="modal__form--header">
<h1>{{ Icon('avatar') }} {{ "portfolios.applications.members.form.edit_access_header" | translate({ "user": member.user_name }) }}</h1> <h1>{{ Icon('avatar') }} {{ "portfolios.applications.members.form.edit_access_header" | translate({ "user": member.user_name }) }}</h1>
</div> </div>
@ -34,7 +34,7 @@
{% if invite_pending or invite_expired -%} {% if invite_pending or invite_expired -%}
{% set resend_invite_modal = "resend_invite-{}".format(member.role_id) %} {% 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") %}
<div class="modal__form--header"> <div class="modal__form--header">
<h1>{{ "portfolios.applications.members.new.verify" | translate }}</h1> <h1>{{ "portfolios.applications.members.new.verify" | translate }}</h1>
</div> </div>
@ -182,6 +182,7 @@
modal=new_manager_modal, modal=new_manager_modal,
) )
], ],
classes="form-content--member-form",
) }} ) }}
{% endif %} {% endif %}
</div> </div>

View File

@ -19,24 +19,25 @@
{{ StickyCTA(text="portfolios.new.cta_step_1"|translate, context=("portfolios.new.sticky_header_context"|translate({"step": "1"}) )) }} {{ StickyCTA(text="portfolios.new.cta_step_1"|translate, context=("portfolios.new.sticky_header_context"|translate({"step": "1"}) )) }}
<base-form inline-template> <base-form inline-template>
<div class="row"> <div class="row">
<form id="portfolio-create" class="col form" action="{{ url_for('portfolios.create_portfolio') }}" method="POST"> <form id="portfolio-create" class="col form-container" action="{{ url_for('portfolios.create_portfolio') }}" method="POST">
{{ form.csrf_token }} {{ form.csrf_token }}
<div class="form-row form-row--bordered"> <div class="form-row">
<div class="form-col"> <div class="form-col">
{{ TextInput(form.name, validation="portfolioName", optional=False, classes="form-col") }} {{ TextInput(form.name, validation="portfolioName", optional=False, classes="form-col") }}
{{"forms.portfolio.name.help_text" | translate | safe }} {{"forms.portfolio.name.help_text" | translate | safe }}
</div> </div>
</div> </div>
<div class="form-row form-row--bordered"> <hr>
<div class="form-row">
<div class="form-col"> <div class="form-col">
{{ TextInput(form.description, validation="defaultTextAreaField", paragraph=True) }} {{ TextInput(form.description, validation="defaultTextAreaField", paragraph=True) }}
{{"forms.portfolio.description.help_text" | translate | safe }} {{"forms.portfolio.description.help_text" | translate | safe }}
</div> </div>
</div> </div>
<hr>
<div class="form-row"> <div class="form-row">
<div class="form-col"> <div class="form-col">
{{ MultiCheckboxInput(form.defense_component, optional=False) }} {{ MultiCheckboxInput(form.defense_component, optional=False) }}
{{ "forms.portfolio.defense_component.help_text" | translate | safe }}
</div> </div>
</div> </div>
<div <div

View File

@ -28,7 +28,7 @@
{% include "fragments/flash.html" %} {% include "fragments/flash.html" %}
<div class="task-order form"> <div class="task-order form-container">
{% block to_builder_form_field %}{% endblock %} {% block to_builder_form_field %}{% endblock %}
</div> </div>
<div <div

View File

@ -1523,3 +1523,23 @@ def test_update_tenant_creds(mock_azure: AzureCloudProvider):
assert updated_secret == KeyVaultCredentials( assert updated_secret == KeyVaultCredentials(
**{**existing_secrets, **MOCK_CREDS} **{**existing_secrets, **MOCK_CREDS}
) )
def test_get_calculator_creds(mock_azure: AzureCloudProvider):
mock_azure.sdk.adal.AuthenticationContext.return_value.acquire_token_with_client_credentials.return_value = {
"accessToken": "TOKEN"
}
assert mock_azure._get_calculator_creds() == "TOKEN"
def test_get_calculator_url(mock_azure: AzureCloudProvider):
with patch.object(
AzureCloudProvider,
"_get_calculator_creds",
wraps=mock_azure._get_calculator_creds,
) as _get_calculator_creds:
_get_calculator_creds.return_value = "TOKEN"
assert (
mock_azure.get_calculator_url()
== f"{mock_azure.config.get('AZURE_CALC_URL')}?access_token=TOKEN"
)

View File

@ -4,6 +4,9 @@ from unittest.mock import Mock
from atst.domain.csp.cloud import AzureCloudProvider from atst.domain.csp.cloud import AzureCloudProvider
AZURE_CONFIG = { AZURE_CONFIG = {
"AZURE_CALC_CLIENT_ID": "MOCK",
"AZURE_CALC_SECRET": "MOCK", # pragma: allowlist secret
"AZURE_CALC_RESOURCE": "http://calc",
"AZURE_CLIENT_ID": "MOCK", "AZURE_CLIENT_ID": "MOCK",
"AZURE_SECRET_KEY": "MOCK", "AZURE_SECRET_KEY": "MOCK",
"AZURE_TENANT_ID": "MOCK", "AZURE_TENANT_ID": "MOCK",

View File

@ -16,7 +16,6 @@ from atst.jobs import (
dispatch_create_user, dispatch_create_user,
dispatch_create_environment_role, dispatch_create_environment_role,
dispatch_provision_portfolio, dispatch_provision_portfolio,
dispatch_send_task_order_files,
create_environment, create_environment,
do_create_user, do_create_user,
do_provision_portfolio, do_provision_portfolio,
@ -24,6 +23,7 @@ from atst.jobs import (
do_create_environment_role, do_create_environment_role,
do_create_application, do_create_application,
send_PPOC_email, send_PPOC_email,
send_task_order_files,
) )
from tests.factories import ( from tests.factories import (
ApplicationFactory, ApplicationFactory,
@ -135,28 +135,63 @@ def test_create_application_job_is_idempotent(csp):
csp.create_application.assert_not_called() csp.create_application.assert_not_called()
def test_create_user_job(session, csp, app): class TestCreateUserJob:
portfolio = PortfolioFactory.create( @pytest.fixture
csp_data={ def portfolio(self, app):
"tenant_id": str(uuid4()), return PortfolioFactory.create(
"domain_name": f"rebelalliance.{app.config.get('OFFICE_365_DOMAIN')}", csp_data={
} "tenant_id": str(uuid4()),
) "domain_name": f"rebelalliance.{app.config.get('OFFICE_365_DOMAIN')}",
application = ApplicationFactory.create(portfolio=portfolio, cloud_id="321") }
user = UserFactory.create( )
first_name="Han", last_name="Solo", email="han@example.com"
)
app_role = ApplicationRoleFactory.create(
application=application,
user=user,
status=ApplicationRoleStatus.ACTIVE,
cloud_id=None,
)
do_create_user(csp, [app_role.id]) @pytest.fixture
session.refresh(app_role) def app_1(self, portfolio):
return ApplicationFactory.create(portfolio=portfolio, cloud_id="321")
assert app_role.cloud_id @pytest.fixture
def app_2(self, portfolio):
return ApplicationFactory.create(portfolio=portfolio, cloud_id="123")
@pytest.fixture
def user(self):
return UserFactory.create(
first_name="Han", last_name="Solo", email="han@example.com"
)
@pytest.fixture
def app_role_1(self, app_1, user):
return ApplicationRoleFactory.create(
application=app_1,
user=user,
status=ApplicationRoleStatus.ACTIVE,
cloud_id=None,
)
@pytest.fixture
def app_role_2(self, app_2, user):
return ApplicationRoleFactory.create(
application=app_2,
user=user,
status=ApplicationRoleStatus.ACTIVE,
cloud_id=None,
)
def test_create_user_job(self, session, csp, app_role_1):
assert not app_role_1.cloud_id
session.begin_nested()
do_create_user(csp, [app_role_1.id])
session.rollback()
assert app_role_1.cloud_id
def test_create_user_sends_email(self, monkeypatch, csp, app_role_1, app_role_2):
mock = Mock()
monkeypatch.setattr("atst.jobs.send_mail", mock)
do_create_user(csp, [app_role_1.id, app_role_2.id])
assert mock.call_count == 1
def test_dispatch_create_environment(session, monkeypatch): def test_dispatch_create_environment(session, monkeypatch):
@ -380,82 +415,77 @@ def test_create_environment_role():
assert env_role.cloud_id == "a-cloud-id" assert env_role.cloud_id == "a-cloud-id"
# TODO: Refactor the tests related to dispatch_send_task_order_files() into a class class TestSendTaskOrderFiles:
# and separate the success test into two tests @pytest.fixture(scope="function")
def test_dispatch_send_task_order_files(monkeypatch, app): def send_mail(self, monkeypatch):
mock = Mock() mock = Mock()
monkeypatch.setattr("atst.jobs.send_mail", mock) monkeypatch.setattr("atst.jobs.send_mail", mock)
return mock
def _download_task_order(MockFileService, object_name): @pytest.fixture(scope="function")
return {"name": object_name} def download_task_order(self, monkeypatch):
def _download_task_order(MockFileService, object_name):
return {"name": object_name}
monkeypatch.setattr( monkeypatch.setattr(
"atst.domain.csp.files.MockFileService.download_task_order", "atst.domain.csp.files.MockFileService.download_task_order",
_download_task_order, _download_task_order,
) )
# Create 3 new Task Orders def test_sends_multiple_emails(self, send_mail, download_task_order):
for i in range(3): # Create 3 Task Orders
TaskOrderFactory.create(create_clins=[{"number": "0001"}]) for i in range(3):
TaskOrderFactory.create(create_clins=[{"number": "0001"}])
dispatch_send_task_order_files.run() send_task_order_files.run()
# Check that send_with_attachment was called once for each task order # Check that send_with_attachment was called once for each task order
assert mock.call_count == 3 assert send_mail.call_count == 3
mock.reset_mock()
# Create new TO def test_kwargs(self, send_mail, download_task_order, app):
task_order = TaskOrderFactory.create(create_clins=[{"number": "0001"}]) task_order = TaskOrderFactory.create(create_clins=[{"number": "0001"}])
assert not task_order.pdf_last_sent_at send_task_order_files.run()
dispatch_send_task_order_files.run() # Check that send_with_attachment was called with correct kwargs
send_mail.assert_called_once_with(
recipients=[app.config.get("MICROSOFT_TASK_ORDER_EMAIL_ADDRESS")],
subject=translate(
"email.task_order_sent.subject", {"to_number": task_order.number}
),
body=translate(
"email.task_order_sent.body", {"to_number": task_order.number}
),
attachments=[
{
"name": task_order.pdf.object_name,
"maintype": "application",
"subtype": "pdf",
}
],
)
assert task_order.pdf_last_sent_at
# Check that send_with_attachment was called with correct kwargs def test_send_failure(self, monkeypatch):
mock.assert_called_once_with( def _raise_smtp_exception(**kwargs):
recipients=[app.config.get("MICROSOFT_TASK_ORDER_EMAIL_ADDRESS")], raise SMTPException
subject=translate(
"email.task_order_sent.subject", {"to_number": task_order.number}
),
body=translate("email.task_order_sent.body", {"to_number": task_order.number}),
attachments=[
{
"name": task_order.pdf.object_name,
"maintype": "application",
"subtype": "pdf",
}
],
)
assert task_order.pdf_last_sent_at monkeypatch.setattr("atst.jobs.send_mail", _raise_smtp_exception)
task_order = TaskOrderFactory.create(create_clins=[{"number": "0001"}])
send_task_order_files.run()
# Check that pdf_last_sent_at has not been updated
assert not task_order.pdf_last_sent_at
def test_dispatch_send_task_order_files_send_failure(monkeypatch): def test_download_failure(self, send_mail, monkeypatch):
def _raise_smtp_exception(**kwargs): def _download_task_order(MockFileService, object_name):
raise SMTPException raise AzureError("something went wrong")
monkeypatch.setattr("atst.jobs.send_mail", _raise_smtp_exception) monkeypatch.setattr(
"atst.domain.csp.files.MockFileService.download_task_order",
_download_task_order,
)
task_order = TaskOrderFactory.create(create_clins=[{"number": "0002"}])
send_task_order_files.run()
task_order = TaskOrderFactory.create(create_clins=[{"number": "0001"}]) # Check that pdf_last_sent_at has not been updated
dispatch_send_task_order_files.run() assert not task_order.pdf_last_sent_at
# Check that pdf_last_sent_at has not been updated
assert not task_order.pdf_last_sent_at
def test_dispatch_send_task_order_files_download_failure(monkeypatch):
mock = Mock()
monkeypatch.setattr("atst.jobs.send_mail", mock)
def _download_task_order(MockFileService, object_name):
raise AzureError("something went wrong")
monkeypatch.setattr(
"atst.domain.csp.files.MockFileService.download_task_order",
_download_task_order,
)
task_order = TaskOrderFactory.create(create_clins=[{"number": "0002"}])
dispatch_send_task_order_files.run()
# Check that pdf_last_sent_at has not been updated
assert not task_order.pdf_last_sent_at

View File

@ -83,7 +83,10 @@ errors:
not_found_sub: This page does not exist. not_found_sub: This page does not exist.
email: email:
application_invite: "{inviter_name} has invited you to a JEDI cloud application" application_invite: "{inviter_name} has invited you to a JEDI cloud application"
portfolio_invite: "{inviter_name} has invited you to a JEDI cloud portfolio" app_role_created:
subject: Application Role Created
body: "Your application role has been created.\nVisit {url}, and use your username, {username}, to log in."
portfolio_invite: "{inviter_name} has invited you to a JEDI cloud portfolio."
portfolio_ready: portfolio_ready:
subject: Portfolio Provisioned subject: Portfolio Provisioned
body: "Your portfolio has been provisioned.\nVisit {password_reset_address}, and use your username, {username}, to create a password." body: "Your portfolio has been provisioned.\nVisit {password_reset_address}, and use your username, {username}, to create a password."
@ -268,7 +271,7 @@ forms:
label: Portfolio Name label: Portfolio Name
length_validation_message: Portfolio names can be between 4-100 characters length_validation_message: Portfolio names can be between 4-100 characters
help_text: | help_text: |
<div> <div class="usa-input__help">
<p> <p>
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. 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.
</p> </p>
@ -282,7 +285,7 @@ forms:
description: description:
label: Portfolio Description label: Portfolio Description
help_text: | help_text: |
<div> <div class="usa-input__help">
<p> <p>
Add a brief one to two sentence description of your Portfolio. Consider this your statement of work. Add a brief one to two sentence description of your Portfolio. Consider this your statement of work.
</p> </p>
@ -307,11 +310,9 @@ forms:
title: Select DoD component(s) funding your Portfolio title: Select DoD component(s) funding your Portfolio
validation_message: You must select at least one defense component. validation_message: You must select at least one defense component.
help_text: | help_text: |
<p>
Select the DOD component(s) that will fund all Applications within this Portfolio. Select the DOD component(s) that will fund all Applications within this Portfolio.
In JEDI, multiple DoD organizations can fund the same Portfolio.<br/> In JEDI, multiple DoD organizations can fund the same Portfolio.<br/>
Select all that apply.<br/> Select all that apply.
</p>
attachment: attachment:
object_name: object_name:
length_error: Object name may be no longer than 40 characters. 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. 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: total_value:
header: Total Portfolio 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: task_orders:
add_new_button: Add New Task Order add_new_button: Add New Task Order
review: review: