diff --git a/Dockerfile b/Dockerfile index 6f29d300..6dd7629a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -101,5 +101,7 @@ RUN mkdir /var/run/uwsgi && \ chown -R atst:atat /var/run/uwsgi && \ chown -R atst:atat "${APP_DIR}" +RUN update-ca-certificates + # Run as the unprivileged APP user USER atst diff --git a/alembic/versions/0039308c6351_remove_root_user_info_from_environment.py b/alembic/versions/0039308c6351_remove_root_user_info_from_environment.py new file mode 100644 index 00000000..e9f3fa8a --- /dev/null +++ b/alembic/versions/0039308c6351_remove_root_user_info_from_environment.py @@ -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 ### diff --git a/alembic/versions/567bfb019a87_add_last_sent_column_to_clins_and_pdf_.py b/alembic/versions/567bfb019a87_add_last_sent_column_to_clins_and_pdf_.py new file mode 100644 index 00000000..e56997ee --- /dev/null +++ b/alembic/versions/567bfb019a87_add_last_sent_column_to_clins_and_pdf_.py @@ -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 ### diff --git a/atst/domain/csp/cloud/azure_cloud_provider.py b/atst/domain/csp/cloud/azure_cloud_provider.py index d5ef5204..1d921fde 100644 --- a/atst/domain/csp/cloud/azure_cloud_provider.py +++ b/atst/domain/csp/cloud/azure_cloud_provider.py @@ -6,12 +6,12 @@ from uuid import uuid4 from atst.utils import sha256_hex from .cloud_provider_interface import CloudProviderInterface -from .exceptions import AuthenticationException, UserProvisioningException +from .exceptions import ( + AuthenticationException, + SecretException, + UserProvisioningException, +) from .models import ( - SubscriptionCreationCSPPayload, - SubscriptionCreationCSPResult, - SubscriptionVerificationCSPPayload, - SuscriptionVerificationCSPResult, AdminRoleDefinitionCSPPayload, AdminRoleDefinitionCSPResult, ApplicationCSPPayload, @@ -24,14 +24,21 @@ from .models import ( BillingProfileTenantAccessCSPResult, BillingProfileVerificationCSPPayload, BillingProfileVerificationCSPResult, + CostManagementQueryCSPResult, + EnvironmentCSPPayload, + EnvironmentCSPResult, KeyVaultCredentials, - ManagementGroupCSPResponse, + PrincipalAdminRoleCSPPayload, + PrincipalAdminRoleCSPResult, ProductPurchaseCSPPayload, ProductPurchaseCSPResult, ProductPurchaseVerificationCSPPayload, ProductPurchaseVerificationCSPResult, - PrincipalAdminRoleCSPPayload, - PrincipalAdminRoleCSPResult, + ReportingCSPPayload, + SubscriptionCreationCSPPayload, + SubscriptionCreationCSPResult, + SubscriptionVerificationCSPPayload, + SuscriptionVerificationCSPResult, TaskOrderBillingCreationCSPPayload, TaskOrderBillingCreationCSPResult, TaskOrderBillingVerificationCSPPayload, @@ -53,7 +60,6 @@ from .models import ( ) from .policy import AzurePolicyManager - # 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 AZURE_SKU_ID = "0001" # probably a static sku specific to ATAT/JEDI @@ -116,11 +122,15 @@ class AzureCloudProvider(CloudProviderInterface): ) try: return secret_client.set_secret(secret_key, secret_value) - except self.exceptions.HttpResponseError: + except self.sdk.exceptions.HttpResponseError as exc: app.logger.error( f"Could not SET secret in Azure keyvault for key {secret_key}.", 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): credential = self._get_client_secret_credential_obj() @@ -129,67 +139,35 @@ class AzureCloudProvider(CloudProviderInterface): ) try: return secret_client.get_secret(secret_key).value - except self.exceptions.HttpResponseError: + except self.sdk.exceptions.HttpResponseError: app.logger.error( f"Could not GET secret in Azure keyvault for key {secret_key}.", 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): - # since this operation would only occur within a tenant, should we source the tenant - # via lookup from environment once we've created the portfolio csp data schema - # something like this: - # environment_tenant = environment.application.portfolio.csp_data.get('tenant_id', None) - # though we'd probably source the whole credentials for these calls from the portfolio csp - # data, as it would have to be where we store the creds for the at-at user within the portfolio tenant - # credentials = self._get_credential_obj(environment.application.portfolio.csp_data.get_creds()) - credentials = self._get_credential_obj(self._root_creds) - 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, + def create_environment(self, payload: EnvironmentCSPPayload): + creds = self._source_creds(payload.tenant_id) + credentials = self._get_credential_obj( + { + "client_id": creds.tenant_sp_client_id, + "secret_key": creds.tenant_sp_key, + "tenant_id": creds.tenant_id, + }, + resource=self.sdk.cloud.endpoints.resource_manager, ) - return ManagementGroupCSPResponse(**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( + response = self._create_management_group( credentials, - # TODO: Determine which subscription this needs to point at - # Once we're in a multi-sub environment - subscription.id, + payload.management_group_name, + payload.display_name, + payload.parent_id, ) - # Create role assignment for - 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, - } + return EnvironmentCSPResult(**response) def create_application(self, payload: ApplicationCSPPayload): creds = self._source_creds(payload.tenant_id) @@ -798,17 +776,6 @@ class AzureCloudProvider(CloudProviderInterface): 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): # we really should be using graph.microsoft.com, but i'm getting # "expired token" errors for that @@ -1070,3 +1037,41 @@ class AzureCloudProvider(CloudProviderInterface): hashed = sha256_hex(tenant_id) raw_creds = self.get_secret(hashed) 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()) diff --git a/atst/domain/csp/cloud/cloud_provider_interface.py b/atst/domain/csp/cloud/cloud_provider_interface.py index d173396a..88b55f96 100644 --- a/atst/domain/csp/cloud/cloud_provider_interface.py +++ b/atst/domain/csp/cloud/cloud_provider_interface.py @@ -11,7 +11,7 @@ class CloudProviderInterface: def root_creds(self) -> Dict: raise NotImplementedError() - def create_environment(self, auth_credentials: Dict, user, environment) -> str: + def create_environment(self, payload): """Create a new environment in the CSP. Arguments: @@ -31,33 +31,6 @@ class CloudProviderInterface: """ 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( self, auth_credentials: Dict, user_info, csp_role_id: str ) -> str: diff --git a/atst/domain/csp/cloud/exceptions.py b/atst/domain/csp/cloud/exceptions.py index 49b05fb4..3480180f 100644 --- a/atst/domain/csp/cloud/exceptions.py +++ b/atst/domain/csp/cloud/exceptions.py @@ -118,3 +118,17 @@ class BaselineProvisionException(GeneralCSPException): return "Could not complete baseline provisioning for environment ({}): {}".format( 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 + ) diff --git a/atst/domain/csp/cloud/mock_cloud_provider.py b/atst/domain/csp/cloud/mock_cloud_provider.py index ec730a3b..7ec0636f 100644 --- a/atst/domain/csp/cloud/mock_cloud_provider.py +++ b/atst/domain/csp/cloud/mock_cloud_provider.py @@ -4,9 +4,7 @@ from .cloud_provider_interface import CloudProviderInterface from .exceptions import ( AuthenticationException, AuthorizationException, - BaselineProvisionException, ConnectionException, - EnvironmentCreationException, GeneralCSPException, UnknownServerException, UserProvisioningException, @@ -25,16 +23,21 @@ from .models import ( BillingProfileTenantAccessCSPResult, BillingProfileVerificationCSPPayload, BillingProfileVerificationCSPResult, + CostManagementQueryCSPResult, + CostManagementQueryProperties, ProductPurchaseCSPPayload, ProductPurchaseCSPResult, ProductPurchaseVerificationCSPPayload, ProductPurchaseVerificationCSPResult, PrincipalAdminRoleCSPPayload, PrincipalAdminRoleCSPResult, + ReportingCSPPayload, SubscriptionCreationCSPPayload, SubscriptionCreationCSPResult, SubscriptionVerificationCSPPayload, SuscriptionVerificationCSPResult, + EnvironmentCSPPayload, + EnvironmentCSPResult, TaskOrderBillingCreationCSPPayload, TaskOrderBillingCreationCSPResult, TaskOrderBillingVerificationCSPPayload, @@ -91,34 +94,6 @@ class MockCloudProvider(CloudProviderInterface): def get_secret(self, secret_key: str, default=dict()): 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): return self.create_subscription_creation(payload) @@ -142,23 +117,6 @@ class MockCloudProvider(CloudProviderInterface): 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): """ payload is an instance of TenantCSPPayload data class @@ -477,6 +435,13 @@ class MockCloudProvider(CloudProviderInterface): 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): self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException) @@ -487,3 +452,25 @@ class MockCloudProvider(CloudProviderInterface): def update_tenant_creds(self, tenant_id, 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,) + ) diff --git a/atst/domain/csp/cloud/models.py b/atst/domain/csp/cloud/models.py index 188c2cc7..358f7934 100644 --- a/atst/domain/csp/cloud/models.py +++ b/atst/domain/csp/cloud/models.py @@ -358,6 +358,14 @@ class ApplicationCSPResult(ManagementGroupCSPResponse): pass +class EnvironmentCSPPayload(ManagementGroupCSPPayload): + pass + + +class EnvironmentCSPResult(ManagementGroupCSPResponse): + pass + + class KeyVaultCredentials(BaseModel): root_sp_client_id: Optional[str] root_sp_key: Optional[str] @@ -499,3 +507,34 @@ class UserCSPPayload(BaseCSPPayload): class UserCSPResult(AliasModel): 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") diff --git a/atst/domain/csp/files.py b/atst/domain/csp/files.py index aade1775..0f3e05a0 100644 --- a/atst/domain/csp/files.py +++ b/atst/domain/csp/files.py @@ -43,10 +43,12 @@ class AzureFileService(FileService): from azure.storage.common import CloudStorageAccount from azure.storage.blob import BlobSasPermissions + from azure.storage.blob.models import BlobPermissions from azure.storage.blob.blockblobservice import BlockBlobService self.CloudStorageAccount = CloudStorageAccount self.BlobSasPermissions = BlobSasPermissions + self.BlobPermissions = BlobPermissions self.BlockBlobService = BlockBlobService def get_token(self): @@ -72,20 +74,22 @@ class AzureFileService(FileService): return ({"token": sas_token}, object_name) 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 ) - bbs = account.create_block_blob_service() - sas_token = bbs.generate_blob_shared_access_signature( - self.container_name, - object_name, - permission=self.BlobSasPermissions(read=True), + sas_token = block_blob_service.generate_blob_shared_access_signature( + container_name=self.container_name, + blob_name=object_name, + permission=self.BlobPermissions(read=True), expiry=datetime.utcnow() + self.timeout, content_disposition=f"attachment; filename={filename}", protocol="https", ) - return bbs.make_blob_url( - self.container_name, object_name, protocol="https", sas_token=sas_token + return block_blob_service.make_blob_url( + container_name=self.container_name, + blob_name=object_name, + protocol="https", + sas_token=sas_token, ) def download_task_order(self, object_name): diff --git a/atst/domain/environment_roles.py b/atst/domain/environment_roles.py index ef8a4b8e..f0b600c6 100644 --- a/atst/domain/environment_roles.py +++ b/atst/domain/environment_roles.py @@ -106,9 +106,11 @@ class EnvironmentRoles(object): def disable(cls, 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: - credentials = environment_role.environment.csp_credentials - app.csp.cloud.disable_user(credentials, environment_role.csp_user_id) + if environment_role.csp_user_id and not environment_role.environment.cloud_id: + tenant_id = environment_role.environment.application.portfolio.csp_data.get( + "tenant_id" + ) + app.csp.cloud.disable_user(tenant_id, environment_role.csp_user_id) environment_role.status = EnvironmentRole.Status.DISABLED db.session.add(environment_role) diff --git a/atst/domain/environments.py b/atst/domain/environments.py index b8a59485..b43ae495 100644 --- a/atst/domain/environments.py +++ b/atst/domain/environments.py @@ -124,18 +124,9 @@ class Environments(object): Any environment with an active CLIN that doesn't yet have a `cloud_id`. """ 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] - - @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] diff --git a/atst/domain/portfolios/portfolios.py b/atst/domain/portfolios/portfolios.py index 1254ac71..b8663730 100644 --- a/atst/domain/portfolios/portfolios.py +++ b/atst/domain/portfolios/portfolios.py @@ -15,6 +15,8 @@ from atst.models import ( Permissions, PortfolioRole, PortfolioRoleStatus, + TaskOrder, + CLIN, ) from .query import PortfoliosQuery, PortfolioStateMachinesQuery @@ -144,7 +146,7 @@ class Portfolios(object): return db.session.query(Portfolio.id) @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: not started yet, @@ -153,22 +155,18 @@ class Portfolios(object): """ results = ( - cls.base_provision_query() + db.session.query(Portfolio.id) .join(PortfolioStateMachine) + .join(TaskOrder) + .join(CLIN) + .filter(Portfolio.deleted == False) + .filter(CLIN.start_date <= now) + .filter(CLIN.end_date > now) .filter( or_( PortfolioStateMachine.state == FSMStates.UNSTARTED, - PortfolioStateMachine.state == FSMStates.FAILED, - PortfolioStateMachine.state == FSMStates.TENANT_FAILED, + PortfolioStateMachine.state.like("%CREATED"), ) ) ) return [id_ for id_, in results] - - # db.session.query(PortfolioStateMachine).\ - # filter( - # or_( - # PortfolioStateMachine.state==FSMStates.UNSTARTED, - # PortfolioStateMachine.state==FSMStates.UNSTARTED, - # ) - # ).all() diff --git a/atst/domain/task_orders.py b/atst/domain/task_orders.py index 9ecf41e9..499bccb0 100644 --- a/atst/domain/task_orders.py +++ b/atst/domain/task_orders.py @@ -1,4 +1,5 @@ -import datetime +from datetime import datetime +from sqlalchemy import or_ from atst.database import db from atst.models.clin import CLIN @@ -40,7 +41,7 @@ class TaskOrders(BaseDomainClass): @classmethod def sign(cls, task_order, 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.commit() @@ -76,3 +77,17 @@ class TaskOrders(BaseDomainClass): task_order = TaskOrders.get(task_order_id) db.session.delete(task_order) 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() + ) diff --git a/atst/jobs.py b/atst/jobs.py index 986b2004..6a12d423 100644 --- a/atst/jobs.py +++ b/atst/jobs.py @@ -1,18 +1,25 @@ -from flask import current_app as app 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.queue import celery -from atst.models import JobFailure -from atst.domain.csp.cloud.exceptions import GeneralCSPException -from atst.domain.csp.cloud import CloudProviderInterface +from atst.domain.application_roles import ApplicationRoles 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.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.queue import celery from atst.utils.localization import translate -from atst.domain.csp.cloud.models import ApplicationCSPPayload, UserCSPPayload class RecordFailure(celery.Task): @@ -40,8 +47,8 @@ class RecordFailure(celery.Task): @celery.task(ignore_result=True) -def send_mail(recipients, subject, body): - app.mailer.send(recipients, subject, body) +def send_mail(recipients, subject, body, attachments=[]): + app.mailer.send(recipients, subject, body, attachments) @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: if environment.cloud_id is not None: - # TODO: Return value for this? return - user = environment.creator - - # we'll need to do some checking in this job for cases where it's retrying - # when a failure occured after some successful steps - # (e.g. if environment.cloud_id is not None, then we can skip first step) - - # 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 + csp_details = environment.application.portfolio.csp_data + parent_id = environment.application.cloud_id + tenant_id = csp_details.get("tenant_id") + payload = EnvironmentCSPPayload( + tenant_id=tenant_id, display_name=environment.name, parent_id=parent_id ) - -def do_create_atat_admin_user(csp: CloudProviderInterface, environment_id=None): - 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 + env_result = csp.create_environment(payload) + environment.cloud_id = env_result.id db.session.add(environment) 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) -@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) def dispatch_provision_portfolio(self): """ 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) @@ -233,3 +205,31 @@ def dispatch_create_atat_admin_user(self): pendulum.now() ): 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() diff --git a/atst/models/clin.py b/atst/models/clin.py index 2811bd6a..13a63cee 100644 --- a/atst/models/clin.py +++ b/atst/models/clin.py @@ -1,5 +1,13 @@ 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 datetime import date @@ -29,6 +37,7 @@ class CLIN(Base, mixins.TimestampsMixin): total_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) + last_sent_at = Column(DateTime) # # NOTE: For now obligated CLINS are CLIN 1 + CLIN 3 diff --git a/atst/models/environment.py b/atst/models/environment.py index b9d16fe0..6eb0c02d 100644 --- a/atst/models/environment.py +++ b/atst/models/environment.py @@ -1,11 +1,9 @@ from sqlalchemy import Column, ForeignKey, String, UniqueConstraint 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.types as types +from atst.models.base import Base class Environment( @@ -30,7 +28,6 @@ class Environment( creator = relationship("User") cloud_id = Column(String) - root_user_info = Column(JSONB(none_as_null=True)) roles = relationship( "EnvironmentRole", @@ -44,10 +41,6 @@ class Environment( ), ) - class ProvisioningStatus(Enum): - PENDING = "pending" - COMPLETED = "completed" - @property def users(self): return {r.application_role.user for r in self.roles} @@ -68,16 +61,9 @@ class Environment( def portfolio_id(self): 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 def is_pending(self): - return self.provisioning_status == self.ProvisioningStatus.PENDING + return self.cloud_id is None def __repr__(self): return "".format( @@ -91,11 +77,3 @@ class Environment( @property def history(self): 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 - ) diff --git a/atst/models/portfolio_state_machine.py b/atst/models/portfolio_state_machine.py index 14e9c01d..f5c1a461 100644 --- a/atst/models/portfolio_state_machine.py +++ b/atst/models/portfolio_state_machine.py @@ -175,11 +175,14 @@ class PortfolioStateMachine( app.logger.info(exc.json()) print(exc.json()) app.logger.info(payload_data) + # TODO: Ensure that failing the stage does not preclude a Celery retry self.fail_stage(stage) + # TODO: catch and handle general CSP exception here except (ConnectionException, UnknownServerException) as exc: app.logger.error( 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.finish_stage(stage) diff --git a/atst/models/task_order.py b/atst/models/task_order.py index 789a7e3f..d6aa63f8 100644 --- a/atst/models/task_order.py +++ b/atst/models/task_order.py @@ -39,6 +39,7 @@ class TaskOrder(Base, mixins.TimestampsMixin): pdf_attachment_id = Column(ForeignKey("attachments.id")) _pdf = relationship("Attachment", foreign_keys=[pdf_attachment_id]) + pdf_last_sent_at = Column(DateTime) number = Column(String, unique=True,) # Task Order Number signer_dod_id = Column(String) signed_at = Column(DateTime) diff --git a/atst/queue.py b/atst/queue.py index 3f97f88c..0ea910fb 100644 --- a/atst/queue.py +++ b/atst/queue.py @@ -19,10 +19,6 @@ def update_celery(celery, app): "task": "atst.jobs.dispatch_create_environment", "schedule": 60, }, - "beat-dispatch_create_atat_admin_user": { - "task": "atst.jobs.dispatch_create_atat_admin_user", - "schedule": 60, - }, "beat-dispatch_create_user": { "task": "atst.jobs.dispatch_create_user", "schedule": 60, diff --git a/atst/routes/applications/settings.py b/atst/routes/applications/settings.py index 8b744b04..8807c7f1 100644 --- a/atst/routes/applications/settings.py +++ b/atst/routes/applications/settings.py @@ -467,9 +467,10 @@ def revoke_invite(application_id, application_role_id): if invite.is_pending: ApplicationInvitations.revoke(invite.token) flash( - "application_invite_revoked", + "invite_revoked", + resource="Application", user_name=app_role.user_name, - application_name=g.application.name, + resource_name=g.application.name, ) else: flash( diff --git a/atst/routes/portfolios/invitations.py b/atst/routes/portfolios/invitations.py index 9ec56aa3..80375e75 100644 --- a/atst/routes/portfolios/invitations.py +++ b/atst/routes/portfolios/invitations.py @@ -37,8 +37,14 @@ def accept_invitation(portfolio_token): ) @user_can(Permissions.EDIT_PORTFOLIO_USERS, message="revoke invitation") 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( url_for( "portfolios.admin", diff --git a/atst/utils/context_processors.py b/atst/utils/context_processors.py index 7d39b367..5bb4771d 100644 --- a/atst/utils/context_processors.py +++ b/atst/utils/context_processors.py @@ -19,6 +19,9 @@ from atst.models import ( def get_resources_from_context(view_args): query = None + if view_args is None: + view_args = {} + if "portfolio_token" in view_args: query = ( db.session.query(Portfolio) diff --git a/atst/utils/flash.py b/atst/utils/flash.py index ea85f1ef..b7ca0cb9 100644 --- a/atst/utils/flash.py +++ b/atst/utils/flash.py @@ -33,11 +33,6 @@ MESSAGES = { "message": "flash.application_invite.resent.message", "category": "success", }, - "application_invite_revoked": { - "title": "flash.application_invite.revoked.title", - "message": "flash.application_invite.revoked.message", - "category": "success", - }, "application_member_removed": { "title": "flash.application_member.removed.title", "message": "flash.application_member.removed.message", @@ -103,6 +98,11 @@ MESSAGES = { "message": None, "category": "warning", }, + "invite_revoked": { + "title": "flash.invite_revoked.title", + "message": "flash.invite_revoked.message", + "category": "success", + }, "logged_out": { "title": "flash.logged_out.title", "message": "flash.logged_out.message", diff --git a/js/components/sidenav_toggler.js b/js/components/sidenav_toggler.js index faba4c3b..11717849 100644 --- a/js/components/sidenav_toggler.js +++ b/js/components/sidenav_toggler.js @@ -1,30 +1,21 @@ +import ExpandSidenavMixin from '../mixins/expand_sidenav' import ToggleMixin from '../mixins/toggle' -const cookieName = 'expandSidenav' - export default { name: 'sidenav-toggler', - mixins: [ToggleMixin], + mixins: [ExpandSidenavMixin, ToggleMixin], - props: { - defaultVisible: { - type: Boolean, - default: function() { - if (document.cookie.match(cookieName)) { - return !!document.cookie.match(cookieName + ' *= *true') - } else { - return true - } - }, - }, + mounted: function() { + this.$parent.$emit('sidenavToggle', this.isVisible) }, methods: { toggle: function(e) { e.preventDefault() this.isVisible = !this.isVisible - document.cookie = cookieName + '=' + this.isVisible + '; path=/' + document.cookie = this.cookieName + '=' + this.isVisible + '; path=/' + this.$parent.$emit('sidenavToggle', this.isVisible) }, }, } diff --git a/js/components/upload_input.js b/js/components/upload_input.js index 9856405b..4f9f06fc 100644 --- a/js/components/upload_input.js +++ b/js/components/upload_input.js @@ -17,7 +17,7 @@ export default { filename: { type: String, }, - objectName: { + initialObjectName: { type: String, }, initialErrors: { @@ -42,6 +42,7 @@ export default { filenameError: false, downloadLink: '', fileSizeLimit: this.sizeLimit, + objectName: this.initialObjectName, } }, @@ -72,6 +73,7 @@ export default { const response = await uploader.upload(file) if (uploadResponseOkay(response)) { this.attachment = e.target.value + this.objectName = uploader.objectName this.$refs.attachmentFilename.value = file.name this.$refs.attachmentObjectName.value = response.objectName this.$refs.attachmentInput.disabled = true diff --git a/js/index.js b/js/index.js index fb5cdd6e..6495268b 100644 --- a/js/index.js +++ b/js/index.js @@ -32,12 +32,14 @@ import ToForm from './components/forms/to_form' import ClinFields from './components/clin_fields' import PopDateRange from './components/pop_date_range' import ToggleMenu from './components/toggle_menu' +import ExpandSidenav from './mixins/expand_sidenav' Vue.config.productionTip = false Vue.use(VTooltip) Vue.mixin(Modal) +Vue.mixin(ExpandSidenav) const app = new Vue({ el: '#app-root', @@ -67,6 +69,12 @@ const app = new Vue({ ToggleMenu, }, + data: function() { + return { + sidenavExpanded: this.defaultVisible, + } + }, + mounted: function() { this.$on('modalOpen', data => { if (data['isOpen']) { @@ -105,6 +113,10 @@ const app = new Vue({ } }) }) + + this.$on('sidenavToggle', data => { + this.sidenavExpanded = data + }) }, delimiters: ['!{', '}'], diff --git a/js/lib/input_validations.js b/js/lib/input_validations.js index 9f113aa6..19b3d350 100644 --- a/js/lib/input_validations.js +++ b/js/lib/input_validations.js @@ -9,6 +9,12 @@ export default { unmask: [], 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: { mask: false, match: /^\d{4}$/, @@ -42,14 +48,14 @@ export default { }, defaultStringField: { mask: false, - match: /^[A-Za-z0-9\-_ \.]{1,100}$/, + match: /^[A-Za-z0-9\-_,'".\s]{1,1000}$/, unmask: [], validationError: 'Please enter a response of no more than 100 alphanumeric characters', }, defaultTextAreaField: { mask: false, - match: /^[A-Za-z0-9\-_ \.]{1,1000}$/, + match: /^[A-Za-z0-9\-_,'".\s]{1,1000}$/, unmask: [], validationError: 'Please enter a response of no more than 1000 alphanumeric characters', @@ -94,7 +100,7 @@ export default { }, portfolioName: { mask: false, - match: /^.{4,100}$/, + match: /^[A-Za-z0-9\-_,'".\s]{4,100}$$/, unmask: [], validationError: 'Portfolio names can be between 4-100 characters', }, diff --git a/js/mixins/expand_sidenav.js b/js/mixins/expand_sidenav.js new file mode 100644 index 00000000..7553b7d4 --- /dev/null +++ b/js/mixins/expand_sidenav.js @@ -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 + } + }, + }, + }, +} diff --git a/styles/atat.scss b/styles/atat.scss index 72c7af40..8eb73473 100644 --- a/styles/atat.scss +++ b/styles/atat.scss @@ -47,3 +47,4 @@ @import "sections/application_edit"; @import "sections/reports"; @import "sections/task_order"; +@import "sections/ccpo"; diff --git a/styles/components/_alerts.scss b/styles/components/_alerts.scss index be326807..eb62a756 100644 --- a/styles/components/_alerts.scss +++ b/styles/components/_alerts.scss @@ -11,6 +11,7 @@ .usa-alert { padding-bottom: 2.4rem; + max-width: $max-panel-width; } @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); - } - } - } -} diff --git a/styles/components/_error_page.scss b/styles/components/_error_page.scss index 19ae7531..683b527f 100644 --- a/styles/components/_error_page.scss +++ b/styles/components/_error_page.scss @@ -1,8 +1,13 @@ .error-page { - max-width: 475px; - margin: auto; + max-width: $max-page-width; .panel { + box-shadow: none; + background-color: unset; + border: none; + max-width: 475px; + margin: auto; + &__heading { text-align: center; padding: $gap 0; @@ -15,17 +20,6 @@ margin-bottom: $gap; } } - - &__body { - padding: $gap * 2; - margin: 0; - - hr { - width: 80%; - margin: auto; - margin-bottom: $gap * 3; - } - } } .icon { diff --git a/styles/components/_topbar.scss b/styles/components/_topbar.scss index 6d84f426..feca57b6 100644 --- a/styles/components/_topbar.scss +++ b/styles/components/_topbar.scss @@ -12,7 +12,7 @@ flex-direction: row; align-items: stretch; justify-content: space-between; - max-width: 1190px; + max-width: $max-page-width; a { color: $color-white; diff --git a/styles/core/_util.scss b/styles/core/_util.scss index 0790a121..7fd8e152 100644 --- a/styles/core/_util.scss +++ b/styles/core/_util.scss @@ -98,3 +98,7 @@ hr { .usa-section { padding: 0; } + +.form { + margin-bottom: $action-footer-height + $large-spacing; +} diff --git a/styles/core/_variables.scss b/styles/core/_variables.scss index 12657ca4..09b838f3 100644 --- a/styles/core/_variables.scss +++ b/styles/core/_variables.scss @@ -19,6 +19,8 @@ $sidenav-collapsed-width: 10rem; $max-panel-width: 90rem; $home-pg-icon-width: 6rem; $large-spacing: 4rem; +$max-page-width: $max-panel-width + $sidenav-expanded-width + $large-spacing; +$action-footer-height: 6rem; /* * USWDS Variables diff --git a/styles/elements/_action_group.scss b/styles/elements/_action_group.scss index fe375f67..8d7dadef 100644 --- a/styles/elements/_action_group.scss +++ b/styles/elements/_action_group.scss @@ -32,22 +32,36 @@ } .action-group-footer { - @extend .action-group; - - &:last-child { - margin-bottom: 0; - } - margin-top: 0; - margin-bottom: 0; padding-top: $gap; padding-bottom: $gap; - + padding-right: $gap * 4; position: fixed; bottom: $footer-height; + left: 0; background: white; - right: 0; - padding-right: $gap * 4; border-top: 1px solid $color-gray-lighter; - width: 100%; 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; + } + } } diff --git a/styles/elements/_inputs.scss b/styles/elements/_inputs.scss index 195d0a2b..9e74ff50 100644 --- a/styles/elements/_inputs.scss +++ b/styles/elements/_inputs.scss @@ -228,6 +228,7 @@ &--validation { &--anything, + &--applicationName, &--portfolioName, &--requiredField, &--defaultStringField, diff --git a/styles/sections/_ccpo.scss b/styles/sections/_ccpo.scss new file mode 100644 index 00000000..4033ac2e --- /dev/null +++ b/styles/sections/_ccpo.scss @@ -0,0 +1,3 @@ +.ccpo-panel-container { + max-width: $max-panel-width; +} diff --git a/styles/sections/_task_order.scss b/styles/sections/_task_order.scss index 05b90595..79f391e0 100644 --- a/styles/sections/_task_order.scss +++ b/styles/sections/_task_order.scss @@ -1,6 +1,5 @@ .task-order { margin-top: $gap * 4; - margin-bottom: $footer-height; width: 900px; &__amount { diff --git a/templates/applications/fragments/environments.html b/templates/applications/fragments/environments.html index d0934268..5b4be1de 100644 --- a/templates/applications/fragments/environments.html +++ b/templates/applications/fragments/environments.html @@ -47,7 +47,7 @@ {{ env['name'] }} - {{ Label(type="pending_creation", classes='label--below')}} + {{ Label(type="pending_creation")}} {%- endif %} {% if user_can(permissions.EDIT_ENVIRONMENT) -%} {{ diff --git a/templates/applications/fragments/members.html b/templates/applications/fragments/members.html index 5cae077f..f60fcba8 100644 --- a/templates/applications/fragments/members.html +++ b/templates/applications/fragments/members.html @@ -14,7 +14,7 @@ action_new, action_update) %} -

+

{{ 'portfolios.applications.settings.team_members' | translate }}

@@ -22,7 +22,7 @@ {% include "fragments/flash.html" %} {% endif %} -
+
{% if not application.members %}

diff --git a/templates/applications/new/step_1.html b/templates/applications/new/step_1.html index d06ddee0..39656257 100644 --- a/templates/applications/new/step_1.html +++ b/templates/applications/new/step_1.html @@ -22,11 +22,11 @@ {% include "fragments/flash.html" %} -

+ {{ form.csrf_token }}
- {{ 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) }}
@@ -39,14 +39,18 @@
- - {% block next_button %} - {{ SaveButton(text=('portfolios.applications.new.step_1_button_text' | translate)) }} - {% endblock %} - - Cancel - - + diff --git a/templates/applications/new/step_2.html b/templates/applications/new/step_2.html index 462c0f46..fe07b44d 100644 --- a/templates/applications/new/step_2.html +++ b/templates/applications/new/step_2.html @@ -21,7 +21,7 @@


-
+
{{ 'portfolios.applications.environments_heading' | translate }}
@@ -58,20 +58,24 @@ {{ Icon("plus") }}
+
+
+
+ - - - {% block next_button %} - {{ SaveButton(text=('portfolios.applications.new.step_2_button_text' | translate)) }} - {% endblock %} - - Previous - - - Cancel - - diff --git a/templates/applications/new/step_3.html b/templates/applications/new/step_3.html index d88a704c..35af10fa 100644 --- a/templates/applications/new/step_3.html +++ b/templates/applications/new/step_3.html @@ -25,16 +25,20 @@ action_update="applications.update_new_application_step_3") }} - - - {{ "portfolios.applications.new.step_3_button_text" | translate }} - - - {{ "common.previous" | translate }} - - - {{ "common.cancel" | translate }} - - + {% endblock %} diff --git a/templates/applications/settings.html b/templates/applications/settings.html index 2c641e27..1ec7be37 100644 --- a/templates/applications/settings.html +++ b/templates/applications/settings.html @@ -22,7 +22,7 @@
{{ 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) }}
{{ SaveButton(text='common.save_changes'|translate) }} diff --git a/templates/ccpo/add_user.html b/templates/ccpo/add_user.html index e8544a2b..eccd27a9 100644 --- a/templates/ccpo/add_user.html +++ b/templates/ccpo/add_user.html @@ -4,21 +4,23 @@ {% from "components/text_input.html" import TextInput %} {% block content %} - - - {{ form.csrf_token }} -

{{ "ccpo.form.add_user_title" | translate }}

-
-
- {{ TextInput(form.dod_id, validation='dodId', optional=False) }} -
-
-
- {{ SaveButton(text="common.next"|translate, element="input", additional_classes="action-group__action", form="add-ccpo-user-form") }} - {{ "common.cancel" | translate }} +
+ + + {{ form.csrf_token }} +

{{ "ccpo.form.add_user_title" | translate }}

+
+
+ {{ TextInput(form.dod_id, validation='dodId', optional=False) }} +
+
+
+ {{ SaveButton(text="common.next"|translate, element="input", additional_classes="action-group__action", form="add-ccpo-user-form") }} + {{ "common.cancel" | translate }} +
-
- - + + +
{% endblock %} diff --git a/templates/ccpo/confirm_user.html b/templates/ccpo/confirm_user.html index dfe30bca..a45abd3c 100644 --- a/templates/ccpo/confirm_user.html +++ b/templates/ccpo/confirm_user.html @@ -3,31 +3,33 @@ {% from "components/text_input.html" import TextInput %} {% block content %} - {% if new_user %} -

{{ 'ccpo.form.confirm_user_title' | translate }}

-
- {{ form.csrf_token }} - -
-

- {{ "ccpo.form.confirm_user_text" | translate }} -

-

- {{ new_user.full_name }} -

-

- {{ new_user.email }} -

-
- -
- {% endif %} +
+ {% if new_user %} +

{{ 'ccpo.form.confirm_user_title' | translate }}

+
+ {{ form.csrf_token }} + +
+

+ {{ "ccpo.form.confirm_user_text" | translate }} +

+

+ {{ new_user.full_name }} +

+

+ {{ new_user.email }} +

+
+ +
+ {% endif %} +
{% endblock %} diff --git a/templates/ccpo/users.html b/templates/ccpo/users.html index c5c8cc3b..15e5d9fe 100644 --- a/templates/ccpo/users.html +++ b/templates/ccpo/users.html @@ -6,78 +6,80 @@ {% from "components/modal.html" import Modal %} {% block content %} -
-
- {{ "ccpo.users_title" | translate }} -
+
+
+
+ {{ "ccpo.users_title" | translate }} +
- {% include "fragments/flash.html" %} - - - - - - - - {% if user_can(permissions.DELETE_CCPO_USER) %} - - {% endif %} - - - - {% 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 %} + {% include "fragments/flash.html" %} +
{{ "common.name" | translate }}{{ "common.email" | translate }}{{ "common.dod_id" | translate }}
+ - - - + + + {% if user_can(permissions.DELETE_CCPO_USER) %} - + {% endif %} - {% endfor %} - -
{{ user.full_name }}{{ user.email }}{{ user.dod_id }}{{ "common.name" | translate }}{{ "common.email" | translate }}{{ "common.dod_id" | translate }} - - {{ "common.disable" | translate }} - -
+ + + {% 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 %} + + + {{ user.full_name }} + {{ user.email }} + {{ user.dod_id }} + {% if user_can(permissions.DELETE_CCPO_USER) %} + + + {{ "common.disable" | translate }} + + + {% endif %} + + {% endfor %} + + +
+ + {% if user_can(permissions.CREATE_CCPO_USER) %} + + {{ "ccpo.add_user" | translate }} {{ Icon("plus") }} + + {% 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) %} +

Disable CCPO User

+
+ {{ + 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 %}
- - {% if user_can(permissions.CREATE_CCPO_USER) %} - - {{ "ccpo.add_user" | translate }} {{ Icon("plus") }} - - {% 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) %} -

Disable CCPO User

-
- {{ - 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 %} diff --git a/templates/components/upload_input.html b/templates/components/upload_input.html index 4f4f307f..bd4cd73c 100644 --- a/templates/components/upload_input.html +++ b/templates/components/upload_input.html @@ -5,7 +5,7 @@ inline-template {% if not field.errors %} 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 %} v-bind:initial-errors='true' {% endif %} @@ -46,7 +46,7 @@ v-bind:value="attachment" type="file"> - +