Merge branch 'staging' into state-machine-unit-tests

This commit is contained in:
tomdds 2020-02-20 10:21:41 -05:00 committed by GitHub
commit be53a1fd1a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 404 additions and 159 deletions

View File

@ -96,6 +96,7 @@ commands:
apk del --purge build apk del --purge build
- run: - run:
name: Login to Azure CLI name: Login to Azure CLI
shell: /bin/sh -o pipefail
command: | command: |
az login \ az login \
--service-principal \ --service-principal \

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

@ -90,3 +90,13 @@ class TaskOrders(BaseDomainClass):
) )
.all() .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()
)

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

@ -10,6 +10,7 @@ from atst.domain.csp.cloud import CloudProviderInterface
from atst.domain.csp.cloud.exceptions import GeneralCSPException from atst.domain.csp.cloud.exceptions import GeneralCSPException
from atst.domain.csp.cloud.models import ( from atst.domain.csp.cloud.models import (
ApplicationCSPPayload, ApplicationCSPPayload,
BillingInstructionCSPPayload,
EnvironmentCSPPayload, EnvironmentCSPPayload,
UserCSPPayload, UserCSPPayload,
UserRoleCSPPayload, UserRoleCSPPayload,
@ -111,6 +112,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 +293,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 +314,36 @@ 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()
@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()

View File

@ -66,12 +66,14 @@ class CLIN(Base, mixins.TimestampsMixin):
) )
def to_dictionary(self): def to_dictionary(self):
return { data = {
c.name: getattr(self, c.name) c.name: getattr(self, c.name)
for c in self.__table__.columns for c in self.__table__.columns
if c.name not in ["id"] if c.name not in ["id"]
} }
return data
@property @property
def is_active(self): def is_active(self):
return ( return (

View File

@ -27,6 +27,14 @@ 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,
},
"beat-create_billing_instruction": {
"task": "atst.jobs.create_billing_instruction",
"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

@ -9,6 +9,27 @@ from atst.models.task_order import TaskOrder, SORT_ORDERING, Status
from tests.factories import TaskOrderFactory, CLINFactory, PortfolioFactory from tests.factories import TaskOrderFactory, CLINFactory, PortfolioFactory
@pytest.fixture
def new_task_order():
return TaskOrderFactory.create(create_clins=[{}])
@pytest.fixture
def updated_task_order():
return TaskOrderFactory.create(
create_clins=[{"last_sent_at": pendulum.date(2020, 2, 1)}],
pdf_last_sent_at=pendulum.date(2020, 1, 1),
)
@pytest.fixture
def sent_task_order():
return TaskOrderFactory.create(
create_clins=[{"last_sent_at": pendulum.date(2020, 1, 1)}],
pdf_last_sent_at=pendulum.date(2020, 1, 1),
)
def test_create_adds_clins(): def test_create_adds_clins():
portfolio = PortfolioFactory.create() portfolio = PortfolioFactory.create()
clins = [ clins = [
@ -181,19 +202,18 @@ def test_allows_alphanumeric_number():
assert TaskOrders.create(portfolio.id, number, [], None) assert TaskOrders.create(portfolio.id, number, [], None)
def test_get_for_send_task_order_files(): def test_get_for_send_task_order_files(
new_to = TaskOrderFactory.create(create_clins=[{}]) new_task_order, updated_task_order, sent_task_order
updated_to = TaskOrderFactory.create( ):
create_clins=[{"last_sent_at": pendulum.datetime(2020, 2, 1)}],
pdf_last_sent_at=pendulum.datetime(2020, 1, 1),
)
sent_to = TaskOrderFactory.create(
create_clins=[{"last_sent_at": pendulum.datetime(2020, 1, 1)}],
pdf_last_sent_at=pendulum.datetime(2020, 1, 1),
)
updated_and_new_task_orders = TaskOrders.get_for_send_task_order_files() updated_and_new_task_orders = TaskOrders.get_for_send_task_order_files()
assert len(updated_and_new_task_orders) == 2 assert len(updated_and_new_task_orders) == 2
assert sent_to not in updated_and_new_task_orders assert sent_task_order not in updated_and_new_task_orders
assert updated_to in updated_and_new_task_orders assert updated_task_order in updated_and_new_task_orders
assert new_to in updated_and_new_task_orders assert new_task_order in updated_and_new_task_orders
def test_get_clins_for_create_billing_instructions(new_task_order, sent_task_order):
new_clins = TaskOrders.get_clins_for_create_billing_instructions()
assert len(new_clins) == 1
assert new_task_order.clins[0] in new_clins
assert sent_task_order.clins[0] not in new_clins

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

@ -6,7 +6,8 @@ from smtplib import SMTPException
from azure.core.exceptions import AzureError from azure.core.exceptions import AzureError
from atst.domain.csp.cloud import MockCloudProvider from atst.domain.csp.cloud import MockCloudProvider
from atst.domain.csp.cloud.models import UserRoleCSPResult from atst.domain.csp.cloud.models import BillingInstructionCSPPayload, UserRoleCSPResult
from atst.domain.portfolios import Portfolios
from atst.models import ApplicationRoleStatus, Portfolio, FSMStates from atst.models import ApplicationRoleStatus, Portfolio, FSMStates
from atst.jobs import ( from atst.jobs import (
@ -16,7 +17,7 @@ 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_billing_instruction,
create_environment, create_environment,
do_create_user, do_create_user,
do_provision_portfolio, do_provision_portfolio,
@ -24,10 +25,12 @@ 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,
ApplicationRoleFactory, ApplicationRoleFactory,
CLINFactory,
EnvironmentFactory, EnvironmentFactory,
EnvironmentRoleFactory, EnvironmentRoleFactory,
PortfolioFactory, PortfolioFactory,
@ -135,28 +138,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 +418,152 @@ 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_download_failure(self, send_mail, monkeypatch):
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"}])
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): class TestCreateBillingInstructions:
def _raise_smtp_exception(**kwargs): @pytest.fixture
raise SMTPException def unsent_clin(self):
start_date = pendulum.now().subtract(days=1)
portfolio = PortfolioFactory.create(
csp_data={
"tenant_id": str(uuid4()),
"billing_account_name": "fake",
"billing_profile_name": "fake",
},
task_orders=[{"create_clins": [{"start_date": start_date}]}],
)
return portfolio.task_orders[0].clins[0]
monkeypatch.setattr("atst.jobs.send_mail", _raise_smtp_exception) def test_update_clin_last_sent_at(self, session, unsent_clin):
assert not unsent_clin.last_sent_at
task_order = TaskOrderFactory.create(create_clins=[{"number": "0001"}]) # The session needs to be nested to prevent detached SQLAlchemy instance
dispatch_send_task_order_files.run() session.begin_nested()
create_billing_instruction()
# Check that pdf_last_sent_at has not been updated # check that last_sent_at has been updated
assert not task_order.pdf_last_sent_at assert unsent_clin.last_sent_at
session.rollback()
def test_failure(self, monkeypatch, session, unsent_clin):
def _create_billing_instruction(MockCloudProvider, object_name):
raise AzureError("something went wrong")
def test_dispatch_send_task_order_files_download_failure(monkeypatch): monkeypatch.setattr(
mock = Mock() "atst.domain.csp.cloud.MockCloudProvider.create_billing_instruction",
monkeypatch.setattr("atst.jobs.send_mail", mock) _create_billing_instruction,
)
def _download_task_order(MockFileService, object_name): # The session needs to be nested to prevent detached SQLAlchemy instance
raise AzureError("something went wrong") session.begin_nested()
create_billing_instruction()
monkeypatch.setattr( # check that last_sent_at has not been updated
"atst.domain.csp.files.MockFileService.download_task_order", assert not unsent_clin.last_sent_at
_download_task_order, session.rollback()
)
task_order = TaskOrderFactory.create(create_clins=[{"number": "0002"}]) def test_task_order_with_multiple_clins(self, session):
dispatch_send_task_order_files.run() start_date = pendulum.now(tz="UTC").subtract(days=1)
portfolio = PortfolioFactory.create(
csp_data={
"tenant_id": str(uuid4()),
"billing_account_name": "fake",
"billing_profile_name": "fake",
},
task_orders=[
{
"create_clins": [
{"start_date": start_date, "last_sent_at": start_date}
]
}
],
)
task_order = portfolio.task_orders[0]
sent_clin = task_order.clins[0]
# Check that pdf_last_sent_at has not been updated # Add new CLIN to the Task Order
assert not task_order.pdf_last_sent_at new_clin = CLINFactory.create(task_order=task_order)
assert not new_clin.last_sent_at
session.begin_nested()
create_billing_instruction()
session.add(sent_clin)
# check that last_sent_at has been update for the new clin only
assert new_clin.last_sent_at
assert sent_clin.last_sent_at != new_clin.last_sent_at
session.rollback()

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: