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/418b52c1cedf_change_to_environment_roles_cloud_id.py b/alembic/versions/418b52c1cedf_change_to_environment_roles_cloud_id.py index 6342b8ca..8d522769 100644 --- a/alembic/versions/418b52c1cedf_change_to_environment_roles_cloud_id.py +++ b/alembic/versions/418b52c1cedf_change_to_environment_roles_cloud_id.py @@ -1,7 +1,7 @@ """change to environment_roles.cloud_Id Revision ID: 418b52c1cedf -Revises: 0039308c6351 +Revises: 567bfb019a87 Create Date: 2020-02-05 13:40:37.870183 """ @@ -11,7 +11,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '418b52c1cedf' # pragma: allowlist secret -down_revision = '0039308c6351' # pragma: allowlist secret +down_revision = '567bfb019a87' # pragma: allowlist secret branch_labels = None depends_on = None 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/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/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 e100dda3..09c44dbf 100644 --- a/atst/jobs.py +++ b/atst/jobs.py @@ -1,5 +1,7 @@ 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.domain.application_roles import ApplicationRoles @@ -16,8 +18,10 @@ from atst.domain.environments import Environments from atst.domain.environment_roles import EnvironmentRoles from atst.domain.portfolios import Portfolios from atst.models import CSPRole, JobFailure +from atst.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 class RecordFailure(celery.Task): @@ -45,8 +49,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) @@ -244,3 +248,39 @@ def dispatch_create_environment(self): pendulum.now() ): create_environment.delay(environment_id=environment_id) + + +@celery.task(bind=True) +def dispatch_create_atat_admin_user(self): + for environment_id in Environments.get_environments_pending_atat_user_creation( + 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 cd202fd0..6eb0c02d 100644 --- a/atst/models/environment.py +++ b/atst/models/environment.py @@ -61,6 +61,10 @@ class Environment( def portfolio_id(self): return self.application.portfolio_id + @property + def is_pending(self): + return self.cloud_id is None + def __repr__(self): return "".format( self.name, 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/routes/applications/settings.py b/atst/routes/applications/settings.py index ad8a6540..8807c7f1 100644 --- a/atst/routes/applications/settings.py +++ b/atst/routes/applications/settings.py @@ -39,6 +39,7 @@ def get_environments_obj_for_app(application): { "id": env.id, "name": env.name, + "pending": env.is_pending, "edit_form": EditEnvironmentForm(obj=env), "member_count": len(env.roles), "members": sorted( 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/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/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/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 372fa868..09b838f3 100644 --- a/styles/core/_variables.scss +++ b/styles/core/_variables.scss @@ -20,6 +20,7 @@ $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 c2d11049..8d7dadef 100644 --- a/styles/elements/_action_group.scss +++ b/styles/elements/_action_group.scss @@ -42,6 +42,7 @@ border-top: 1px solid $color-gray-lighter; z-index: 1; width: 100%; + height: $action-footer-height; &.action-group-footer--expand-offset { padding-left: $sidenav-expanded-width; 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/_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 3841bf96..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) }}
diff --git a/templates/applications/new/step_2.html b/templates/applications/new/step_2.html index 2cd5cf98..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 }}
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/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"> - +