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$",
"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"
}
],

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).
- `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

View File

@ -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}"

View File

@ -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(),

View File

@ -111,6 +111,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 +292,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 +313,7 @@ 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()

View File

@ -27,6 +27,10 @@ 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,
},
}
class ContextTask(celery.Task):

View File

@ -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

View File

@ -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

View File

@ -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;
}
}

View File

@ -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;
}

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 {
padding: 0;
}
.form {
margin-bottom: $action-footer-height + $large-spacing;
}

View File

@ -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

View File

@ -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;

View File

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

View File

@ -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") %}
<div class="modal__form--header">
<h1>{{ Icon('avatar') }} {{ "portfolios.applications.members.form.edit_access_header" | translate({ "user": member.user_name }) }}</h1>
</div>
@ -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") %}
<div class="modal__form--header">
<h1>{{ "portfolios.applications.members.new.verify" | translate }}</h1>
</div>
@ -183,6 +183,7 @@
modal=new_member_modal_name,
)
],
classes="form-content--member-form",
) }}
{% endif %}
</div>

View File

@ -22,7 +22,7 @@
{% include "fragments/flash.html" %}
<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 }}
<div class="form-row">
<div class="form-col">

View File

@ -21,7 +21,7 @@
</p>
<hr>
<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="panel">
<div class="panel__content">

View File

@ -20,7 +20,7 @@
{% if user_can(permissions.EDIT_APPLICATION) %}
<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 }}
{{ TextInput(application_form.name, validation="applicationName", optional=False) }}
{{ TextInput(application_form.description, validation="defaultTextAreaField", paragraph=True, optional=True, showOptional=False) }}

View File

@ -25,10 +25,10 @@
</div>
{% 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 %}
<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.csrf_token }}
<div v-if="$root.activeModal === '{{ name }}'">

View File

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

View File

@ -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") %}
<div class="modal__form--header">
<h1>{{ Icon('avatar') }} {{ "portfolios.applications.members.form.edit_access_header" | translate({ "user": member.user_name }) }}</h1>
</div>
@ -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") %}
<div class="modal__form--header">
<h1>{{ "portfolios.applications.members.new.verify" | translate }}</h1>
</div>
@ -182,6 +182,7 @@
modal=new_manager_modal,
)
],
classes="form-content--member-form",
) }}
{% endif %}
</div>

View File

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

View File

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

View File

@ -1523,3 +1523,23 @@ def test_update_tenant_creds(mock_azure: AzureCloudProvider):
assert updated_secret == KeyVaultCredentials(
**{**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
AZURE_CONFIG = {
"AZURE_CALC_CLIENT_ID": "MOCK",
"AZURE_CALC_SECRET": "MOCK", # pragma: allowlist secret
"AZURE_CALC_RESOURCE": "http://calc",
"AZURE_CLIENT_ID": "MOCK",
"AZURE_SECRET_KEY": "MOCK",
"AZURE_TENANT_ID": "MOCK",

View File

@ -16,7 +16,6 @@ from atst.jobs import (
dispatch_create_user,
dispatch_create_environment_role,
dispatch_provision_portfolio,
dispatch_send_task_order_files,
create_environment,
do_create_user,
do_provision_portfolio,
@ -24,6 +23,7 @@ from atst.jobs import (
do_create_environment_role,
do_create_application,
send_PPOC_email,
send_task_order_files,
)
from tests.factories import (
ApplicationFactory,
@ -135,28 +135,63 @@ def test_create_application_job_is_idempotent(csp):
csp.create_application.assert_not_called()
def test_create_user_job(session, csp, app):
portfolio = PortfolioFactory.create(
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,
)
class TestCreateUserJob:
@pytest.fixture
def portfolio(self, app):
return PortfolioFactory.create(
csp_data={
"tenant_id": str(uuid4()),
"domain_name": f"rebelalliance.{app.config.get('OFFICE_365_DOMAIN')}",
}
)
do_create_user(csp, [app_role.id])
session.refresh(app_role)
@pytest.fixture
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):
@ -380,82 +415,77 @@ def test_create_environment_role():
assert env_role.cloud_id == "a-cloud-id"
# TODO: Refactor the tests related to dispatch_send_task_order_files() into a class
# and separate the success test into two tests
def test_dispatch_send_task_order_files(monkeypatch, app):
mock = Mock()
monkeypatch.setattr("atst.jobs.send_mail", mock)
class TestSendTaskOrderFiles:
@pytest.fixture(scope="function")
def send_mail(self, monkeypatch):
mock = Mock()
monkeypatch.setattr("atst.jobs.send_mail", mock)
return mock
def _download_task_order(MockFileService, object_name):
return {"name": object_name}
@pytest.fixture(scope="function")
def download_task_order(self, monkeypatch):
def _download_task_order(MockFileService, object_name):
return {"name": object_name}
monkeypatch.setattr(
"atst.domain.csp.files.MockFileService.download_task_order",
_download_task_order,
)
monkeypatch.setattr(
"atst.domain.csp.files.MockFileService.download_task_order",
_download_task_order,
)
# Create 3 new Task Orders
for i in range(3):
TaskOrderFactory.create(create_clins=[{"number": "0001"}])
def test_sends_multiple_emails(self, send_mail, download_task_order):
# Create 3 Task Orders
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
assert mock.call_count == 3
mock.reset_mock()
# Check that send_with_attachment was called once for each task order
assert send_mail.call_count == 3
# Create new TO
task_order = TaskOrderFactory.create(create_clins=[{"number": "0001"}])
assert not task_order.pdf_last_sent_at
def test_kwargs(self, send_mail, download_task_order, app):
task_order = TaskOrderFactory.create(create_clins=[{"number": "0001"}])
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
mock.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",
}
],
)
def test_send_failure(self, monkeypatch):
def _raise_smtp_exception(**kwargs):
raise SMTPException
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 _raise_smtp_exception(**kwargs):
raise SMTPException
def test_download_failure(self, send_mail, monkeypatch):
def _download_task_order(MockFileService, object_name):
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"}])
dispatch_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_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
# 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.
email:
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:
subject: Portfolio Provisioned
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
length_validation_message: Portfolio names can be between 4-100 characters
help_text: |
<div>
<div class="usa-input__help">
<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.
</p>
@ -282,7 +285,7 @@ forms:
description:
label: Portfolio Description
help_text: |
<div>
<div class="usa-input__help">
<p>
Add a brief one to two sentence description of your Portfolio. Consider this your statement of work.
</p>
@ -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: |
<p>
Select the DOD component(s) that will fund all Applications within this Portfolio.
In JEDI, multiple DoD organizations can fund the same Portfolio.<br/>
Select all that apply.<br/>
</p>
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: