Merge branch 'staging' into move-tf

This commit is contained in:
dandds 2020-02-09 16:17:19 -05:00 committed by GitHub
commit 98d932c4dc
63 changed files with 1143 additions and 717 deletions

View File

@ -101,5 +101,7 @@ RUN mkdir /var/run/uwsgi && \
chown -R atst:atat /var/run/uwsgi && \ chown -R atst:atat /var/run/uwsgi && \
chown -R atst:atat "${APP_DIR}" chown -R atst:atat "${APP_DIR}"
RUN update-ca-certificates
# Run as the unprivileged APP user # Run as the unprivileged APP user
USER atst USER atst

View File

@ -0,0 +1,28 @@
"""Remove root_user_info from Environment
Revision ID: 0039308c6351
Revises: 17da2a475429
Create Date: 2020-02-04 14:37:06.814645
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '0039308c6351' # pragma: allowlist secret
down_revision = '17da2a475429' # pragma: allowlist secret
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('environments', 'root_user_info')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('environments', sa.Column('root_user_info', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True))
# ### end Alembic commands ###

View File

@ -0,0 +1,29 @@
"""add last_sent column to clins and pdf_last_sent to task_orders
Revision ID: 567bfb019a87
Revises: 0039308c6351
Create Date: 2020-01-31 14:06:21.926019
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '567bfb019a87' # pragma: allowlist secret
down_revision = '0039308c6351' # pragma: allowlist secret
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('clins', sa.Column('last_sent_at', sa.DateTime(), nullable=True))
op.add_column('task_orders', sa.Column('pdf_last_sent_at', sa.DateTime(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('task_orders', 'pdf_last_sent_at')
op.drop_column('clins', 'last_sent_at')
# ### end Alembic commands ###

View File

@ -6,12 +6,12 @@ from uuid import uuid4
from atst.utils import sha256_hex from atst.utils import sha256_hex
from .cloud_provider_interface import CloudProviderInterface from .cloud_provider_interface import CloudProviderInterface
from .exceptions import AuthenticationException, UserProvisioningException from .exceptions import (
AuthenticationException,
SecretException,
UserProvisioningException,
)
from .models import ( from .models import (
SubscriptionCreationCSPPayload,
SubscriptionCreationCSPResult,
SubscriptionVerificationCSPPayload,
SuscriptionVerificationCSPResult,
AdminRoleDefinitionCSPPayload, AdminRoleDefinitionCSPPayload,
AdminRoleDefinitionCSPResult, AdminRoleDefinitionCSPResult,
ApplicationCSPPayload, ApplicationCSPPayload,
@ -24,14 +24,21 @@ from .models import (
BillingProfileTenantAccessCSPResult, BillingProfileTenantAccessCSPResult,
BillingProfileVerificationCSPPayload, BillingProfileVerificationCSPPayload,
BillingProfileVerificationCSPResult, BillingProfileVerificationCSPResult,
CostManagementQueryCSPResult,
EnvironmentCSPPayload,
EnvironmentCSPResult,
KeyVaultCredentials, KeyVaultCredentials,
ManagementGroupCSPResponse, PrincipalAdminRoleCSPPayload,
PrincipalAdminRoleCSPResult,
ProductPurchaseCSPPayload, ProductPurchaseCSPPayload,
ProductPurchaseCSPResult, ProductPurchaseCSPResult,
ProductPurchaseVerificationCSPPayload, ProductPurchaseVerificationCSPPayload,
ProductPurchaseVerificationCSPResult, ProductPurchaseVerificationCSPResult,
PrincipalAdminRoleCSPPayload, ReportingCSPPayload,
PrincipalAdminRoleCSPResult, SubscriptionCreationCSPPayload,
SubscriptionCreationCSPResult,
SubscriptionVerificationCSPPayload,
SuscriptionVerificationCSPResult,
TaskOrderBillingCreationCSPPayload, TaskOrderBillingCreationCSPPayload,
TaskOrderBillingCreationCSPResult, TaskOrderBillingCreationCSPResult,
TaskOrderBillingVerificationCSPPayload, TaskOrderBillingVerificationCSPPayload,
@ -53,7 +60,6 @@ from .models import (
) )
from .policy import AzurePolicyManager from .policy import AzurePolicyManager
# This needs to be a fully pathed role definition identifier, not just a UUID # This needs to be a fully pathed role definition identifier, not just a UUID
# TODO: Extract these from sdk msrestazure.azure_cloud import AZURE_PUBLIC_CLOUD # TODO: Extract these from sdk msrestazure.azure_cloud import AZURE_PUBLIC_CLOUD
AZURE_SKU_ID = "0001" # probably a static sku specific to ATAT/JEDI AZURE_SKU_ID = "0001" # probably a static sku specific to ATAT/JEDI
@ -116,11 +122,15 @@ class AzureCloudProvider(CloudProviderInterface):
) )
try: try:
return secret_client.set_secret(secret_key, secret_value) return secret_client.set_secret(secret_key, secret_value)
except self.exceptions.HttpResponseError: except self.sdk.exceptions.HttpResponseError as exc:
app.logger.error( app.logger.error(
f"Could not SET secret in Azure keyvault for key {secret_key}.", f"Could not SET secret in Azure keyvault for key {secret_key}.",
exc_info=1, exc_info=1,
) )
raise SecretException(
f"Could not SET secret in Azure keyvault for key {secret_key}.",
exc.message,
)
def get_secret(self, secret_key): def get_secret(self, secret_key):
credential = self._get_client_secret_credential_obj() credential = self._get_client_secret_credential_obj()
@ -129,67 +139,35 @@ class AzureCloudProvider(CloudProviderInterface):
) )
try: try:
return secret_client.get_secret(secret_key).value return secret_client.get_secret(secret_key).value
except self.exceptions.HttpResponseError: except self.sdk.exceptions.HttpResponseError:
app.logger.error( app.logger.error(
f"Could not GET secret in Azure keyvault for key {secret_key}.", f"Could not GET secret in Azure keyvault for key {secret_key}.",
exc_info=1, exc_info=1,
) )
raise SecretException(
f"Could not GET secret in Azure keyvault for key {secret_key}.",
exc.message,
)
def create_environment(self, auth_credentials: Dict, user, environment): def create_environment(self, payload: EnvironmentCSPPayload):
# since this operation would only occur within a tenant, should we source the tenant creds = self._source_creds(payload.tenant_id)
# via lookup from environment once we've created the portfolio csp data schema credentials = self._get_credential_obj(
# something like this: {
# environment_tenant = environment.application.portfolio.csp_data.get('tenant_id', None) "client_id": creds.tenant_sp_client_id,
# though we'd probably source the whole credentials for these calls from the portfolio csp "secret_key": creds.tenant_sp_key,
# data, as it would have to be where we store the creds for the at-at user within the portfolio tenant "tenant_id": creds.tenant_id,
# credentials = self._get_credential_obj(environment.application.portfolio.csp_data.get_creds()) },
credentials = self._get_credential_obj(self._root_creds) resource=self.sdk.cloud.endpoints.resource_manager,
display_name = f"{environment.application.name}_{environment.name}_{environment.id}" # proposed format
management_group_id = "?" # management group id chained from environment
parent_id = "?" # from environment.application
management_group = self._create_management_group(
credentials, management_group_id, display_name, parent_id,
) )
return ManagementGroupCSPResponse(**management_group) response = self._create_management_group(
def create_atat_admin_user(
self, auth_credentials: Dict, csp_environment_id: str
) -> Dict:
root_creds = self._root_creds
credentials = self._get_credential_obj(root_creds)
sub_client = self.sdk.subscription.SubscriptionClient(credentials)
subscription = sub_client.subscriptions.get(csp_environment_id)
managment_principal = self._get_management_service_principal()
auth_client = self.sdk.authorization.AuthorizationManagementClient(
credentials, credentials,
# TODO: Determine which subscription this needs to point at payload.management_group_name,
# Once we're in a multi-sub environment payload.display_name,
subscription.id, payload.parent_id,
) )
# Create role assignment for return EnvironmentCSPResult(**response)
role_assignment_id = str(uuid4())
role_assignment_create_params = auth_client.role_assignments.models.RoleAssignmentCreateParameters(
role_definition_id=REMOTE_ROOT_ROLE_DEF_ID,
principal_id=managment_principal.id,
)
auth_client.role_assignments.create(
scope=f"/subscriptions/{subscription.id}/",
role_assignment_name=role_assignment_id,
parameters=role_assignment_create_params,
)
return {
"csp_user_id": managment_principal.object_id,
"credentials": managment_principal.password_credentials,
"role_name": role_assignment_id,
}
def create_application(self, payload: ApplicationCSPPayload): def create_application(self, payload: ApplicationCSPPayload):
creds = self._source_creds(payload.tenant_id) creds = self._source_creds(payload.tenant_id)
@ -798,17 +776,6 @@ class AzureCloudProvider(CloudProviderInterface):
return self._ok() return self._ok()
def create_billing_alerts(self, TBD):
# TODO: Add azure-mgmt-consumption for Budget and Notification entities/operations
# TODO: Determine how to auth against that API using the SDK, doesn't seeem possible at the moment
# TODO: billing alerts are registered as Notifications on Budget objects, which have start/end dates
# TODO: determine what the keys in the Notifications dict are supposed to be
# we may need to rotate budget objects when new TOs/CLINs are reported?
# we likely only want the budget ID, can be updated or replaced?
response = {"id": "id"}
return self._ok({"budget_id": response["id"]})
def _get_management_service_principal(self): def _get_management_service_principal(self):
# we really should be using graph.microsoft.com, but i'm getting # we really should be using graph.microsoft.com, but i'm getting
# "expired token" errors for that # "expired token" errors for that
@ -1070,3 +1037,41 @@ class AzureCloudProvider(CloudProviderInterface):
hashed = sha256_hex(tenant_id) hashed = sha256_hex(tenant_id)
raw_creds = self.get_secret(hashed) raw_creds = self.get_secret(hashed)
return KeyVaultCredentials(**json.loads(raw_creds)) return KeyVaultCredentials(**json.loads(raw_creds))
def get_reporting_data(self, payload: ReportingCSPPayload):
"""
Queries the Cost Management API for an invoice section's raw reporting data
We query at the invoiceSection scope. The full scope path is passed in
with the payload at the `invoice_section_id` key.
"""
creds = self._source_tenant_creds(payload.tenant_id)
token = self._get_sp_token(
payload.tenant_id, creds.tenant_sp_client_id, creds.tenant_sp_key
)
if not token:
raise AuthenticationException("Could not retrieve tenant access token")
headers = {"Authorization": f"Bearer {token}"}
request_body = {
"type": "Usage",
"timeframe": "Custom",
"timePeriod": {"from": payload.from_date, "to": payload.to_date,},
"dataset": {
"granularity": "Daily",
"aggregation": {"totalCost": {"name": "PreTaxCost", "function": "Sum"}},
"grouping": [{"type": "Dimension", "name": "InvoiceId"}],
},
}
cost_mgmt_url = (
f"/providers/Microsoft.CostManagement/query?api-version=2019-11-01"
)
result = self.sdk.requests.post(
f"{self.sdk.cloud.endpoints.resource_manager}{payload.invoice_section_id}{cost_mgmt_url}",
json=request_body,
headers=headers,
)
if result.ok:
return CostManagementQueryCSPResult(**result.json())

View File

@ -11,7 +11,7 @@ class CloudProviderInterface:
def root_creds(self) -> Dict: def root_creds(self) -> Dict:
raise NotImplementedError() raise NotImplementedError()
def create_environment(self, auth_credentials: Dict, user, environment) -> str: def create_environment(self, payload):
"""Create a new environment in the CSP. """Create a new environment in the CSP.
Arguments: Arguments:
@ -31,33 +31,6 @@ class CloudProviderInterface:
""" """
raise NotImplementedError() raise NotImplementedError()
def create_atat_admin_user(
self, auth_credentials: Dict, csp_environment_id: str
) -> Dict:
"""Creates a new, programmatic user in the CSP. Grants this user full permissions to administer
the CSP.
Arguments:
auth_credentials -- Object containing CSP account credentials
csp_environment_id -- ID of the CSP Environment the admin user should be created in
Returns:
object: Object representing new remote admin user, including credentials
Something like:
{
"user_id": string,
"credentials": dict, # structure TBD based on csp
}
Raises:
AuthenticationException: Problem with the credentials
AuthorizationException: Credentials not authorized for current action(s)
ConnectionException: Issue with the CSP API connection
UnknownServerException: Unknown issue on the CSP side
UserProvisioningException: Problem creating the root user
"""
raise NotImplementedError()
def create_or_update_user( def create_or_update_user(
self, auth_credentials: Dict, user_info, csp_role_id: str self, auth_credentials: Dict, user_info, csp_role_id: str
) -> str: ) -> str:

View File

@ -118,3 +118,17 @@ class BaselineProvisionException(GeneralCSPException):
return "Could not complete baseline provisioning for environment ({}): {}".format( return "Could not complete baseline provisioning for environment ({}): {}".format(
self.env_identifier, self.reason self.env_identifier, self.reason
) )
class SecretException(GeneralCSPException):
"""A problem occurred with setting or getting secrets"""
def __init__(self, tenant_id, reason):
self.tenant_id = tenant_id
self.reason = reason
@property
def message(self):
return "Could not get or set secret for ({}): {}".format(
self.tenant_id, self.reason
)

View File

@ -4,9 +4,7 @@ from .cloud_provider_interface import CloudProviderInterface
from .exceptions import ( from .exceptions import (
AuthenticationException, AuthenticationException,
AuthorizationException, AuthorizationException,
BaselineProvisionException,
ConnectionException, ConnectionException,
EnvironmentCreationException,
GeneralCSPException, GeneralCSPException,
UnknownServerException, UnknownServerException,
UserProvisioningException, UserProvisioningException,
@ -25,16 +23,21 @@ from .models import (
BillingProfileTenantAccessCSPResult, BillingProfileTenantAccessCSPResult,
BillingProfileVerificationCSPPayload, BillingProfileVerificationCSPPayload,
BillingProfileVerificationCSPResult, BillingProfileVerificationCSPResult,
CostManagementQueryCSPResult,
CostManagementQueryProperties,
ProductPurchaseCSPPayload, ProductPurchaseCSPPayload,
ProductPurchaseCSPResult, ProductPurchaseCSPResult,
ProductPurchaseVerificationCSPPayload, ProductPurchaseVerificationCSPPayload,
ProductPurchaseVerificationCSPResult, ProductPurchaseVerificationCSPResult,
PrincipalAdminRoleCSPPayload, PrincipalAdminRoleCSPPayload,
PrincipalAdminRoleCSPResult, PrincipalAdminRoleCSPResult,
ReportingCSPPayload,
SubscriptionCreationCSPPayload, SubscriptionCreationCSPPayload,
SubscriptionCreationCSPResult, SubscriptionCreationCSPResult,
SubscriptionVerificationCSPPayload, SubscriptionVerificationCSPPayload,
SuscriptionVerificationCSPResult, SuscriptionVerificationCSPResult,
EnvironmentCSPPayload,
EnvironmentCSPResult,
TaskOrderBillingCreationCSPPayload, TaskOrderBillingCreationCSPPayload,
TaskOrderBillingCreationCSPResult, TaskOrderBillingCreationCSPResult,
TaskOrderBillingVerificationCSPPayload, TaskOrderBillingVerificationCSPPayload,
@ -91,34 +94,6 @@ class MockCloudProvider(CloudProviderInterface):
def get_secret(self, secret_key: str, default=dict()): def get_secret(self, secret_key: str, default=dict()):
return default return default
def create_environment(self, auth_credentials, user, environment):
self._authorize(auth_credentials)
self._delay(1, 5)
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(
self.ENV_CREATE_FAILURE_PCT,
EnvironmentCreationException(
environment.id, "Could not create environment."
),
)
csp_environment_id = self._id()
self._delay(1, 5)
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(
self.ATAT_ADMIN_CREATE_FAILURE_PCT,
BaselineProvisionException(
csp_environment_id, "Could not create environment baseline."
),
)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return csp_environment_id
def create_subscription(self, payload: SubscriptionCreationCSPPayload): def create_subscription(self, payload: SubscriptionCreationCSPPayload):
return self.create_subscription_creation(payload) return self.create_subscription_creation(payload)
@ -142,23 +117,6 @@ class MockCloudProvider(CloudProviderInterface):
subscription_id="subscriptions/60fbbb72-0516-4253-ab18-c92432ba3230" subscription_id="subscriptions/60fbbb72-0516-4253-ab18-c92432ba3230"
) )
def create_atat_admin_user(self, auth_credentials, csp_environment_id):
self._authorize(auth_credentials)
self._delay(1, 5)
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(
self.ATAT_ADMIN_CREATE_FAILURE_PCT,
UserProvisioningException(
csp_environment_id, "atat_admin", "Could not create admin user."
),
)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return {"id": self._id(), "credentials": self._auth_credentials}
def create_tenant(self, payload: TenantCSPPayload): def create_tenant(self, payload: TenantCSPPayload):
""" """
payload is an instance of TenantCSPPayload data class payload is an instance of TenantCSPPayload data class
@ -477,6 +435,13 @@ class MockCloudProvider(CloudProviderInterface):
id=f"{AZURE_MGMNT_PATH}{payload.management_group_name}" id=f"{AZURE_MGMNT_PATH}{payload.management_group_name}"
) )
def create_environment(self, payload: EnvironmentCSPPayload):
self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException)
return EnvironmentCSPResult(
id=f"{AZURE_MGMNT_PATH}{payload.management_group_name}"
)
def create_user(self, payload: UserCSPPayload): def create_user(self, payload: UserCSPPayload):
self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException) self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException)
@ -487,3 +452,25 @@ class MockCloudProvider(CloudProviderInterface):
def update_tenant_creds(self, tenant_id, secret): def update_tenant_creds(self, tenant_id, secret):
return secret return secret
def get_reporting_data(self, payload: ReportingCSPPayload):
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
object_id = str(uuid4())
properties = CostManagementQueryProperties(
**dict(
columns=[
{"name": "PreTaxCost", "type": "Number"},
{"name": "UsageDate", "type": "Number"},
{"name": "InvoiceId", "type": "String"},
{"name": "Currency", "type": "String"},
],
rows=[],
)
)
return CostManagementQueryCSPResult(
**dict(name=object_id, properties=properties,)
)

View File

@ -358,6 +358,14 @@ class ApplicationCSPResult(ManagementGroupCSPResponse):
pass pass
class EnvironmentCSPPayload(ManagementGroupCSPPayload):
pass
class EnvironmentCSPResult(ManagementGroupCSPResponse):
pass
class KeyVaultCredentials(BaseModel): class KeyVaultCredentials(BaseModel):
root_sp_client_id: Optional[str] root_sp_client_id: Optional[str]
root_sp_key: Optional[str] root_sp_key: Optional[str]
@ -499,3 +507,34 @@ class UserCSPPayload(BaseCSPPayload):
class UserCSPResult(AliasModel): class UserCSPResult(AliasModel):
id: str id: str
class QueryColumn(AliasModel):
name: str
type: str
class CostManagementQueryProperties(AliasModel):
columns: List[QueryColumn]
rows: List[Optional[list]]
class CostManagementQueryCSPResult(AliasModel):
name: str
properties: CostManagementQueryProperties
class ReportingCSPPayload(BaseCSPPayload):
invoice_section_id: str
from_date: str
to_date: str
@root_validator(pre=True)
def extract_invoice_section(cls, values):
try:
values["invoice_section_id"] = values["billing_profile_properties"][
"invoice_sections"
][0]["invoice_section_id"]
return values
except (KeyError, IndexError):
raise ValueError("Invoice section ID not present in payload")

View File

@ -43,10 +43,12 @@ class AzureFileService(FileService):
from azure.storage.common import CloudStorageAccount from azure.storage.common import CloudStorageAccount
from azure.storage.blob import BlobSasPermissions from azure.storage.blob import BlobSasPermissions
from azure.storage.blob.models import BlobPermissions
from azure.storage.blob.blockblobservice import BlockBlobService from azure.storage.blob.blockblobservice import BlockBlobService
self.CloudStorageAccount = CloudStorageAccount self.CloudStorageAccount = CloudStorageAccount
self.BlobSasPermissions = BlobSasPermissions self.BlobSasPermissions = BlobSasPermissions
self.BlobPermissions = BlobPermissions
self.BlockBlobService = BlockBlobService self.BlockBlobService = BlockBlobService
def get_token(self): def get_token(self):
@ -72,20 +74,22 @@ class AzureFileService(FileService):
return ({"token": sas_token}, object_name) return ({"token": sas_token}, object_name)
def generate_download_link(self, object_name, filename): def generate_download_link(self, object_name, filename):
account = self.CloudStorageAccount( block_blob_service = self.BlockBlobService(
account_name=self.account_name, account_key=self.storage_key account_name=self.account_name, account_key=self.storage_key
) )
bbs = account.create_block_blob_service() sas_token = block_blob_service.generate_blob_shared_access_signature(
sas_token = bbs.generate_blob_shared_access_signature( container_name=self.container_name,
self.container_name, blob_name=object_name,
object_name, permission=self.BlobPermissions(read=True),
permission=self.BlobSasPermissions(read=True),
expiry=datetime.utcnow() + self.timeout, expiry=datetime.utcnow() + self.timeout,
content_disposition=f"attachment; filename={filename}", content_disposition=f"attachment; filename={filename}",
protocol="https", protocol="https",
) )
return bbs.make_blob_url( return block_blob_service.make_blob_url(
self.container_name, object_name, protocol="https", sas_token=sas_token container_name=self.container_name,
blob_name=object_name,
protocol="https",
sas_token=sas_token,
) )
def download_task_order(self, object_name): def download_task_order(self, object_name):

View File

@ -106,9 +106,11 @@ class EnvironmentRoles(object):
def disable(cls, environment_role_id): def disable(cls, environment_role_id):
environment_role = EnvironmentRoles.get_by_id(environment_role_id) environment_role = EnvironmentRoles.get_by_id(environment_role_id)
if environment_role.csp_user_id and not environment_role.environment.is_pending: if environment_role.csp_user_id and not environment_role.environment.cloud_id:
credentials = environment_role.environment.csp_credentials tenant_id = environment_role.environment.application.portfolio.csp_data.get(
app.csp.cloud.disable_user(credentials, environment_role.csp_user_id) "tenant_id"
)
app.csp.cloud.disable_user(tenant_id, environment_role.csp_user_id)
environment_role.status = EnvironmentRole.Status.DISABLED environment_role.status = EnvironmentRole.Status.DISABLED
db.session.add(environment_role) db.session.add(environment_role)

View File

@ -124,18 +124,9 @@ class Environments(object):
Any environment with an active CLIN that doesn't yet have a `cloud_id`. Any environment with an active CLIN that doesn't yet have a `cloud_id`.
""" """
results = ( results = (
cls.base_provision_query(now).filter(Environment.cloud_id == None).all() cls.base_provision_query(now)
.filter(Application.cloud_id != None)
.filter(Environment.cloud_id.is_(None))
.all()
) )
return [id_ for id_, in results] return [id_ for id_, in results]
@classmethod
def get_environments_pending_atat_user_creation(cls, now) -> List[UUID]:
"""
Any environment with an active CLIN that has a cloud_id but no `root_user_info`.
"""
results = (
cls.base_provision_query(now)
.filter(Environment.cloud_id != None)
.filter(Environment.root_user_info == None)
).all()
return [id_ for id_, in results]

View File

@ -15,6 +15,8 @@ from atst.models import (
Permissions, Permissions,
PortfolioRole, PortfolioRole,
PortfolioRoleStatus, PortfolioRoleStatus,
TaskOrder,
CLIN,
) )
from .query import PortfoliosQuery, PortfolioStateMachinesQuery from .query import PortfoliosQuery, PortfolioStateMachinesQuery
@ -144,7 +146,7 @@ class Portfolios(object):
return db.session.query(Portfolio.id) return db.session.query(Portfolio.id)
@classmethod @classmethod
def get_portfolios_pending_provisioning(cls) -> List[UUID]: def get_portfolios_pending_provisioning(cls, now) -> List[UUID]:
""" """
Any portfolio with a corresponding State Machine that is either: Any portfolio with a corresponding State Machine that is either:
not started yet, not started yet,
@ -153,22 +155,18 @@ class Portfolios(object):
""" """
results = ( results = (
cls.base_provision_query() db.session.query(Portfolio.id)
.join(PortfolioStateMachine) .join(PortfolioStateMachine)
.join(TaskOrder)
.join(CLIN)
.filter(Portfolio.deleted == False)
.filter(CLIN.start_date <= now)
.filter(CLIN.end_date > now)
.filter( .filter(
or_( or_(
PortfolioStateMachine.state == FSMStates.UNSTARTED, PortfolioStateMachine.state == FSMStates.UNSTARTED,
PortfolioStateMachine.state == FSMStates.FAILED, PortfolioStateMachine.state.like("%CREATED"),
PortfolioStateMachine.state == FSMStates.TENANT_FAILED,
) )
) )
) )
return [id_ for id_, in results] return [id_ for id_, in results]
# db.session.query(PortfolioStateMachine).\
# filter(
# or_(
# PortfolioStateMachine.state==FSMStates.UNSTARTED,
# PortfolioStateMachine.state==FSMStates.UNSTARTED,
# )
# ).all()

View File

@ -1,4 +1,5 @@
import datetime from datetime import datetime
from sqlalchemy import or_
from atst.database import db from atst.database import db
from atst.models.clin import CLIN from atst.models.clin import CLIN
@ -40,7 +41,7 @@ class TaskOrders(BaseDomainClass):
@classmethod @classmethod
def sign(cls, task_order, signer_dod_id): def sign(cls, task_order, signer_dod_id):
task_order.signer_dod_id = signer_dod_id task_order.signer_dod_id = signer_dod_id
task_order.signed_at = datetime.datetime.now() task_order.signed_at = datetime.now()
db.session.add(task_order) db.session.add(task_order)
db.session.commit() db.session.commit()
@ -76,3 +77,17 @@ class TaskOrders(BaseDomainClass):
task_order = TaskOrders.get(task_order_id) task_order = TaskOrders.get(task_order_id)
db.session.delete(task_order) db.session.delete(task_order)
db.session.commit() db.session.commit()
@classmethod
def get_for_send_task_order_files(cls):
return (
db.session.query(TaskOrder)
.join(CLIN)
.filter(
or_(
TaskOrder.pdf_last_sent_at < CLIN.last_sent_at,
TaskOrder.pdf_last_sent_at.is_(None),
)
)
.all()
)

View File

@ -1,18 +1,25 @@
from flask import current_app as app
import pendulum import pendulum
from flask import current_app as app
from smtplib import SMTPException
from azure.core.exceptions import AzureError
from atst.database import db from atst.database import db
from atst.queue import celery from atst.domain.application_roles import ApplicationRoles
from atst.models import JobFailure
from atst.domain.csp.cloud.exceptions import GeneralCSPException
from atst.domain.csp.cloud import CloudProviderInterface
from atst.domain.applications import Applications from atst.domain.applications import Applications
from atst.domain.csp.cloud import CloudProviderInterface
from atst.domain.csp.cloud.exceptions import GeneralCSPException
from atst.domain.csp.cloud.models import (
ApplicationCSPPayload,
EnvironmentCSPPayload,
UserCSPPayload,
)
from atst.domain.environments import Environments from atst.domain.environments import Environments
from atst.domain.portfolios import Portfolios from atst.domain.portfolios import Portfolios
from atst.domain.application_roles import ApplicationRoles from atst.models import JobFailure
from atst.domain.task_orders import TaskOrders
from atst.models.utils import claim_for_update, claim_many_for_update from atst.models.utils import claim_for_update, claim_many_for_update
from atst.queue import celery
from atst.utils.localization import translate from atst.utils.localization import translate
from atst.domain.csp.cloud.models import ApplicationCSPPayload, UserCSPPayload
class RecordFailure(celery.Task): class RecordFailure(celery.Task):
@ -40,8 +47,8 @@ class RecordFailure(celery.Task):
@celery.task(ignore_result=True) @celery.task(ignore_result=True)
def send_mail(recipients, subject, body): def send_mail(recipients, subject, body, attachments=[]):
app.mailer.send(recipients, subject, body) app.mailer.send(recipients, subject, body, attachments)
@celery.task(ignore_result=True) @celery.task(ignore_result=True)
@ -109,45 +116,17 @@ def do_create_environment(csp: CloudProviderInterface, environment_id=None):
with claim_for_update(environment) as environment: with claim_for_update(environment) as environment:
if environment.cloud_id is not None: if environment.cloud_id is not None:
# TODO: Return value for this?
return return
user = environment.creator csp_details = environment.application.portfolio.csp_data
parent_id = environment.application.cloud_id
# we'll need to do some checking in this job for cases where it's retrying tenant_id = csp_details.get("tenant_id")
# when a failure occured after some successful steps payload = EnvironmentCSPPayload(
# (e.g. if environment.cloud_id is not None, then we can skip first step) tenant_id=tenant_id, display_name=environment.name, parent_id=parent_id
# credentials either from a given user or pulled from config?
# if using global creds, do we need to log what user authorized action?
atat_root_creds = csp.root_creds()
# user is needed because baseline root account in the environment will
# be assigned to the requesting user, open question how to handle duplicate
# email addresses across new environments
csp_environment_id = csp.create_environment(atat_root_creds, user, environment)
environment.cloud_id = csp_environment_id
db.session.add(environment)
db.session.commit()
body = render_email(
"emails/application/environment_ready.txt", {"environment": environment}
)
app.mailer.send(
[environment.creator.email], translate("email.environment_ready"), body
) )
env_result = csp.create_environment(payload)
def do_create_atat_admin_user(csp: CloudProviderInterface, environment_id=None): environment.cloud_id = env_result.id
environment = Environments.get(environment_id)
with claim_for_update(environment) as environment:
atat_root_creds = csp.root_creds()
atat_remote_root_user = csp.create_atat_admin_user(
atat_root_creds, environment.cloud_id
)
environment.root_user_info = atat_remote_root_user
db.session.add(environment) db.session.add(environment)
db.session.commit() db.session.commit()
@ -191,19 +170,12 @@ def create_environment(self, environment_id=None):
do_work(do_create_environment, self, app.csp.cloud, environment_id=environment_id) do_work(do_create_environment, self, app.csp.cloud, environment_id=environment_id)
@celery.task(bind=True, base=RecordFailure)
def create_atat_admin_user(self, environment_id=None):
do_work(
do_create_atat_admin_user, self, app.csp.cloud, environment_id=environment_id
)
@celery.task(bind=True) @celery.task(bind=True)
def dispatch_provision_portfolio(self): def dispatch_provision_portfolio(self):
""" """
Iterate over portfolios with a corresponding State Machine that have not completed. Iterate over portfolios with a corresponding State Machine that have not completed.
""" """
for portfolio_id in Portfolios.get_portfolios_pending_provisioning(): for portfolio_id in Portfolios.get_portfolios_pending_provisioning(pendulum.now()):
provision_portfolio.delay(portfolio_id=portfolio_id) provision_portfolio.delay(portfolio_id=portfolio_id)
@ -233,3 +205,31 @@ def dispatch_create_atat_admin_user(self):
pendulum.now() pendulum.now()
): ):
create_atat_admin_user.delay(environment_id=environment_id) create_atat_admin_user.delay(environment_id=environment_id)
@celery.task(bind=True)
def dispatch_send_task_order_files(self):
task_orders = TaskOrders.get_for_send_task_order_files()
recipients = [app.config.get("MICROSOFT_TASK_ORDER_EMAIL_ADDRESS")]
for task_order in task_orders:
subject = translate(
"email.task_order_sent.subject", {"to_number": task_order.number}
)
body = translate("email.task_order_sent.body", {"to_number": task_order.number})
try:
file = app.csp.files.download_task_order(task_order.pdf.object_name)
file["maintype"] = "application"
file["subtype"] = "pdf"
send_mail(
recipients=recipients, subject=subject, body=body, attachments=[file]
)
except (AzureError, SMTPException) as err:
app.logger.exception(err)
continue
task_order.pdf_last_sent_at = pendulum.now()
db.session.add(task_order)
db.session.commit()

View File

@ -1,5 +1,13 @@
from enum import Enum from enum import Enum
from sqlalchemy import Column, Date, Enum as SQLAEnum, ForeignKey, Numeric, String from sqlalchemy import (
Column,
Date,
DateTime,
Enum as SQLAEnum,
ForeignKey,
Numeric,
String,
)
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from datetime import date from datetime import date
@ -29,6 +37,7 @@ class CLIN(Base, mixins.TimestampsMixin):
total_amount = Column(Numeric(scale=2), nullable=False) total_amount = Column(Numeric(scale=2), nullable=False)
obligated_amount = Column(Numeric(scale=2), nullable=False) obligated_amount = Column(Numeric(scale=2), nullable=False)
jedi_clin_type = Column(SQLAEnum(JEDICLINType, native_enum=False), nullable=False) jedi_clin_type = Column(SQLAEnum(JEDICLINType, native_enum=False), nullable=False)
last_sent_at = Column(DateTime)
# #
# NOTE: For now obligated CLINS are CLIN 1 + CLIN 3 # NOTE: For now obligated CLINS are CLIN 1 + CLIN 3

View File

@ -1,11 +1,9 @@
from sqlalchemy import Column, ForeignKey, String, UniqueConstraint from sqlalchemy import Column, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import JSONB
from enum import Enum
from atst.models.base import Base
import atst.models.mixins as mixins import atst.models.mixins as mixins
import atst.models.types as types import atst.models.types as types
from atst.models.base import Base
class Environment( class Environment(
@ -30,7 +28,6 @@ class Environment(
creator = relationship("User") creator = relationship("User")
cloud_id = Column(String) cloud_id = Column(String)
root_user_info = Column(JSONB(none_as_null=True))
roles = relationship( roles = relationship(
"EnvironmentRole", "EnvironmentRole",
@ -44,10 +41,6 @@ class Environment(
), ),
) )
class ProvisioningStatus(Enum):
PENDING = "pending"
COMPLETED = "completed"
@property @property
def users(self): def users(self):
return {r.application_role.user for r in self.roles} return {r.application_role.user for r in self.roles}
@ -68,16 +61,9 @@ class Environment(
def portfolio_id(self): def portfolio_id(self):
return self.application.portfolio_id return self.application.portfolio_id
@property
def provisioning_status(self) -> ProvisioningStatus:
if self.cloud_id is None or self.root_user_info is None:
return self.ProvisioningStatus.PENDING
else:
return self.ProvisioningStatus.COMPLETED
@property @property
def is_pending(self): def is_pending(self):
return self.provisioning_status == self.ProvisioningStatus.PENDING return self.cloud_id is None
def __repr__(self): def __repr__(self):
return "<Environment(name='{}', num_users='{}', application='{}', portfolio='{}', id='{}')>".format( return "<Environment(name='{}', num_users='{}', application='{}', portfolio='{}', id='{}')>".format(
@ -91,11 +77,3 @@ class Environment(
@property @property
def history(self): def history(self):
return self.get_changes() return self.get_changes()
@property
def csp_credentials(self):
return (
self.root_user_info.get("credentials")
if self.root_user_info is not None
else None
)

View File

@ -175,11 +175,14 @@ class PortfolioStateMachine(
app.logger.info(exc.json()) app.logger.info(exc.json())
print(exc.json()) print(exc.json())
app.logger.info(payload_data) app.logger.info(payload_data)
# TODO: Ensure that failing the stage does not preclude a Celery retry
self.fail_stage(stage) self.fail_stage(stage)
# TODO: catch and handle general CSP exception here
except (ConnectionException, UnknownServerException) as exc: except (ConnectionException, UnknownServerException) as exc:
app.logger.error( app.logger.error(
f"CSP api call. Caught exception for {self.__repr__()}.", exc_info=1, f"CSP api call. Caught exception for {self.__repr__()}.", exc_info=1,
) )
# TODO: Ensure that failing the stage does not preclude a Celery retry
self.fail_stage(stage) self.fail_stage(stage)
self.finish_stage(stage) self.finish_stage(stage)

View File

@ -39,6 +39,7 @@ class TaskOrder(Base, mixins.TimestampsMixin):
pdf_attachment_id = Column(ForeignKey("attachments.id")) pdf_attachment_id = Column(ForeignKey("attachments.id"))
_pdf = relationship("Attachment", foreign_keys=[pdf_attachment_id]) _pdf = relationship("Attachment", foreign_keys=[pdf_attachment_id])
pdf_last_sent_at = Column(DateTime)
number = Column(String, unique=True,) # Task Order Number number = Column(String, unique=True,) # Task Order Number
signer_dod_id = Column(String) signer_dod_id = Column(String)
signed_at = Column(DateTime) signed_at = Column(DateTime)

View File

@ -19,10 +19,6 @@ def update_celery(celery, app):
"task": "atst.jobs.dispatch_create_environment", "task": "atst.jobs.dispatch_create_environment",
"schedule": 60, "schedule": 60,
}, },
"beat-dispatch_create_atat_admin_user": {
"task": "atst.jobs.dispatch_create_atat_admin_user",
"schedule": 60,
},
"beat-dispatch_create_user": { "beat-dispatch_create_user": {
"task": "atst.jobs.dispatch_create_user", "task": "atst.jobs.dispatch_create_user",
"schedule": 60, "schedule": 60,

View File

@ -467,9 +467,10 @@ def revoke_invite(application_id, application_role_id):
if invite.is_pending: if invite.is_pending:
ApplicationInvitations.revoke(invite.token) ApplicationInvitations.revoke(invite.token)
flash( flash(
"application_invite_revoked", "invite_revoked",
resource="Application",
user_name=app_role.user_name, user_name=app_role.user_name,
application_name=g.application.name, resource_name=g.application.name,
) )
else: else:
flash( flash(

View File

@ -37,8 +37,14 @@ def accept_invitation(portfolio_token):
) )
@user_can(Permissions.EDIT_PORTFOLIO_USERS, message="revoke invitation") @user_can(Permissions.EDIT_PORTFOLIO_USERS, message="revoke invitation")
def revoke_invitation(portfolio_id, portfolio_token): def revoke_invitation(portfolio_id, portfolio_token):
PortfolioInvitations.revoke(portfolio_token) invite = PortfolioInvitations.revoke(portfolio_token)
flash(
"invite_revoked",
resource="Portfolio",
user_name=invite.user_name,
resource_name=g.portfolio.name,
)
return redirect( return redirect(
url_for( url_for(
"portfolios.admin", "portfolios.admin",

View File

@ -19,6 +19,9 @@ from atst.models import (
def get_resources_from_context(view_args): def get_resources_from_context(view_args):
query = None query = None
if view_args is None:
view_args = {}
if "portfolio_token" in view_args: if "portfolio_token" in view_args:
query = ( query = (
db.session.query(Portfolio) db.session.query(Portfolio)

View File

@ -33,11 +33,6 @@ MESSAGES = {
"message": "flash.application_invite.resent.message", "message": "flash.application_invite.resent.message",
"category": "success", "category": "success",
}, },
"application_invite_revoked": {
"title": "flash.application_invite.revoked.title",
"message": "flash.application_invite.revoked.message",
"category": "success",
},
"application_member_removed": { "application_member_removed": {
"title": "flash.application_member.removed.title", "title": "flash.application_member.removed.title",
"message": "flash.application_member.removed.message", "message": "flash.application_member.removed.message",
@ -103,6 +98,11 @@ MESSAGES = {
"message": None, "message": None,
"category": "warning", "category": "warning",
}, },
"invite_revoked": {
"title": "flash.invite_revoked.title",
"message": "flash.invite_revoked.message",
"category": "success",
},
"logged_out": { "logged_out": {
"title": "flash.logged_out.title", "title": "flash.logged_out.title",
"message": "flash.logged_out.message", "message": "flash.logged_out.message",

View File

@ -1,30 +1,21 @@
import ExpandSidenavMixin from '../mixins/expand_sidenav'
import ToggleMixin from '../mixins/toggle' import ToggleMixin from '../mixins/toggle'
const cookieName = 'expandSidenav'
export default { export default {
name: 'sidenav-toggler', name: 'sidenav-toggler',
mixins: [ToggleMixin], mixins: [ExpandSidenavMixin, ToggleMixin],
props: { mounted: function() {
defaultVisible: { this.$parent.$emit('sidenavToggle', this.isVisible)
type: Boolean,
default: function() {
if (document.cookie.match(cookieName)) {
return !!document.cookie.match(cookieName + ' *= *true')
} else {
return true
}
},
},
}, },
methods: { methods: {
toggle: function(e) { toggle: function(e) {
e.preventDefault() e.preventDefault()
this.isVisible = !this.isVisible this.isVisible = !this.isVisible
document.cookie = cookieName + '=' + this.isVisible + '; path=/' document.cookie = this.cookieName + '=' + this.isVisible + '; path=/'
this.$parent.$emit('sidenavToggle', this.isVisible)
}, },
}, },
} }

View File

@ -17,7 +17,7 @@ export default {
filename: { filename: {
type: String, type: String,
}, },
objectName: { initialObjectName: {
type: String, type: String,
}, },
initialErrors: { initialErrors: {
@ -42,6 +42,7 @@ export default {
filenameError: false, filenameError: false,
downloadLink: '', downloadLink: '',
fileSizeLimit: this.sizeLimit, fileSizeLimit: this.sizeLimit,
objectName: this.initialObjectName,
} }
}, },
@ -72,6 +73,7 @@ export default {
const response = await uploader.upload(file) const response = await uploader.upload(file)
if (uploadResponseOkay(response)) { if (uploadResponseOkay(response)) {
this.attachment = e.target.value this.attachment = e.target.value
this.objectName = uploader.objectName
this.$refs.attachmentFilename.value = file.name this.$refs.attachmentFilename.value = file.name
this.$refs.attachmentObjectName.value = response.objectName this.$refs.attachmentObjectName.value = response.objectName
this.$refs.attachmentInput.disabled = true this.$refs.attachmentInput.disabled = true

View File

@ -32,12 +32,14 @@ import ToForm from './components/forms/to_form'
import ClinFields from './components/clin_fields' import ClinFields from './components/clin_fields'
import PopDateRange from './components/pop_date_range' import PopDateRange from './components/pop_date_range'
import ToggleMenu from './components/toggle_menu' import ToggleMenu from './components/toggle_menu'
import ExpandSidenav from './mixins/expand_sidenav'
Vue.config.productionTip = false Vue.config.productionTip = false
Vue.use(VTooltip) Vue.use(VTooltip)
Vue.mixin(Modal) Vue.mixin(Modal)
Vue.mixin(ExpandSidenav)
const app = new Vue({ const app = new Vue({
el: '#app-root', el: '#app-root',
@ -67,6 +69,12 @@ const app = new Vue({
ToggleMenu, ToggleMenu,
}, },
data: function() {
return {
sidenavExpanded: this.defaultVisible,
}
},
mounted: function() { mounted: function() {
this.$on('modalOpen', data => { this.$on('modalOpen', data => {
if (data['isOpen']) { if (data['isOpen']) {
@ -105,6 +113,10 @@ const app = new Vue({
} }
}) })
}) })
this.$on('sidenavToggle', data => {
this.sidenavExpanded = data
})
}, },
delimiters: ['!{', '}'], delimiters: ['!{', '}'],

View File

@ -9,6 +9,12 @@ export default {
unmask: [], unmask: [],
validationError: 'Please enter a response', validationError: 'Please enter a response',
}, },
applicationName: {
mask: false,
match: /^[A-Za-z0-9\-_,'".\s]{4,100}$$/,
unmask: [],
validationError: 'Application names can be between 4-100 characters',
},
clinNumber: { clinNumber: {
mask: false, mask: false,
match: /^\d{4}$/, match: /^\d{4}$/,
@ -42,14 +48,14 @@ export default {
}, },
defaultStringField: { defaultStringField: {
mask: false, mask: false,
match: /^[A-Za-z0-9\-_ \.]{1,100}$/, match: /^[A-Za-z0-9\-_,'".\s]{1,1000}$/,
unmask: [], unmask: [],
validationError: validationError:
'Please enter a response of no more than 100 alphanumeric characters', 'Please enter a response of no more than 100 alphanumeric characters',
}, },
defaultTextAreaField: { defaultTextAreaField: {
mask: false, mask: false,
match: /^[A-Za-z0-9\-_ \.]{1,1000}$/, match: /^[A-Za-z0-9\-_,'".\s]{1,1000}$/,
unmask: [], unmask: [],
validationError: validationError:
'Please enter a response of no more than 1000 alphanumeric characters', 'Please enter a response of no more than 1000 alphanumeric characters',
@ -94,7 +100,7 @@ export default {
}, },
portfolioName: { portfolioName: {
mask: false, mask: false,
match: /^.{4,100}$/, match: /^[A-Za-z0-9\-_,'".\s]{4,100}$$/,
unmask: [], unmask: [],
validationError: 'Portfolio names can be between 4-100 characters', validationError: 'Portfolio names can be between 4-100 characters',
}, },

View File

@ -0,0 +1,15 @@
export default {
props: {
cookieName: 'expandSidenav',
defaultVisible: {
type: Boolean,
default: function() {
if (document.cookie.match(this.cookieName)) {
return !!document.cookie.match(this.cookieName + ' *= *true')
} else {
return true
}
},
},
},
}

View File

@ -47,3 +47,4 @@
@import "sections/application_edit"; @import "sections/application_edit";
@import "sections/reports"; @import "sections/reports";
@import "sections/task_order"; @import "sections/task_order";
@import "sections/ccpo";

View File

@ -11,6 +11,7 @@
.usa-alert { .usa-alert {
padding-bottom: 2.4rem; padding-bottom: 2.4rem;
max-width: $max-panel-width;
} }
@mixin alert { @mixin alert {
@ -97,38 +98,3 @@
} }
} }
} }
.alert {
@include alert;
@include alert-level("info");
&.alert--success {
@include alert-level("success");
.alert__actions {
.icon-link {
@include icon-link-color($color-green, $color-white);
}
}
}
&.alert--warning {
@include alert-level("warning");
.alert__actions {
.icon-link {
@include icon-link-color($color-gold-dark, $color-white);
}
}
}
&.alert--error {
@include alert-level("error");
.alert__actions {
.icon-link {
@include icon-link-color($color-red, $color-white);
}
}
}
}

View File

@ -1,8 +1,13 @@
.error-page { .error-page {
max-width: 475px; max-width: $max-page-width;
margin: auto;
.panel { .panel {
box-shadow: none;
background-color: unset;
border: none;
max-width: 475px;
margin: auto;
&__heading { &__heading {
text-align: center; text-align: center;
padding: $gap 0; padding: $gap 0;
@ -15,17 +20,6 @@
margin-bottom: $gap; margin-bottom: $gap;
} }
} }
&__body {
padding: $gap * 2;
margin: 0;
hr {
width: 80%;
margin: auto;
margin-bottom: $gap * 3;
}
}
} }
.icon { .icon {

View File

@ -12,7 +12,7 @@
flex-direction: row; flex-direction: row;
align-items: stretch; align-items: stretch;
justify-content: space-between; justify-content: space-between;
max-width: 1190px; max-width: $max-page-width;
a { a {
color: $color-white; color: $color-white;

View File

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

View File

@ -19,6 +19,8 @@ $sidenav-collapsed-width: 10rem;
$max-panel-width: 90rem; $max-panel-width: 90rem;
$home-pg-icon-width: 6rem; $home-pg-icon-width: 6rem;
$large-spacing: 4rem; $large-spacing: 4rem;
$max-page-width: $max-panel-width + $sidenav-expanded-width + $large-spacing;
$action-footer-height: 6rem;
/* /*
* USWDS Variables * USWDS Variables

View File

@ -32,22 +32,36 @@
} }
.action-group-footer { .action-group-footer {
@extend .action-group;
&:last-child {
margin-bottom: 0;
}
margin-top: 0;
margin-bottom: 0;
padding-top: $gap; padding-top: $gap;
padding-bottom: $gap; padding-bottom: $gap;
padding-right: $gap * 4;
position: fixed; position: fixed;
bottom: $footer-height; bottom: $footer-height;
left: 0;
background: white; background: white;
right: 0;
padding-right: $gap * 4;
border-top: 1px solid $color-gray-lighter; border-top: 1px solid $color-gray-lighter;
width: 100%;
z-index: 1; z-index: 1;
width: 100%;
height: $action-footer-height;
&.action-group-footer--expand-offset {
padding-left: $sidenav-expanded-width;
}
&.action-group-footer--collapse-offset {
padding-left: $sidenav-collapsed-width;
}
.action-group-footer--container {
@extend .action-group;
margin-top: 0;
margin-bottom: 0;
margin-left: $large-spacing;
max-width: $max-panel-width;
&:last-child {
margin-bottom: 0;
}
}
} }

View File

@ -228,6 +228,7 @@
&--validation { &--validation {
&--anything, &--anything,
&--applicationName,
&--portfolioName, &--portfolioName,
&--requiredField, &--requiredField,
&--defaultStringField, &--defaultStringField,

View File

@ -0,0 +1,3 @@
.ccpo-panel-container {
max-width: $max-panel-width;
}

View File

@ -1,6 +1,5 @@
.task-order { .task-order {
margin-top: $gap * 4; margin-top: $gap * 4;
margin-bottom: $footer-height;
width: 900px; width: 900px;
&__amount { &__amount {

View File

@ -47,7 +47,7 @@
<span> <span>
{{ env['name'] }} {{ env['name'] }}
</span> </span>
{{ Label(type="pending_creation", classes='label--below')}} {{ Label(type="pending_creation")}}
{%- endif %} {%- endif %}
{% if user_can(permissions.EDIT_ENVIRONMENT) -%} {% if user_can(permissions.EDIT_ENVIRONMENT) -%}
{{ {{

View File

@ -14,7 +14,7 @@
action_new, action_new,
action_update) %} action_update) %}
<h3 id="application-members"> <h3 id="application-members">
{{ 'portfolios.applications.settings.team_members' | translate }} {{ 'portfolios.applications.settings.team_members' | translate }}
</h3> </h3>
@ -22,7 +22,7 @@
{% include "fragments/flash.html" %} {% include "fragments/flash.html" %}
{% endif %} {% endif %}
<div class="panel"> <div class="panel{% if new_member_form%} form{% endif %}">
{% if not application.members %} {% if not application.members %}
<div class='empty-state empty-state--centered empty-state--white panel__content'> <div class='empty-state empty-state--centered empty-state--white panel__content'>
<p class='empty-state__message'> <p class='empty-state__message'>

View File

@ -22,11 +22,11 @@
{% 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"> <form method="POST" action="{{ action }}" v-on:submit="handleSubmit" class="form">
{{ form.csrf_token }} {{ form.csrf_token }}
<div class="form-row"> <div class="form-row">
<div class="form-col"> <div class="form-col">
{{ TextInput(form.name, validation="name", optional=False) }} {{ TextInput(form.name, validation="applicationName", optional=False) }}
{{ ('portfolios.applications.new.step_1_form_help_text.name' | translate | safe) }} {{ ('portfolios.applications.new.step_1_form_help_text.name' | translate | safe) }}
</div> </div>
</div> </div>
@ -39,14 +39,18 @@
</div> </div>
<span class="action-group-footer"> <div
{% block next_button %} class="action-group-footer"
{{ SaveButton(text=('portfolios.applications.new.step_1_button_text' | translate)) }} v-bind:class="{'action-group-footer--expand-offset': this.$root.sidenavExpanded, 'action-group-footer--collapse-offset': !this.$root.sidenavExpanded}">
{% endblock %} <div class="action-group-footer--container">
<a href="{{ url_for('applications.portfolio_applications', portfolio_id=portfolio.id) }}"> {% block next_button %}
Cancel {{ SaveButton(text=('portfolios.applications.new.step_1_button_text' | translate)) }}
</a> {% endblock %}
</span> <a href="{{ url_for('applications.portfolio_applications', portfolio_id=portfolio.id) }}">
Cancel
</a>
</div>
</div>
</form> </form>
</base-form> </base-form>

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"> <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">
<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">
@ -58,20 +58,24 @@
{{ Icon("plus") }} {{ Icon("plus") }}
</button> </button>
</div> </div>
</div>
</div>
</div>
<div
class="action-group-footer"
v-bind:class="{'action-group-footer--expand-offset': this.$root.sidenavExpanded, 'action-group-footer--collapse-offset': !this.$root.sidenavExpanded}">
<div class="action-group-footer--container">
{% block next_button %}
{{ SaveButton(text=('portfolios.applications.new.step_2_button_text' | translate)) }}
{% endblock %}
<a class="usa-button usa-button-secondary" href="{{ url_for('applications.view_new_application_step_1', application_id=application.id) }}">
Previous
</a>
<a href="{{ url_for('applications.portfolio_applications', portfolio_id=portfolio.id) }}">
Cancel
</a>
</div> </div>
</div> </div>
</div>
<span class="action-group-footer">
{% block next_button %}
{{ SaveButton(text=('portfolios.applications.new.step_2_button_text' | translate)) }}
{% endblock %}
<a class="usa-button usa-button-secondary" href="{{ url_for('applications.view_new_application_step_1', application_id=application.id) }}">
Previous
</a>
<a href="{{ url_for('applications.portfolio_applications', portfolio_id=portfolio.id) }}">
Cancel
</a>
</span>
</form> </form>
</application-environments> </application-environments>

View File

@ -25,16 +25,20 @@
action_update="applications.update_new_application_step_3") }} action_update="applications.update_new_application_step_3") }}
<span class="action-group-footer"> <div
<a class="usa-button" href="{{ url_for('applications.settings', application_id=application_id) }}"> class="action-group-footer"
{{ "portfolios.applications.new.step_3_button_text" | translate }} v-bind:class="{'action-group-footer--expand-offset': this.$root.sidenavExpanded, 'action-group-footer--collapse-offset': !this.$root.sidenavExpanded}">
</a> <div class="action-group-footer--container">
<a class="usa-button usa-button-secondary" href="{{ url_for('applications.view_new_application_step_2', application_id=application.id) }}"> <a class="usa-button" href="{{ url_for('applications.settings', application_id=application_id) }}">
{{ "common.previous" | translate }} {{ "portfolios.applications.new.step_3_button_text" | translate }}
</a> </a>
<a href="{{ url_for('applications.portfolio_applications', portfolio_id=portfolio.id) }}"> <a class="usa-button usa-button-secondary" href="{{ url_for('applications.view_new_application_step_2', application_id=application.id) }}">
{{ "common.cancel" | translate }} {{ "common.previous" | translate }}
</a> </a>
</span> <a href="{{ url_for('applications.portfolio_applications', portfolio_id=portfolio.id) }}">
{{ "common.cancel" | translate }}
</a>
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -22,7 +22,7 @@
<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="col col--half">
{{ application_form.csrf_token }} {{ application_form.csrf_token }}
{{ TextInput(application_form.name, validation="name", 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) }}
<div class="action-group action-group--tight"> <div class="action-group action-group--tight">
{{ SaveButton(text='common.save_changes'|translate) }} {{ SaveButton(text='common.save_changes'|translate) }}

View File

@ -4,21 +4,23 @@
{% from "components/text_input.html" import TextInput %} {% from "components/text_input.html" import TextInput %}
{% block content %} {% block content %}
<base-form inline-template> <div class="ccpo-panel-container">
<form id="add-ccpo-user-form" action="{{ url_for('ccpo.submit_new_user') }}" method="POST"> <base-form inline-template>
{{ form.csrf_token }} <form id="add-ccpo-user-form" action="{{ url_for('ccpo.submit_new_user') }}" method="POST">
<h1>{{ "ccpo.form.add_user_title" | translate }}</h1> {{ form.csrf_token }}
<div class='form-row'> <h1>{{ "ccpo.form.add_user_title" | translate }}</h1>
<div class='form-col form-col--two-thirds'> <div class='form-row'>
{{ TextInput(form.dod_id, validation='dodId', optional=False) }} <div class='form-col form-col--two-thirds'>
</div> {{ TextInput(form.dod_id, validation='dodId', optional=False) }}
<div class="form-col form-col--third"> </div>
<div class='action-group'> <div class="form-col form-col--third">
{{ SaveButton(text="common.next"|translate, element="input", additional_classes="action-group__action", form="add-ccpo-user-form") }} <div class='action-group'>
<a class='action-group__action icon-link icon-link--default' href="{{ url_for('ccpo.users') }}">{{ "common.cancel" | translate }}</a> {{ SaveButton(text="common.next"|translate, element="input", additional_classes="action-group__action", form="add-ccpo-user-form") }}
<a class='action-group__action icon-link icon-link--default' href="{{ url_for('ccpo.users') }}">{{ "common.cancel" | translate }}</a>
</div>
</div> </div>
</div> </div>
</div> </form>
</form> </base-form>
</base-form> </div>
{% endblock %} {% endblock %}

View File

@ -3,31 +3,33 @@
{% from "components/text_input.html" import TextInput %} {% from "components/text_input.html" import TextInput %}
{% block content %} {% block content %}
{% if new_user %} <div class="ccpo-panel-container">
<h3>{{ 'ccpo.form.confirm_user_title' | translate }}</h3> {% if new_user %}
<form id="add-ccpo-user-form" action="{{ url_for('ccpo.confirm_new_user') }}" method="POST"> <h3>{{ 'ccpo.form.confirm_user_title' | translate }}</h3>
{{ form.csrf_token }} <form id="add-ccpo-user-form" action="{{ url_for('ccpo.confirm_new_user') }}" method="POST">
<input type="hidden" name="dod_id" value="{{ form.dod_id.data }}"> {{ form.csrf_token }}
<div> <input type="hidden" name="dod_id" value="{{ form.dod_id.data }}">
<p> <div>
{{ "ccpo.form.confirm_user_text" | translate }} <p>
</p> {{ "ccpo.form.confirm_user_text" | translate }}
<p> </p>
{{ new_user.full_name }} <p>
</p> {{ new_user.full_name }}
<p> </p>
{{ new_user.email }} <p>
</p> {{ new_user.email }}
</div> </p>
<div class='action-group'> </div>
<input <div class='action-group'>
type='submit' <input
class='action-group__action usa-button' type='submit'
value='{{ "ccpo.form.confirm_button" | translate }}'> class='action-group__action usa-button'
<a class='action-group__action icon-link icon-link--default' href="{{ url_for('ccpo.users') }}"> value='{{ "ccpo.form.confirm_button" | translate }}'>
{{ "common.cancel" | translate }} <a class='action-group__action icon-link icon-link--default' href="{{ url_for('ccpo.users') }}">
</a> {{ "common.cancel" | translate }}
</div> </a>
</form> </div>
{% endif %} </form>
{% endif %}
</div>
{% endblock %} {% endblock %}

View File

@ -6,78 +6,80 @@
{% from "components/modal.html" import Modal %} {% from "components/modal.html" import Modal %}
{% block content %} {% block content %}
<div class='col'> <div class="ccpo-panel-container">
<div class="h2"> <div class='col'>
{{ "ccpo.users_title" | translate }} <div class="h2">
</div> {{ "ccpo.users_title" | translate }}
</div>
{% include "fragments/flash.html" %} {% include "fragments/flash.html" %}
<table>
<thead>
<tr>
<th>{{ "common.name" | translate }}</th>
<th>{{ "common.email" | translate }}</th>
<th>{{ "common.dod_id" | translate }}</th>
{% if user_can(permissions.DELETE_CCPO_USER) %}
<th></th>
{% endif %}
</tr>
</thead>
<tbody>
{% for user, form in users_info %}
{% set modal_id = "disable_ccpo_user_{}".format(user.dod_id) %}
{% set disable_button_class = 'button-danger-outline' %}
{% if user == g.current_user %}
{% set disable_button_class = "usa-button-disabled" %}
{% endif %}
<table>
<thead>
<tr> <tr>
<td>{{ user.full_name }}</td> <th>{{ "common.name" | translate }}</th>
<td>{{ user.email }}</td> <th>{{ "common.email" | translate }}</th>
<td>{{ user.dod_id }}</td> <th>{{ "common.dod_id" | translate }}</th>
{% if user_can(permissions.DELETE_CCPO_USER) %} {% if user_can(permissions.DELETE_CCPO_USER) %}
<td> <th></th>
<a v-on:click="openModal('{{ modal_id }}')" class='usa-button {{ disable_button_class }}'>
{{ "common.disable" | translate }}
</a>
</td>
{% endif %} {% endif %}
</tr> </tr>
{% endfor %} </thead>
</tbody> <tbody>
</table> {% for user, form in users_info %}
{% set modal_id = "disable_ccpo_user_{}".format(user.dod_id) %}
{% set disable_button_class = 'button-danger-outline' %}
{% if user == g.current_user %}
{% set disable_button_class = "usa-button-disabled" %}
{% endif %}
<tr>
<td>{{ user.full_name }}</td>
<td>{{ user.email }}</td>
<td>{{ user.dod_id }}</td>
{% if user_can(permissions.DELETE_CCPO_USER) %}
<td>
<a v-on:click="openModal('{{ modal_id }}')" class='usa-button {{ disable_button_class }}'>
{{ "common.disable" | translate }}
</a>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if user_can(permissions.CREATE_CCPO_USER) %}
<a class="icon-link" href="{{ url_for('ccpo.add_new_user')}}">
{{ "ccpo.add_user" | translate }} {{ Icon("plus") }}
</a>
{% endif %}
{% if user_can(permissions.DELETE_CCPO_USER) %}
{% for user, form in users_info %}
{% set modal_id = "disable_ccpo_user_{}".format(user.dod_id) %}
{% call Modal(name=modal_id) %}
<h1>Disable CCPO User</h1>
<hr>
{{
Alert(
title=("components.modal.destructive_title" | translate),
message=("ccpo.disable_user.alert_message" | translate({"user_name": user.full_name})),
level="warning"
)
}}
{{
DeleteConfirmation(
modal_id=modal_id,
delete_text='Remove Access',
delete_action=(url_for('ccpo.remove_access', user_id=user.id)),
form=form,
confirmation_text='remove'
)
}}
{% endcall %}
{% endfor %}
{% endif %}
</div> </div>
{% if user_can(permissions.CREATE_CCPO_USER) %}
<a class="icon-link" href="{{ url_for('ccpo.add_new_user')}}">
{{ "ccpo.add_user" | translate }} {{ Icon("plus") }}
</a>
{% endif %}
{% if user_can(permissions.DELETE_CCPO_USER) %}
{% for user, form in users_info %}
{% set modal_id = "disable_ccpo_user_{}".format(user.dod_id) %}
{% call Modal(name=modal_id) %}
<h1>Disable CCPO User</h1>
<hr>
{{
Alert(
title=("components.modal.destructive_title" | translate),
message=("ccpo.disable_user.alert_message" | translate({"user_name": user.full_name})),
level="warning"
)
}}
{{
DeleteConfirmation(
modal_id=modal_id,
delete_text='Remove Access',
delete_action=(url_for('ccpo.remove_access', user_id=user.id)),
form=form,
confirmation_text='remove'
)
}}
{% endcall %}
{% endfor %}
{% endif %}
{% endblock %} {% endblock %}

View File

@ -5,7 +5,7 @@
inline-template inline-template
{% if not field.errors %} {% if not field.errors %}
v-bind:filename='{{ field.filename.data | tojson }}' v-bind:filename='{{ field.filename.data | tojson }}'
v-bind:object-name='{{ field.object_name.data | tojson }}' v-bind:initial-object-name='{{ field.object_name.data | tojson }}'
{% else %} {% else %}
v-bind:initial-errors='true' v-bind:initial-errors='true'
{% endif %} {% endif %}
@ -46,7 +46,7 @@
v-bind:value="attachment" v-bind:value="attachment"
type="file"> type="file">
<input type="hidden" name="{{ field.filename.name }}" id="{{ field.filename.name }}" ref="attachmentFilename"> <input type="hidden" name="{{ field.filename.name }}" id="{{ field.filename.name }}" ref="attachmentFilename">
<input type="hidden" name="{{ field.object_name.name }}" id="{{ field.object_name.name }}" ref="attachmentObjectName"> <input type="hidden" name="{{ field.object_name.name }}" id="{{ field.object_name.name }}" ref="attachmentObjectName" v-bind:value='objectName'>
</div> </div>
<template v-if="uploadError"> <template v-if="uploadError">
<span class="usa-input__message">{{ "forms.task_order.upload_error" | translate }}</span> <span class="usa-input__message">{{ "forms.task_order.upload_error" | translate }}</span>

View File

@ -5,6 +5,7 @@
{% block content %} {% block content %}
<main class="usa-section usa-content error-page"> <main class="usa-section usa-content error-page">
<div class="panel">
<div class="panel__heading"> <div class="panel__heading">
{{ Icon('cloud', classes="icon--red icon--large")}} {{ Icon('cloud', classes="icon--red icon--large")}}
<hr> <hr>
@ -17,6 +18,7 @@
{%- endif %} {%- endif %}
</p> </p>
</div> </div>
</div>
</main> </main>
{% endblock %} {% endblock %}

View File

@ -10,29 +10,30 @@
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='img/favicon.ico') }} " /> <link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='img/favicon.ico') }} " />
</head> </head>
<body class="{% if g.modalOpen %} modalOpen{% endif %}"> <body class="{% if g.modalOpen %} modalOpen{% endif %}">
<div id='app-root'>
{% block template_vars %}{% endblock %}
{% block template_vars %}{% endblock %} {% include 'components/usa_header.html' %}
{% include 'components/usa_header.html' %} {% include 'navigation/topbar.html' %}
{% include 'navigation/topbar.html' %} <div class='global-layout'>
<div class='global-layout'> <div class='global-panel-container'>
{% block sidenav %}{% endblock %}
<div class='global-panel-container'> {% block content %}
{% block sidenav %}{% endblock %} these are not the droids you are looking for
{% endblock %}
{% block content %} </div>
these are not the droids you are looking for
{% endblock %}
</div> </div>
{% include 'footer.html' %}
{% block modal %}{% endblock %}
{% assets "js_all" %}
<script src="{{ ASSET_URL }}"></script>
{% endassets %}
</div> </div>
{% include 'footer.html' %}
{% block modal %}{% endblock %}
{% assets "js_all" %}
<script src="{{ ASSET_URL }}"></script>
{% endassets %}
</body> </body>
</html> </html>

View File

@ -15,14 +15,15 @@
<p>{{ "portfolios.header" | translate }}</p> <p>{{ "portfolios.header" | translate }}</p>
<h1>{{ 'portfolios.new.title' | translate }}</h1> <h1>{{ 'portfolios.new.title' | translate }}</h1>
</div> </div>
</div>
{{ 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" action="{{ url_for('portfolios.create_portfolio') }}" method="POST"> <form id="portfolio-create" class="col form" 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 form-row--bordered">
<div class="form-col"> <div class="form-col">
{{ TextInput(form.name, validation="name", 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>
@ -38,13 +39,18 @@
{{ "forms.portfolio.defense_component.help_text" | translate | safe }} {{ "forms.portfolio.defense_component.help_text" | translate | safe }}
</div> </div>
</div> </div>
<div class='action-group-footer'> <div
{% block next_button %} class='action-group-footer'
{{ SaveButton(text=('portfolios.new.save' | translate), form="portfolio-create", element="input") }} v-bind:class="{'action-group-footer--expand-offset': this.$root.sidenavExpanded, 'action-group-footer--collapse-offset': !this.$root.sidenavExpanded}">
{% endblock %} <div class="action-group-footer--container">
<a class="usa-button usa-button-secondary" href="{{ url_for('atst.home') }}"> {% block next_button %}
Cancel {{ SaveButton(text=('portfolios.new.save' | translate), form="portfolio-create", element="input") }}
</a> {% endblock %}
<a class="usa-button usa-button-secondary" href="{{ url_for('atst.home') }}">
Cancel
</a>
</div>
</div>
</form> </form>
</div> </div>
</base-form> </base-form>

View File

@ -28,10 +28,13 @@
{% include "fragments/flash.html" %} {% include "fragments/flash.html" %}
<div class="task-order"> <div class="task-order form">
{% block to_builder_form_field %}{% endblock %} {% block to_builder_form_field %}{% endblock %}
</div> </div>
<span class="action-group-footer"> <div
class="action-group-footer"
v-bind:class="{'action-group-footer--expand-offset': this.$root.sidenavExpanded, 'action-group-footer--collapse-offset': !this.$root.sidenavExpanded}">
<div class="action-group-footer--container">
{% block next_button %} {% block next_button %}
<input <input
type="submit" type="submit"
@ -58,14 +61,13 @@
</a> </a>
{%- endif %} {%- endif %}
{% endif %} {% endif %}
<a <a
v-on:click="openModal('cancel')" v-on:click="openModal('cancel')"
class="action-group__action icon-link"> class="action-group__action icon-link">
{{ "common.cancel" | translate }} {{ "common.cancel" | translate }}
</a> </a>
</span> </div>
</div>
</form> </form>
</to-form> </to-form>
{% endblock %} {% endblock %}

View File

@ -2,6 +2,8 @@ import json
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from uuid import uuid4 from uuid import uuid4
import pendulum
import pydantic
import pytest import pytest
from tests.factories import ApplicationFactory, EnvironmentFactory from tests.factories import ApplicationFactory, EnvironmentFactory
from tests.mock_azure import AUTH_CREDENTIALS, mock_azure from tests.mock_azure import AUTH_CREDENTIALS, mock_azure
@ -20,10 +22,16 @@ from atst.domain.csp.cloud.models import (
BillingProfileTenantAccessCSPResult, BillingProfileTenantAccessCSPResult,
BillingProfileVerificationCSPPayload, BillingProfileVerificationCSPPayload,
BillingProfileVerificationCSPResult, BillingProfileVerificationCSPResult,
CostManagementQueryCSPResult,
EnvironmentCSPPayload,
EnvironmentCSPResult,
PrincipalAdminRoleCSPPayload,
PrincipalAdminRoleCSPResult,
ProductPurchaseCSPPayload, ProductPurchaseCSPPayload,
ProductPurchaseCSPResult, ProductPurchaseCSPResult,
ProductPurchaseVerificationCSPPayload, ProductPurchaseVerificationCSPPayload,
ProductPurchaseVerificationCSPResult, ProductPurchaseVerificationCSPResult,
ReportingCSPPayload,
SubscriptionCreationCSPPayload, SubscriptionCreationCSPPayload,
SubscriptionCreationCSPResult, SubscriptionCreationCSPResult,
SubscriptionVerificationCSPPayload, SubscriptionVerificationCSPPayload,
@ -44,6 +52,7 @@ from atst.domain.csp.cloud.models import (
TenantPrincipalCSPResult, TenantPrincipalCSPResult,
TenantPrincipalOwnershipCSPPayload, TenantPrincipalOwnershipCSPPayload,
TenantPrincipalOwnershipCSPResult, TenantPrincipalOwnershipCSPResult,
UserCSPPayload,
) )
BILLING_ACCOUNT_NAME = "52865e4c-52e8-5a6c-da6b-c58f0814f06f:7ea5de9d-b8ce-4901-b1c5-d864320c7b03_2019-05-31" BILLING_ACCOUNT_NAME = "52865e4c-52e8-5a6c-da6b-c58f0814f06f:7ea5de9d-b8ce-4901-b1c5-d864320c7b03_2019-05-31"
@ -57,12 +66,14 @@ def mock_management_group_create(mock_azure, spec_dict):
def test_create_environment_succeeds(mock_azure: AzureCloudProvider): def test_create_environment_succeeds(mock_azure: AzureCloudProvider):
environment = EnvironmentFactory.create() environment = EnvironmentFactory.create()
mock_management_group_create(mock_azure, {"id": "Test Id"}) mock_management_group_create(mock_azure, {"id": "Test Id"})
result = mock_azure.create_environment( mock_azure = mock_get_secret(mock_azure)
AUTH_CREDENTIALS, environment.creator, environment
payload = EnvironmentCSPPayload(
tenant_id="1234", display_name=environment.name, parent_id=str(uuid4())
) )
result = mock_azure.create_environment(payload)
assert result.id == "Test Id" assert result.id == "Test Id"
@ -97,20 +108,6 @@ def test_create_application_succeeds(mock_azure: AzureCloudProvider):
assert result.id == "Test Id" assert result.id == "Test Id"
def test_create_atat_admin_user_succeeds(mock_azure: AzureCloudProvider):
environment_id = str(uuid4())
csp_user_id = str(uuid4)
mock_azure.sdk.graphrbac.GraphRbacManagementClient.return_value.service_principals.create.return_value.object_id = (
csp_user_id
)
result = mock_azure.create_atat_admin_user(AUTH_CREDENTIALS, environment_id)
assert result.get("csp_user_id") == csp_user_id
def test_create_policy_definition_succeeds(mock_azure: AzureCloudProvider): def test_create_policy_definition_succeeds(mock_azure: AzureCloudProvider):
subscription_id = str(uuid4()) subscription_id = str(uuid4())
management_group_id = str(uuid4()) management_group_id = str(uuid4())
@ -162,6 +159,27 @@ def test_create_tenant(mock_azure: AzureCloudProvider):
assert body.tenant_id == "60ff9d34-82bf-4f21-b565-308ef0533435" assert body.tenant_id == "60ff9d34-82bf-4f21-b565-308ef0533435"
def test_create_tenant_fails(mock_azure: AzureCloudProvider):
mock_result = Mock()
mock_result.json.return_value = {"error": "body"}
mock_result.status_code = 403
mock_azure.sdk.requests.post.return_value = mock_result
payload = TenantCSPPayload(
**dict(
user_id="admin",
password="JediJan13$coot", # pragma: allowlist secret
domain_name="jediccpospawnedtenant2",
first_name="Tedry",
last_name="Tenet",
country_code="US",
password_recovery_email_address="thomas@promptworks.com",
)
)
mock_azure = mock_get_secret(mock_azure)
result = mock_azure.create_tenant(payload)
assert result.get("status") == "error"
def test_create_billing_profile_creation(mock_azure: AzureCloudProvider): def test_create_billing_profile_creation(mock_azure: AzureCloudProvider):
mock_azure.sdk.adal.AuthenticationContext.return_value.context.acquire_token_with_client_credentials.return_value = { mock_azure.sdk.adal.AuthenticationContext.return_value.context.acquire_token_with_client_credentials.return_value = {
"accessToken": "TOKEN" "accessToken": "TOKEN"
@ -573,10 +591,10 @@ def test_create_tenant_principal_credential(mock_azure: AzureCloudProvider):
def test_create_admin_role_definition(mock_azure: AzureCloudProvider): def test_create_admin_role_definition(mock_azure: AzureCloudProvider):
with patch.object( with patch.object(
AzureCloudProvider, AzureCloudProvider,
"_get_elevated_management_token", "_get_tenant_admin_token",
wraps=mock_azure._get_elevated_management_token, wraps=mock_azure._get_tenant_admin_token,
) as get_elevated_management_token: ) as get_tenant_admin_token:
get_elevated_management_token.return_value = "my fake token" get_tenant_admin_token.return_value = "my fake token"
mock_result = Mock() mock_result = Mock()
mock_result.ok = True mock_result.ok = True
@ -657,6 +675,35 @@ def test_create_tenant_principal_ownership(mock_azure: AzureCloudProvider):
assert result.principal_owner_assignment_id == "id" assert result.principal_owner_assignment_id == "id"
def test_create_principal_admin_role(mock_azure: AzureCloudProvider):
with patch.object(
AzureCloudProvider,
"_get_tenant_admin_token",
wraps=mock_azure._get_tenant_admin_token,
) as get_tenant_admin_token:
get_tenant_admin_token.return_value = "my fake token"
mock_result = Mock()
mock_result.ok = True
mock_result.json.return_value = {"id": "id"}
mock_azure.sdk.requests.post.return_value = mock_result
payload = PrincipalAdminRoleCSPPayload(
**{
"tenant_id": uuid4().hex,
"principal_id": "6d2d2d6c-a6d6-41e1-8bb1-73d11475f8f4",
"admin_role_def_id": uuid4().hex,
}
)
result: PrincipalAdminRoleCSPResult = mock_azure.create_principal_admin_role(
payload
)
assert result.principal_assignment_id == "id"
def test_create_subscription_creation(mock_azure: AzureCloudProvider): def test_create_subscription_creation(mock_azure: AzureCloudProvider):
with patch.object( with patch.object(
AzureCloudProvider, AzureCloudProvider,
@ -718,3 +765,176 @@ def test_create_subscription_verification(mock_azure: AzureCloudProvider):
payload payload
) )
assert result.subscription_id == "60fbbb72-0516-4253-ab18-c92432ba3230" assert result.subscription_id == "60fbbb72-0516-4253-ab18-c92432ba3230"
def test_get_reporting_data(mock_azure: AzureCloudProvider):
mock_result = Mock()
mock_result.json.return_value = {
"eTag": None,
"id": "providers/Microsoft.Billing/billingAccounts/52865e4c-52e8-5a6c-da6b-c58f0814f06f:7ea5de9d-b8ce-4901-b1c5-d864320c7b03_2019-05-31/billingProfiles/XQDJ-6LB4-BG7-TGB/invoiceSections/P73M-XC7J-PJA-TGB/providers/Microsoft.CostManagement/query/e82d0cda-2ffb-4476-a98a-425c83c216f9",
"location": None,
"name": "e82d0cda-2ffb-4476-a98a-425c83c216f9",
"properties": {
"columns": [
{"name": "PreTaxCost", "type": "Number"},
{"name": "UsageDate", "type": "Number"},
{"name": "InvoiceId", "type": "String"},
{"name": "Currency", "type": "String"},
],
"nextLink": None,
"rows": [],
},
"sku": None,
"type": "Microsoft.CostManagement/query",
}
mock_result.ok = True
mock_azure.sdk.requests.post.return_value = mock_result
mock_azure = mock_get_secret(mock_azure)
# Subset of a profile's CSP data that we care about for reporting
csp_data = {
"tenant_id": "6d2d2d6c-a6d6-41e1-8bb1-73d11475f8f4",
"billing_profile_properties": {
"invoice_sections": [
{
"invoice_section_id": "providers/Microsoft.Billing/billingAccounts/52865e4c-52e8-5a6c-da6b-c58f0814f06f:7ea5de9d-b8ce-4901-b1c5-d864320c7b03_2019-05-31/billingProfiles/XQDJ-6LB4-BG7-TGB/invoiceSections/P73M-XC7J-PJA-TGB",
}
],
},
}
data: CostManagementQueryCSPResult = mock_azure.get_reporting_data(
ReportingCSPPayload(
from_date=pendulum.now().subtract(years=1).add(days=1).format("YYYY-MM-DD"),
to_date=pendulum.now().format("YYYY-MM-DD"),
**csp_data,
)
)
assert isinstance(data, CostManagementQueryCSPResult)
assert data.name == "e82d0cda-2ffb-4476-a98a-425c83c216f9"
assert len(data.properties.columns) == 4
def test_get_reporting_data_malformed_payload(mock_azure: AzureCloudProvider):
mock_result = Mock()
mock_result.ok = True
mock_azure.sdk.requests.post.return_value = mock_result
mock_azure = mock_get_secret(mock_azure)
# Malformed csp_data payloads that should throw pydantic validation errors
index_error = {
"tenant_id": "6d2d2d6c-a6d6-41e1-8bb1-73d11475f8f4",
"billing_profile_properties": {"invoice_sections": [],},
}
key_error = {
"tenant_id": "6d2d2d6c-a6d6-41e1-8bb1-73d11475f8f4",
"billing_profile_properties": {"invoice_sections": [{}],},
}
for malformed_payload in [key_error, index_error]:
with pytest.raises(pydantic.ValidationError):
assert mock_azure.get_reporting_data(
ReportingCSPPayload(
from_date="foo", to_date="bar", **malformed_payload,
)
)
def test_get_secret(mock_azure: AzureCloudProvider):
with patch.object(
AzureCloudProvider,
"_get_client_secret_credential_obj",
wraps=mock_azure._get_client_secret_credential_obj,
) as _get_client_secret_credential_obj:
_get_client_secret_credential_obj.return_value = {}
mock_azure.sdk.secrets.SecretClient.return_value.get_secret.return_value.value = (
"my secret"
)
assert mock_azure.get_secret("secret key") == "my secret"
def test_set_secret(mock_azure: AzureCloudProvider):
with patch.object(
AzureCloudProvider,
"_get_client_secret_credential_obj",
wraps=mock_azure._get_client_secret_credential_obj,
) as _get_client_secret_credential_obj:
_get_client_secret_credential_obj.return_value = {}
mock_azure.sdk.secrets.SecretClient.return_value.set_secret.return_value = (
"my secret"
)
assert mock_azure.set_secret("secret key", "secret_value") == "my secret"
def test_create_active_directory_user(mock_azure: AzureCloudProvider):
mock_result = Mock()
mock_result.ok = True
mock_result.json.return_value = {"id": "id"}
mock_azure.sdk.requests.post.return_value = mock_result
payload = UserCSPPayload(
tenant_id=uuid4().hex,
display_name="Test Testerson",
tenant_host_name="testtenant",
email="test@testerson.test",
password="asdfghjkl", # pragma: allowlist secret
)
result = mock_azure._create_active_directory_user("token", payload)
assert result.id == "id"
def test_update_active_directory_user_email(mock_azure: AzureCloudProvider):
mock_result = Mock()
mock_result.ok = True
mock_azure.sdk.requests.patch.return_value = mock_result
payload = UserCSPPayload(
tenant_id=uuid4().hex,
display_name="Test Testerson",
tenant_host_name="testtenant",
email="test@testerson.test",
password="asdfghjkl", # pragma: allowlist secret
)
result = mock_azure._update_active_directory_user_email(
"token", uuid4().hex, payload
)
assert result
def test_create_user(mock_azure: AzureCloudProvider):
with patch.object(
AzureCloudProvider,
"_get_tenant_principal_token",
wraps=mock_azure._get_tenant_principal_token,
) as _get_tenant_principal_token:
_get_tenant_principal_token.return_value = "token"
mock_result_create = Mock()
mock_result_create.ok = True
mock_result_create.json.return_value = {"id": "id"}
mock_azure.sdk.requests.post.return_value = mock_result_create
mock_result_update = Mock()
mock_result_update.ok = True
mock_azure.sdk.requests.patch.return_value = mock_result_update
payload = UserCSPPayload(
tenant_id=uuid4().hex,
display_name="Test Testerson",
tenant_host_name="testtenant",
email="test@testerson.test",
password="asdfghjkl", # pragma: allowlist secret
)
result = mock_azure.create_user(payload)
assert result.id == "id"

View File

@ -1,6 +1,7 @@
import pytest import pytest
from atst.domain.csp import MockCloudProvider from atst.domain.csp import MockCloudProvider
from atst.domain.csp.cloud.models import EnvironmentCSPPayload, EnvironmentCSPResult
from tests.factories import EnvironmentFactory, EnvironmentRoleFactory, UserFactory from tests.factories import EnvironmentFactory, EnvironmentRoleFactory, UserFactory
@ -14,20 +15,17 @@ def mock_csp():
def test_create_environment(mock_csp: MockCloudProvider): def test_create_environment(mock_csp: MockCloudProvider):
environment = EnvironmentFactory.create() environment = EnvironmentFactory.create()
user = UserFactory.create() environment.application.cloud_id = "parent_id"
environment_id = mock_csp.create_environment(CREDENTIALS, user, environment) environment.application.portfolio.csp_data = {"tenant_id": "fake"}
assert isinstance(environment_id, str) payload = EnvironmentCSPPayload(
**dict(
tenant_id=environment.application.portfolio.csp_data.get("tenant_id"),
def test_create_admin_user(mock_csp: MockCloudProvider): display_name=environment.name,
admin_user = mock_csp.create_atat_admin_user(CREDENTIALS, "env_id") parent_id=environment.application.cloud_id,
assert isinstance(admin_user["id"], str) )
assert isinstance(admin_user["credentials"], dict) )
result = mock_csp.create_environment(payload)
assert isinstance(result, EnvironmentCSPResult)
def test_create_environment_baseline(mock_csp: MockCloudProvider):
baseline = mock_csp.create_atat_admin_user(CREDENTIALS, "env_id")
assert isinstance(baseline, dict)
def test_create_or_update_user(mock_csp: MockCloudProvider): def test_create_or_update_user(mock_csp: MockCloudProvider):

View File

@ -93,27 +93,25 @@ def test_disable_completed(application_role, environment):
def test_disable_checks_env_provisioning_status(session): def test_disable_checks_env_provisioning_status(session):
environment = EnvironmentFactory.create() environment = EnvironmentFactory.create()
assert environment.is_pending assert not environment.cloud_id
env_role1 = EnvironmentRoleFactory.create(environment=environment) env_role1 = EnvironmentRoleFactory.create(environment=environment)
env_role1 = EnvironmentRoles.disable(env_role1.id) env_role1 = EnvironmentRoles.disable(env_role1.id)
assert env_role1.disabled assert env_role1.disabled
environment.cloud_id = "cloud-id" environment.cloud_id = "cloud-id"
environment.root_user_info = {"credentials": "credentials"}
session.add(environment) session.add(environment)
session.commit() session.commit()
session.refresh(environment) session.refresh(environment)
assert not environment.is_pending assert environment.cloud_id
env_role2 = EnvironmentRoleFactory.create(environment=environment) env_role2 = EnvironmentRoleFactory.create(environment=environment)
env_role2 = EnvironmentRoles.disable(env_role2.id) env_role2 = EnvironmentRoles.disable(env_role2.id)
assert env_role2.disabled assert env_role2.disabled
def test_disable_checks_env_role_provisioning_status(): def test_disable_checks_env_role_provisioning_status():
environment = EnvironmentFactory.create( environment = EnvironmentFactory.create(cloud_id="cloud-id")
cloud_id="cloud-id", root_user_info={"credentials": "credentials"} environment.application.portfolio.csp_data = {"tenant_id": uuid4().hex}
)
env_role1 = EnvironmentRoleFactory.create(environment=environment) env_role1 = EnvironmentRoleFactory.create(environment=environment)
assert not env_role1.csp_user_id assert not env_role1.csp_user_id
env_role1 = EnvironmentRoles.disable(env_role1.id) env_role1 = EnvironmentRoles.disable(env_role1.id)

View File

@ -1,5 +1,4 @@
import pytest import pytest
import pendulum
from uuid import uuid4 from uuid import uuid4
from atst.domain.environments import Environments from atst.domain.environments import Environments
@ -14,6 +13,7 @@ from tests.factories import (
EnvironmentRoleFactory, EnvironmentRoleFactory,
ApplicationRoleFactory, ApplicationRoleFactory,
) )
from tests.utils import EnvQueryTest
def test_create_environments(): def test_create_environments():
@ -119,40 +119,6 @@ def test_update_does_not_duplicate_names_within_application():
Environments.update(dupe_env, name) Environments.update(dupe_env, name)
class EnvQueryTest:
@property
def NOW(self):
return pendulum.now()
@property
def YESTERDAY(self):
return self.NOW.subtract(days=1)
@property
def TOMORROW(self):
return self.NOW.add(days=1)
def create_portfolio_with_clins(self, start_and_end_dates, env_data=None):
env_data = env_data or {}
return PortfolioFactory.create(
applications=[
{
"name": "Mos Eisley",
"description": "Where Han shot first",
"environments": [{"name": "thebar", **env_data}],
}
],
task_orders=[
{
"create_clins": [
{"start_date": start_date, "end_date": end_date}
for (start_date, end_date) in start_and_end_dates
]
}
],
)
class TestGetEnvironmentsPendingCreate(EnvQueryTest): class TestGetEnvironmentsPendingCreate(EnvQueryTest):
def test_with_expired_clins(self, session): def test_with_expired_clins(self, session):
self.create_portfolio_with_clins([(self.YESTERDAY, self.YESTERDAY)]) self.create_portfolio_with_clins([(self.YESTERDAY, self.YESTERDAY)])
@ -168,37 +134,16 @@ class TestGetEnvironmentsPendingCreate(EnvQueryTest):
self.create_portfolio_with_clins([(self.TOMORROW, self.TOMORROW)]) self.create_portfolio_with_clins([(self.TOMORROW, self.TOMORROW)])
assert len(Environments.get_environments_pending_creation(self.NOW)) == 0 assert len(Environments.get_environments_pending_creation(self.NOW)) == 0
def test_with_already_provisioned_app(self, session):
self.create_portfolio_with_clins(
[(self.YESTERDAY, self.TOMORROW)], app_data={"cloud_id": uuid4().hex}
)
assert len(Environments.get_environments_pending_creation(self.NOW)) == 1
def test_with_already_provisioned_env(self, session): def test_with_already_provisioned_env(self, session):
self.create_portfolio_with_clins( self.create_portfolio_with_clins(
[(self.YESTERDAY, self.TOMORROW)], env_data={"cloud_id": uuid4().hex} [(self.YESTERDAY, self.TOMORROW)],
env_data={"cloud_id": uuid4().hex},
app_data={"cloud_id": uuid4().hex},
) )
assert len(Environments.get_environments_pending_creation(self.NOW)) == 0 assert len(Environments.get_environments_pending_creation(self.NOW)) == 0
class TestGetEnvironmentsPendingAtatUserCreation(EnvQueryTest):
def test_with_provisioned_environment(self):
self.create_portfolio_with_clins(
[(self.YESTERDAY, self.TOMORROW)],
{"cloud_id": uuid4().hex, "root_user_info": {}},
)
assert (
len(Environments.get_environments_pending_atat_user_creation(self.NOW)) == 0
)
def test_with_unprovisioned_environment(self):
self.create_portfolio_with_clins(
[(self.YESTERDAY, self.TOMORROW)],
{"cloud_id": uuid4().hex, "root_user_info": None},
)
assert (
len(Environments.get_environments_pending_atat_user_creation(self.NOW)) == 1
)
def test_with_unprovisioned_expired_clins_environment(self):
self.create_portfolio_with_clins(
[(self.YESTERDAY, self.YESTERDAY)],
{"cloud_id": uuid4().hex, "root_user_info": None},
)
assert (
len(Environments.get_environments_pending_atat_user_creation(self.NOW)) == 0
)

View File

@ -26,6 +26,7 @@ from tests.factories import (
PortfolioStateMachineFactory, PortfolioStateMachineFactory,
get_all_portfolio_permission_sets, get_all_portfolio_permission_sets,
) )
from tests.utils import EnvQueryTest
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
@ -263,10 +264,44 @@ def test_create_state_machine(portfolio):
assert fsm assert fsm
def test_get_portfolios_pending_provisioning(session): class TestGetPortfoliosPendingCreate(EnvQueryTest):
for x in range(5): def test_finds_unstarted(self):
portfolio = PortfolioFactory.create() for x in range(5):
sm = PortfolioStateMachineFactory.create(portfolio=portfolio) if x == 2:
if x == 2: state = "COMPLETED"
sm.state = FSMStates.COMPLETED else:
assert len(Portfolios.get_portfolios_pending_provisioning()) == 4 state = "UNSTARTED"
self.create_portfolio_with_clins(
[(self.YESTERDAY, self.TOMORROW)], state_machine_status=state
)
assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 4
def test_finds_created(self):
self.create_portfolio_with_clins(
[(self.YESTERDAY, self.TOMORROW)], state_machine_status="TENANT_CREATED"
)
assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 1
def test_does_not_find_failed(self):
self.create_portfolio_with_clins(
[(self.YESTERDAY, self.TOMORROW)], state_machine_status="TENANT_FAILED"
)
assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 0
def test_with_expired_clins(self):
self.create_portfolio_with_clins([(self.YESTERDAY, self.YESTERDAY)])
assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 0
def test_with_active_clins(self):
portfolio = self.create_portfolio_with_clins([(self.YESTERDAY, self.TOMORROW)])
Portfolios.get_portfolios_pending_provisioning(self.NOW) == [portfolio.id]
def test_with_future_clins(self):
self.create_portfolio_with_clins([(self.TOMORROW, self.TOMORROW)])
assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 0
def test_with_already_provisioned_env(self):
self.create_portfolio_with_clins(
[(self.YESTERDAY, self.TOMORROW)], env_data={"cloud_id": uuid4().hex}
)
assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 0

View File

@ -1,5 +1,5 @@
import pytest import pytest
from datetime import date, timedelta from datetime import date, datetime, timedelta
from decimal import Decimal from decimal import Decimal
from atst.domain.exceptions import AlreadyExistsError from atst.domain.exceptions import AlreadyExistsError
@ -178,3 +178,21 @@ def test_allows_alphanumeric_number():
for number in valid_to_numbers: for number in valid_to_numbers:
assert TaskOrders.create(portfolio.id, number, [], None) assert TaskOrders.create(portfolio.id, number, [], None)
def test_get_for_send_task_order_files():
new_to = TaskOrderFactory.create(create_clins=[{}])
updated_to = TaskOrderFactory.create(
create_clins=[{"last_sent_at": datetime(2020, 2, 1)}],
pdf_last_sent_at=datetime(2020, 1, 1),
)
sent_to = TaskOrderFactory.create(
create_clins=[{"last_sent_at": datetime(2020, 1, 1)}],
pdf_last_sent_at=datetime(2020, 1, 1),
)
updated_and_new_task_orders = TaskOrders.get_for_send_task_order_files()
assert len(updated_and_new_task_orders) == 2
assert sent_to not in updated_and_new_task_orders
assert updated_to in updated_and_new_task_orders
assert new_to in updated_and_new_task_orders

View File

@ -322,6 +322,7 @@ class TaskOrderFactory(Base):
number = factory.LazyFunction(random_task_order_number) number = factory.LazyFunction(random_task_order_number)
signed_at = None signed_at = None
_pdf = factory.SubFactory(AttachmentFactory) _pdf = factory.SubFactory(AttachmentFactory)
pdf_last_sent_at = None
@classmethod @classmethod
def _create(cls, model_class, *args, **kwargs): def _create(cls, model_class, *args, **kwargs):
@ -347,6 +348,7 @@ class CLINFactory(Base):
jedi_clin_type = factory.LazyFunction( jedi_clin_type = factory.LazyFunction(
lambda *args: random.choice(list(clin.JEDICLINType)) lambda *args: random.choice(list(clin.JEDICLINType))
) )
last_sent_at = None
class NotificationRecipientFactory(Base): class NotificationRecipientFactory(Base):

View File

@ -51,28 +51,6 @@ def test_audit_event_for_environment_deletion(session):
assert after assert after
@pytest.mark.parametrize(
"env_data,expected_status",
[
[
{"cloud_id": None, "root_user_info": None},
Environment.ProvisioningStatus.PENDING,
],
[
{"cloud_id": 1, "root_user_info": None},
Environment.ProvisioningStatus.PENDING,
],
[
{"cloud_id": 1, "root_user_info": {}},
Environment.ProvisioningStatus.COMPLETED,
],
],
)
def test_environment_provisioning_status(env_data, expected_status):
environment = EnvironmentFactory.create(**env_data)
assert environment.provisioning_status == expected_status
def test_environment_roles_do_not_include_deleted(): def test_environment_roles_do_not_include_deleted():
member_list = [ member_list = [
{"role_name": CSPRole.ADMIN}, {"role_name": CSPRole.ADMIN},

View File

@ -2,6 +2,8 @@ import pendulum
import pytest import pytest
from uuid import uuid4 from uuid import uuid4
from unittest.mock import Mock from unittest.mock import Mock
from smtplib import SMTPException
from azure.core.exceptions import AzureError
from atst.domain.csp.cloud import MockCloudProvider from atst.domain.csp.cloud import MockCloudProvider
from atst.domain.portfolios import Portfolios from atst.domain.portfolios import Portfolios
@ -12,25 +14,26 @@ from atst.jobs import (
dispatch_create_environment, dispatch_create_environment,
dispatch_create_application, dispatch_create_application,
dispatch_create_user, dispatch_create_user,
dispatch_create_atat_admin_user,
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,
do_create_environment, do_create_environment,
do_create_application, do_create_application,
do_create_atat_admin_user,
) )
from tests.factories import ( from tests.factories import (
ApplicationFactory,
ApplicationRoleFactory,
EnvironmentFactory, EnvironmentFactory,
EnvironmentRoleFactory, EnvironmentRoleFactory,
PortfolioFactory, PortfolioFactory,
PortfolioStateMachineFactory, PortfolioStateMachineFactory,
ApplicationFactory, TaskOrderFactory,
ApplicationRoleFactory,
UserFactory, UserFactory,
) )
from atst.models import CSPRole, EnvironmentRole, ApplicationRoleStatus, JobFailure from atst.models import CSPRole, EnvironmentRole, ApplicationRoleStatus, JobFailure
from atst.utils.localization import translate
@pytest.fixture(autouse=True, scope="function") @pytest.fixture(autouse=True, scope="function")
@ -94,6 +97,10 @@ tomorrow = now.add(days=1)
def test_create_environment_job(session, csp): def test_create_environment_job(session, csp):
environment = EnvironmentFactory.create() environment = EnvironmentFactory.create()
environment.application.cloud_id = "parentId"
environment.application.portfolio.csp_data = {"tenant_id": "fake"}
session.add(environment)
session.commit()
do_create_environment(csp, environment.id) do_create_environment(csp, environment.id)
session.refresh(environment) session.refresh(environment)
@ -149,19 +156,11 @@ def test_create_user_job(session, csp):
assert app_role.cloud_id assert app_role.cloud_id
def test_create_atat_admin_user(csp, session):
environment = EnvironmentFactory.create(cloud_id="something")
do_create_atat_admin_user(csp, environment.id)
session.refresh(environment)
assert environment.root_user_info
def test_dispatch_create_environment(session, monkeypatch): def test_dispatch_create_environment(session, monkeypatch):
# Given that I have a portfolio with an active CLIN and two environments, # Given that I have a portfolio with an active CLIN and two environments,
# one of which is deleted # one of which is deleted
portfolio = PortfolioFactory.create( portfolio = PortfolioFactory.create(
applications=[{"environments": [{}, {}]}], applications=[{"environments": [{}, {}], "cloud_id": uuid4().hex}],
task_orders=[ task_orders=[
{ {
"create_clins": [ "create_clins": [
@ -227,36 +226,9 @@ def test_dispatch_create_user(monkeypatch):
mock.delay.assert_called_once_with(application_role_ids=[app_role.id]) mock.delay.assert_called_once_with(application_role_ids=[app_role.id])
def test_dispatch_create_atat_admin_user(session, monkeypatch):
portfolio = PortfolioFactory.create(
applications=[
{"environments": [{"cloud_id": uuid4().hex, "root_user_info": None}]}
],
task_orders=[
{
"create_clins": [
{
"start_date": pendulum.now().subtract(days=1),
"end_date": pendulum.now().add(days=1),
}
]
}
],
)
mock = Mock()
monkeypatch.setattr("atst.jobs.create_atat_admin_user", mock)
environment = portfolio.applications[0].environments[0]
dispatch_create_atat_admin_user.run()
mock.delay.assert_called_once_with(environment_id=environment.id)
def test_create_environment_no_dupes(session, celery_app, celery_worker): def test_create_environment_no_dupes(session, celery_app, celery_worker):
portfolio = PortfolioFactory.create( portfolio = PortfolioFactory.create(
applications=[ applications=[{"environments": [{"cloud_id": uuid4().hex}]}],
{"environments": [{"cloud_id": uuid4().hex, "root_user_info": {}}]}
],
task_orders=[ task_orders=[
{ {
"create_clins": [ "create_clins": [
@ -286,9 +258,19 @@ def test_create_environment_no_dupes(session, celery_app, celery_worker):
assert environment.claimed_until == None assert environment.claimed_until == None
def test_dispatch_provision_portfolio( def test_dispatch_provision_portfolio(csp, monkeypatch):
csp, session, portfolio, celery_app, celery_worker, monkeypatch portfolio = PortfolioFactory.create(
): task_orders=[
{
"create_clins": [
{
"start_date": pendulum.now().subtract(days=1),
"end_date": pendulum.now().add(days=1),
}
]
}
],
)
sm = PortfolioStateMachineFactory.create(portfolio=portfolio) sm = PortfolioStateMachineFactory.create(portfolio=portfolio)
mock = Mock() mock = Mock()
monkeypatch.setattr("atst.jobs.provision_portfolio", mock) monkeypatch.setattr("atst.jobs.provision_portfolio", mock)
@ -310,3 +292,84 @@ def test_provision_portfolio_create_tenant(
# monkeypatch.setattr("atst.jobs.provision_portfolio", mock) # monkeypatch.setattr("atst.jobs.provision_portfolio", mock)
# dispatch_provision_portfolio.run() # dispatch_provision_portfolio.run()
# mock.delay.assert_called_once_with(portfolio_id=portfolio.id) # mock.delay.assert_called_once_with(portfolio_id=portfolio.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)
def _download_task_order(MockFileService, object_name):
return {"name": object_name}
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"}])
dispatch_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()
# Create new TO
task_order = TaskOrderFactory.create(create_clins=[{"number": "0001"}])
assert not task_order.pdf_last_sent_at
dispatch_send_task_order_files.run()
# 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",
}
],
)
assert task_order.pdf_last_sent_at
def test_dispatch_send_task_order_files_send_failure(monkeypatch):
def _raise_smtp_exception(**kwargs):
raise SMTPException
monkeypatch.setattr("atst.jobs.send_mail", _raise_smtp_exception)
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

View File

@ -5,9 +5,12 @@ from unittest.mock import Mock
from OpenSSL import crypto from OpenSSL import crypto
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
from flask import template_rendered from flask import template_rendered
import pendulum
from atst.utils.notification_sender import NotificationSender from atst.utils.notification_sender import NotificationSender
import tests.factories as factories
@contextmanager @contextmanager
def captured_templates(app): def captured_templates(app):
@ -62,3 +65,46 @@ def make_crl_list(x509_obj, x509_path):
issuer = x509_obj.issuer.public_bytes(default_backend()) issuer = x509_obj.issuer.public_bytes(default_backend())
filename = os.path.basename(x509_path) filename = os.path.basename(x509_path)
return [(filename, issuer.hex())] return [(filename, issuer.hex())]
class EnvQueryTest:
@property
def NOW(self):
return pendulum.now()
@property
def YESTERDAY(self):
return self.NOW.subtract(days=1)
@property
def TOMORROW(self):
return self.NOW.add(days=1)
def create_portfolio_with_clins(
self,
start_and_end_dates,
env_data=None,
app_data=None,
state_machine_status=None,
):
env_data = env_data or {}
app_data = app_data or {}
return factories.PortfolioFactory.create(
state=state_machine_status,
applications=[
{
"name": "Mos Eisley",
"description": "Where Han shot first",
"environments": [{"name": "thebar", **env_data}],
**app_data,
}
],
task_orders=[
{
"create_clins": [
{"start_date": start_date, "end_date": end_date}
for (start_date, end_date) in start_and_end_dates
]
}
],
)

View File

@ -128,9 +128,6 @@ flash:
message: There was an error processing the invitation for {user_name} from {application_name} message: There was an error processing the invitation for {user_name} from {application_name}
resent: resent:
message: "{email} has been sent an invitation to access this Application" message: "{email} has been sent an invitation to access this Application"
revoked:
title: Application invitation revoked
message: You have successfully revoked the invite for {user_name} from {application_name}
application_member: application_member:
removed: removed:
title: Team member removed from application title: Team member removed from application
@ -166,6 +163,9 @@ flash:
errors: errors:
title: There were some errors title: There were some errors
message: Please see below. message: Please see below.
invite_revoked:
title: "{resource} invitation revoked"
message: "You have successfully revoked the invite for {user_name} from {resource_name}"
login_required_message: After you log in, you will be redirected to your destination page. login_required_message: After you log in, you will be redirected to your destination page.
login_required_title: Log in required login_required_title: Log in required
logged_out: logged_out: