diff --git a/.secrets.baseline b/.secrets.baseline
index 4e393738..45f10336 100644
--- a/.secrets.baseline
+++ b/.secrets.baseline
@@ -3,7 +3,7 @@
"files": "^.secrets.baseline$|^.*pgsslrootcert.yml$",
"lines": null
},
- "generated_at": "2020-02-10T21:40:38Z",
+ "generated_at": "2020-02-12T18:51:01Z",
"plugins_used": [
{
"base64_limit": 4.5,
@@ -82,7 +82,7 @@
"hashed_secret": "afc848c316af1a89d49826c5ae9d00ed769415f3",
"is_secret": false,
"is_verified": false,
- "line_number": 43,
+ "line_number": 44,
"type": "Secret Keyword"
}
],
diff --git a/README.md b/README.md
index b8530135..accecd38 100644
--- a/README.md
+++ b/README.md
@@ -219,6 +219,7 @@ 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_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
- `BLOB_STORAGE_URL`: URL to Azure blob storage container.
diff --git a/atst/app.py b/atst/app.py
index 04aed44d..1499671c 100644
--- a/atst/app.py
+++ b/atst/app.py
@@ -186,6 +186,7 @@ def map_config(config):
# with a Beat job once a day)
"CELERY_RESULT_EXPIRES": 0,
"CELERY_RESULT_EXTENDED": True,
+ "OFFICE_365_DOMAIN": "onmicrosoft.com",
"CONTRACT_START_DATE": datetime.strptime(
config.get("default", "CONTRACT_START_DATE"), "%Y-%m-%d"
).date(),
diff --git a/atst/domain/csp/cloud/azure_cloud_provider.py b/atst/domain/csp/cloud/azure_cloud_provider.py
index 7c976b8d..b85bed09 100644
--- a/atst/domain/csp/cloud/azure_cloud_provider.py
+++ b/atst/domain/csp/cloud/azure_cloud_provider.py
@@ -327,9 +327,7 @@ class AzureCloudProvider(CloudProviderInterface):
if result.status_code == 200:
result_dict = result.json()
tenant_id = result_dict.get("tenantId")
- tenant_admin_username = (
- f"{payload.user_id}@{payload.domain_name}.onmicrosoft.com"
- )
+ tenant_admin_username = f"{payload.user_id}@{payload.domain_name}.{self.config.get('OFFICE_365_DOMAIN')}"
self.update_tenant_creds(
tenant_id,
KeyVaultCredentials(
diff --git a/atst/domain/csp/cloud/models.py b/atst/domain/csp/cloud/models.py
index 859158a0..27f2c9c7 100644
--- a/atst/domain/csp/cloud/models.py
+++ b/atst/domain/csp/cloud/models.py
@@ -4,6 +4,7 @@ from typing import Dict, List, Optional
from uuid import uuid4
import re
+from flask import current_app as app
from pydantic import BaseModel, validator, root_validator
from atst.utils import snake_to_camel
@@ -526,7 +527,7 @@ class UserMixin(BaseModel):
@property
def user_principal_name(self):
- return f"{self.mail_nickname}@{self.tenant_host_name}.onmicrosoft.com"
+ return f"{self.mail_nickname}@{self.tenant_host_name}.{app.config.get('OFFICE_365_DOMAIN')}"
@property
def mail_nickname(self):
diff --git a/atst/jobs.py b/atst/jobs.py
index 0d462f0f..77a8c2f6 100644
--- a/atst/jobs.py
+++ b/atst/jobs.py
@@ -18,6 +18,7 @@ from atst.domain.environments import Environments
from atst.domain.environment_roles import EnvironmentRoles
from atst.domain.portfolios import Portfolios
from atst.models import CSPRole, JobFailure
+from atst.models.mixins.state_machines import FSMStates
from atst.domain.task_orders import TaskOrders
from atst.models.utils import claim_for_update, claim_many_for_update
from atst.queue import celery
@@ -177,10 +178,30 @@ def do_work(fn, task, csp, **kwargs):
raise task.retry(exc=e)
+def send_PPOC_email(portfolio_dict):
+ ppoc_email = portfolio_dict.get("password_recovery_email_address")
+ user_id = portfolio_dict.get("user_id")
+ domain_name = portfolio_dict.get("domain_name")
+
+ send_mail(
+ recipients=[ppoc_email],
+ subject=translate("email.portfolio_ready.subject"),
+ body=translate(
+ "email.portfolio_ready.body",
+ {
+ "password_reset_address": app.config.get("AZURE_LOGIN_URL"),
+ "username": f"{user_id}@{domain_name}.{app.config.get('OFFICE_365_DOMAIN')}",
+ },
+ ),
+ )
+
+
def do_provision_portfolio(csp: CloudProviderInterface, portfolio_id=None):
portfolio = Portfolios.get_for_update(portfolio_id)
fsm = Portfolios.get_or_create_state_machine(portfolio)
fsm.trigger_next_transition(csp_data=portfolio.to_dictionary())
+ if fsm.current_state == FSMStates.COMPLETED:
+ send_PPOC_email(portfolio.to_dictionary())
@celery.task(bind=True, base=RecordFailure)
diff --git a/atst/models/task_order.py b/atst/models/task_order.py
index 370cc325..d2a63964 100644
--- a/atst/models/task_order.py
+++ b/atst/models/task_order.py
@@ -76,7 +76,7 @@ class TaskOrder(Base, mixins.TimestampsMixin):
@property
def is_draft(self):
- return self.status == Status.DRAFT
+ return self.status == Status.DRAFT or self.status == Status.UNSIGNED
@property
def is_active(self):
diff --git a/config/base.ini b/config/base.ini
index 727172d8..3aa9ad86 100644
--- a/config/base.ini
+++ b/config/base.ini
@@ -4,6 +4,7 @@ AZURE_AADP_QTY=5
AZURE_ACCOUNT_NAME
AZURE_CLIENT_ID
AZURE_GRAPH_RESOURCE="https://graph.microsoft.com/"
+AZURE_LOGIN_URL="https://portal.azure.com/"
AZURE_POLICY_LOCATION=policies
AZURE_POWERSHELL_CLIENT_ID
AZURE_ROLE_DEF_ID_BILLING_READER="fa23ad8b-c56e-40d8-ac0c-ce449e1d2c64"
diff --git a/js/components/forms/multi_step_modal_form.js b/js/components/forms/multi_step_modal_form.js
index 5cc5f4df..2cf20bba 100644
--- a/js/components/forms/multi_step_modal_form.js
+++ b/js/components/forms/multi_step_modal_form.js
@@ -55,6 +55,10 @@ export default {
return this.step === this.steps - 1
},
handleSubmit: function(e) {
+ if (this._onLastPage) {
+ this.submitted = true
+ }
+
if (!this.validateFields() || !this._onLastPage()) {
e.preventDefault()
this.next()
diff --git a/js/components/upload_input.js b/js/components/upload_input.js
index 4f9f06fc..8bf806e9 100644
--- a/js/components/upload_input.js
+++ b/js/components/upload_input.js
@@ -72,7 +72,7 @@ export default {
const uploader = await this.getUploader()
const response = await uploader.upload(file)
if (uploadResponseOkay(response)) {
- this.attachment = e.target.value
+ this.attachment = file.name
this.objectName = uploader.objectName
this.$refs.attachmentFilename.value = file.name
this.$refs.attachmentObjectName.value = response.objectName
diff --git a/templates/components/upload_input.html b/templates/components/upload_input.html
index bd4cd73c..b4292284 100644
--- a/templates/components/upload_input.html
+++ b/templates/components/upload_input.html
@@ -43,7 +43,6 @@
:id="name"
:name="name"
aria-label="Task Order Upload"
- v-bind:value="attachment"
type="file">
diff --git a/tests/domain/cloud/test_models.py b/tests/domain/cloud/test_models.py
index e9951c9d..68bcd351 100644
--- a/tests/domain/cloud/test_models.py
+++ b/tests/domain/cloud/test_models.py
@@ -134,9 +134,12 @@ def test_UserCSPPayload_mail_nickname():
assert payload.mail_nickname == f"han.solo"
-def test_UserCSPPayload_user_principal_name():
+def test_UserCSPPayload_user_principal_name(app):
payload = UserCSPPayload(**user_payload)
- assert payload.user_principal_name == f"han.solo@rebelalliance.onmicrosoft.com"
+ assert (
+ payload.user_principal_name
+ == f"han.solo@rebelalliance.{app.config.get('OFFICE_365_DOMAIN')}"
+ )
def test_UserCSPPayload_password():
@@ -167,11 +170,11 @@ class TestBillingOwnerCSPPayload:
payload = BillingOwnerCSPPayload(**self.user_payload)
assert payload.password
- def test_user_principal_name(self):
+ def test_user_principal_name(self, app):
payload = BillingOwnerCSPPayload(**self.user_payload)
assert (
payload.user_principal_name
- == f"billing_admin@rebelalliance.onmicrosoft.com"
+ == f"billing_admin@rebelalliance.{app.config.get('OFFICE_365_DOMAIN')}"
)
def test_email(self):
diff --git a/tests/test_jobs.py b/tests/test_jobs.py
index c14b626c..9df83264 100644
--- a/tests/test_jobs.py
+++ b/tests/test_jobs.py
@@ -7,8 +7,7 @@ from azure.core.exceptions import AzureError
from atst.domain.csp.cloud import MockCloudProvider
from atst.domain.csp.cloud.models import UserRoleCSPResult
-from atst.domain.portfolios import Portfolios
-from atst.models import ApplicationRoleStatus
+from atst.models import ApplicationRoleStatus, Portfolio, FSMStates
from atst.jobs import (
RecordFailure,
@@ -24,6 +23,7 @@ from atst.jobs import (
do_create_environment,
do_create_environment_role,
do_create_application,
+ send_PPOC_email,
)
from tests.factories import (
ApplicationFactory,
@@ -135,11 +135,11 @@ def test_create_application_job_is_idempotent(csp):
csp.create_application.assert_not_called()
-def test_create_user_job(session, csp):
+def test_create_user_job(session, csp, app):
portfolio = PortfolioFactory.create(
csp_data={
"tenant_id": str(uuid4()),
- "domain_name": "rebelalliance.onmicrosoft.com",
+ "domain_name": f"rebelalliance.{app.config.get('OFFICE_365_DOMAIN')}",
}
)
application = ApplicationFactory.create(portfolio=portfolio, cloud_id="321")
@@ -281,10 +281,57 @@ def test_dispatch_provision_portfolio(csp, monkeypatch):
mock.delay.assert_called_once_with(portfolio_id=portfolio.id)
-def test_do_provision_portfolio(csp, session, portfolio):
- do_provision_portfolio(csp=csp, portfolio_id=portfolio.id)
- session.refresh(portfolio)
- assert portfolio.state_machine
+class TestDoProvisionPortfolio:
+ def test_portfolio_has_state_machine(self, csp, session, portfolio):
+ do_provision_portfolio(csp=csp, portfolio_id=portfolio.id)
+ session.refresh(portfolio)
+ assert portfolio.state_machine
+
+ def test_sends_email_to_PPOC_on_completion(
+ self, monkeypatch, csp, portfolio: Portfolio
+ ):
+ mock = Mock()
+ monkeypatch.setattr("atst.jobs.send_PPOC_email", mock)
+
+ csp._authorize.return_value = None
+ csp._maybe_raise.return_value = None
+ sm: PortfolioStateMachine = PortfolioStateMachineFactory.create(
+ portfolio=portfolio
+ )
+ # The stage before "COMPLETED"
+ sm.state = FSMStates.BILLING_OWNER_CREATED
+ do_provision_portfolio(csp=csp, portfolio_id=portfolio.id)
+
+ # send_PPOC_email was called
+ assert mock.assert_called_once
+
+
+def test_send_ppoc_email(monkeypatch, app):
+ mock = Mock()
+ monkeypatch.setattr("atst.jobs.send_mail", mock)
+
+ ppoc_email = "example@example.com"
+ user_id = "user_id"
+ domain_name = "domain"
+
+ send_PPOC_email(
+ {
+ "password_recovery_email_address": ppoc_email,
+ "user_id": user_id,
+ "domain_name": domain_name,
+ }
+ )
+ mock.assert_called_once_with(
+ recipients=[ppoc_email],
+ subject=translate("email.portfolio_ready.subject"),
+ body=translate(
+ "email.portfolio_ready.body",
+ {
+ "password_reset_address": app.config.get("AZURE_LOGIN_URL"),
+ "username": f"{user_id}@{domain_name}.{app.config.get('OFFICE_365_DOMAIN')}",
+ },
+ ),
+ )
def test_provision_portfolio_create_tenant(
diff --git a/translations.yaml b/translations.yaml
index 3b53ded1..adee11af 100644
--- a/translations.yaml
+++ b/translations.yaml
@@ -83,7 +83,9 @@ errors:
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"
- environment_ready: JEDI cloud environment ready
+ portfolio_ready:
+ subject: Portfolio Provisioned
+ body: "Your portfolio has been provisioned.\nVisit {password_reset_address}, and use your username, {username}, to create a password."
task_order_sent:
subject: "Task Order {to_number}"
body: "Task Order number {to_number} updated."