diff --git a/README.md b/README.md index 6fc01982..33d537bd 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,33 @@ When the `DEBUG` environment variable is enabled and the app environment is not set to production, sent email messages are available at the `/messages` endpoint. Emails are not sent in development and test modes. +### File Uploads and Downloads + +Testing file uploads and downloads locally requires a few configuration options. + +In the flask config (`config/base.ini`, perhaps): + +``` +CSP= + +AWS_REGION_NAME="" +AWS_ACCESS_KEY="" +AWS_SECRET_KEY="" +AWS_BUCKET_NAME="" + +AZURE_STORAGE_KEY="" +AZURE_ACCOUNT_NAME="" +AZURE_TO_BUCKET_NAME="" +``` + +There are also some build-time configuration that are used by parcel. Add these to `.env.local`, and run `rm -r .cache/` before running `yarn build`: + +``` +CLOUD_PROVIDER= +AZURE_ACCOUNT_NAME="" +AZURE_CONTAINER_NAME="" +``` + ## Testing Tests require a test database: diff --git a/atst/domain/csp/file_uploads.py b/atst/domain/csp/file_uploads.py index be6f2cae..a437cdbe 100644 --- a/atst/domain/csp/file_uploads.py +++ b/atst/domain/csp/file_uploads.py @@ -6,7 +6,10 @@ class Uploader: def generate_token(self): pass - def object_name(self): + def generate_download_link(self, object_name, filename) -> (dict, str): + pass + + def object_name(self) -> str: return str(uuid4()) @@ -17,6 +20,9 @@ class MockUploader(Uploader): def get_token(self): return ({}, self.object_name()) + def generate_download_link(self, object_name, filename): + return "" + class AzureUploader(Uploader): def __init__(self, config): @@ -53,6 +59,23 @@ class AzureUploader(Uploader): ) return ({"token": sas_token}, object_name) + def generate_download_link(self, object_name, filename): + account = self.CloudStorageAccount( + 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.BlobPermissions.READ, + 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 + ) + class AwsUploader(Uploader): def __init__(self, config): @@ -87,7 +110,7 @@ class AwsUploader(Uploader): presigned_post = s3_client.generate_presigned_post( self.bucket_name, object_name, - ExpiresIn=3600, + ExpiresIn=self.timeout_secs, Conditions=[ ("eq", "$Content-Type", "application/pdf"), ("starts-with", "$x-amz-meta-filename", ""), @@ -95,3 +118,22 @@ class AwsUploader(Uploader): Fields={"Content-Type": "application/pdf", "x-amz-meta-filename": ""}, ) return (presigned_post, object_name) + + def generate_download_link(self, object_name, filename): + s3_client = self.boto3.client( + "s3", + aws_access_key_id=self.access_key_id, + aws_secret_access_key=self.secret_key, + config=self.boto3.session.Config( + signature_version="s3v4", region_name=self.region_name + ), + ) + return s3_client.generate_presigned_url( + "get_object", + Params={ + "Bucket": self.bucket_name, + "Key": object_name, + "ResponseContentDisposition": f"attachment; filename={filename}", + }, + ExpiresIn=self.timeout_secs, + ) diff --git a/atst/routes/task_orders/new.py b/atst/routes/task_orders/new.py index 201a6923..c284d3f1 100644 --- a/atst/routes/task_orders/new.py +++ b/atst/routes/task_orders/new.py @@ -17,8 +17,11 @@ from atst.models.permissions import Permissions from atst.utils.flash import formatted_flash as flash -def render_task_orders_edit(template, portfolio_id=None, task_order_id=None, form=None): - render_args = {} +def render_task_orders_edit( + template, portfolio_id=None, task_order_id=None, form=None, extra_args=None +): + render_args = extra_args or {} + if task_order_id: task_order = TaskOrders.get(task_order_id) portfolio_id = task_order.portfolio_id @@ -72,7 +75,7 @@ def update_task_order( ) -@task_orders_bp.route("/task_orders//upload-token") +@task_orders_bp.route("/task_orders//upload_token") @user_can(Permissions.CREATE_TASK_ORDER, message="edit task order form") def upload_token(portfolio_id): (token, object_name) = app.csp.files.get_token() @@ -81,6 +84,18 @@ def upload_token(portfolio_id): return jsonify(render_args) +@task_orders_bp.route("/task_orders//download_link") +@user_can(Permissions.VIEW_TASK_ORDER_DETAILS, message="view task order download link") +def download_link(portfolio_id): + filename = http_request.args.get("filename") + object_name = http_request.args.get("objectName") + render_args = { + "downloadLink": app.csp.files.generate_download_link(object_name, filename) + } + + return jsonify(render_args) + + @task_orders_bp.route("/task_orders//edit") @user_can(Permissions.CREATE_TASK_ORDER, message="edit task order form") def edit(task_order_id): @@ -219,11 +234,17 @@ def submit_form_step_three_add_clins(task_order_id): def form_step_four_review(task_order_id): task_order = TaskOrders.get(task_order_id) + extra_args = { + "pdf_download_url": app.csp.files.generate_download_link( + task_order.pdf.object_name, task_order.pdf.filename + ) + } + if task_order.is_completed == False: raise NoAccessError("task order form review") return render_task_orders_edit( - "task_orders/step_4.html", task_order_id=task_order_id + "task_orders/step_4.html", task_order_id=task_order_id, extra_args=extra_args ) diff --git a/js/components/__tests__/upload_input.test.js b/js/components/__tests__/upload_input.test.js index c867eea3..c2853cc7 100644 --- a/js/components/__tests__/upload_input.test.js +++ b/js/components/__tests__/upload_input.test.js @@ -8,7 +8,8 @@ const UploadWrapper = makeTestWrapper({ components: { uploadinput }, templatePath: 'upload_input_template.html', data: function() { - return { initialvalue: this.initialData.initialvalue, token: this.token } + const { filename, objectName } = this.initialData + return { filename, objectName } }, }) @@ -16,7 +17,7 @@ const UploadErrorWrapper = makeTestWrapper({ components: { uploadinput }, templatePath: 'upload_input_error_template.html', data: function() { - return { initialvalue: null, token: null } + return { filename: null, objectName: null } }, }) @@ -24,7 +25,7 @@ describe('UploadInput Test', () => { it('should show input and button when no attachment present', () => { const wrapper = mount(UploadWrapper, { propsData: { - initialData: { initialvalue: null, token: 'token' }, + initialData: {}, }, }) @@ -35,21 +36,24 @@ describe('UploadInput Test', () => { it('should show file name and hide input', () => { const wrapper = mount(UploadWrapper, { propsData: { - initialData: { initialvalue: 'somepdf.pdf', token: 'token' }, + initialData: { + filename: 'somepdf.pdf', + objectName: 'abcd', + }, }, }) const fileInput = wrapper.find('input[type=file]').element - const fileNameSpan = wrapper.find('.uploaded-file__name') + const fileNameLink = wrapper.find('.uploaded-file__name') expect(fileInput).toBe(undefined) - expect(fileNameSpan.html()).toContain('somepdf.pdf') + expect(fileNameLink.html()).toContain('somepdf.pdf') }) it('should correctly display error treatment', () => { const wrapper = mount(UploadErrorWrapper, { propsData: { - initialData: { initialvalue: 'somepdf.pdf', token: 'token' }, + initialData: { initialvalue: 'somepdf.pdf', objectName: 'abcd' }, }, }) diff --git a/js/components/upload_input.js b/js/components/upload_input.js index 428cb367..a0ad494c 100644 --- a/js/components/upload_input.js +++ b/js/components/upload_input.js @@ -17,7 +17,10 @@ export default { props: { name: String, - initialData: { + filename: { + type: String, + }, + objectName: { type: String, }, initialErrors: { @@ -38,11 +41,12 @@ export default { data: function() { return { - hasInitialData: !!this.initialData, - attachment: this.initialData || null, + hasInitialData: !!this.filename, + attachment: this.filename || null, changed: false, uploadError: false, sizeError: false, + downloadLink: '', } }, @@ -52,6 +56,13 @@ export default { name: this.name, valid: this.hasAttachment, }) + + if (this.hasInitialData) { + this.downloadLink = await this.getDownloadLink( + this.filename, + this.objectName + ) + } }, methods: { @@ -70,6 +81,10 @@ export default { this.attachment = e.target.value this.$refs.attachmentFilename.value = file.name this.$refs.attachmentObjectName.value = response.objectName + this.downloadLink = await this.getDownloadLink( + file.name, + response.objectName + ) } else { this.uploadError = true } @@ -104,12 +119,21 @@ export default { this.sizeError = false }, getUploader: async function() { - return fetch(`/task_orders/${this.portfolioId}/upload-token`, { + return fetch(`/task_orders/${this.portfolioId}/upload_token`, { credentials: 'include', }) .then(response => response.json()) .then(({ token, objectName }) => buildUploader(token, objectName)) }, + getDownloadLink: async function(filename, objectName) { + const { downloadLink } = await fetch( + `/task_orders/${ + this.portfolioId + }/download_link?filename=${filename}&objectName=${objectName}`, + { credentials: 'include' } + ).then(r => r.json()) + return downloadLink + }, }, computed: { diff --git a/js/lib/upload.js b/js/lib/upload.js index f2c5440b..50fe3edd 100644 --- a/js/lib/upload.js +++ b/js/lib/upload.js @@ -43,6 +43,14 @@ class AzureUploader { fileReader.readAsText(file) }) } + + downloadUrl(objectName) { + const blobService = Azure.createBlobServiceWithSas( + `https://${this.accountName}.blob.core.windows.net`, + this.sasToken + ) + return blobService.getUrl(this.containerName, objectName, this.sasToken) + } } class AwsUploader { @@ -58,6 +66,7 @@ class AwsUploader { }) form.append('file', file) form.set('x-amz-meta-filename', file.name) + form.set('Content-Type', 'application/pdf') const response = await fetch(this.presignedPost.url, { method: 'POST', diff --git a/js/test_templates/upload_input_error_template.html b/js/test_templates/upload_input_error_template.html index 50c1c3ab..ff3676ab 100644 --- a/js/test_templates/upload_input_error_template.html +++ b/js/test_templates/upload_input_error_template.html @@ -13,7 +13,7 @@ - + Remove
diff --git a/js/test_templates/upload_input_template.html b/js/test_templates/upload_input_template.html index d572cfe1..07fbb194 100644 --- a/js/test_templates/upload_input_template.html +++ b/js/test_templates/upload_input_template.html @@ -1,7 +1,8 @@
diff --git a/styles/elements/_uploader.scss b/styles/elements/_uploader.scss index d7f6bd9b..ec384f91 100644 --- a/styles/elements/_uploader.scss +++ b/styles/elements/_uploader.scss @@ -43,6 +43,11 @@ margin-left: 0.5rem; font-weight: $font-bold; text-decoration: underline; + color: $color-black-light !important; + + &:hover { + color: $color-black-light; + } } &__remove { diff --git a/templates/components/upload_input.html b/templates/components/upload_input.html index 41234b00..5d2bf737 100644 --- a/templates/components/upload_input.html +++ b/templates/components/upload_input.html @@ -4,7 +4,8 @@
{{ Icon("check-circle-solid") }} - + Remove
diff --git a/templates/fragments/task_order_review.html b/templates/fragments/task_order_review.html index cf1d276b..9b938106 100644 --- a/templates/fragments/task_order_review.html +++ b/templates/fragments/task_order_review.html @@ -13,7 +13,7 @@
{{ 'task_orders.review.pdf_title' | translate }}
- + {{ Icon('check-circle-solid') }} {{ task_order.pdf.filename }} diff --git a/tests/render_vue_component.py b/tests/render_vue_component.py index ab33303c..6c23ab9d 100644 --- a/tests/render_vue_component.py +++ b/tests/render_vue_component.py @@ -15,8 +15,8 @@ class InitialValueForm(Form): class TaskOrderPdfForm(Form): - filename = StringField(default="initialvalue") - object_name = StringField() + filename = StringField(default="filename") + object_name = StringField(default="objectName") errorfield = StringField( label="error", validators=[InputRequired(message="Test Error Message")]