From 779674ab600f82d98c1a80922d9affa1ba462121 Mon Sep 17 00:00:00 2001 From: Dan Funk Date: Fri, 13 Mar 2020 15:03:57 -0400 Subject: [PATCH] Add the ability to upload and request general reference files by name. These will be used across workflows and will frequently contain lookup tables that can be referenced by various script tasks. --- crc/api.yml | 61 ++++++++++++++++++++++ crc/api/file.py | 41 +++++++++++++++ crc/models/api_models.py | 1 + crc/models/file.py | 9 +++- crc/services/file_service.py | 66 +++++++++++++++++------- migrations/versions/0c8a2f8db28c_.py | 28 ++++++++++ tests/data/reference/irb_documents.xlsx | Bin 0 -> 26113 bytes tests/test_files_api.py | 51 ++++++++++++++++-- 8 files changed, 234 insertions(+), 23 deletions(-) create mode 100644 migrations/versions/0c8a2f8db28c_.py create mode 100644 tests/data/reference/irb_documents.xlsx diff --git a/crc/api.yml b/crc/api.yml index ab80f01d..b535310f 100644 --- a/crc/api.yml +++ b/crc/api.yml @@ -458,6 +458,67 @@ paths: format: binary example: '' # /v1.0/workflow/0 + /reference_file: + get: + operationId: crc.api.file.get_reference_files + summary: Provides a list of existing reference files that are available in the system. + tags: + - Files + responses: + '200': + description: An array of file descriptions (not the file content) + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/File" + /reference_file/{name}: + parameters: + - name: name + in: path + required: true + description: The special name of the reference file. + schema: + type: string + get: + operationId: crc.api.file.get_reference_file + summary: Reference files are called by name rather than by id. + tags: + - Files + responses: + '200': + description: Returns the actual file + content: + application/octet-stream: + schema: + type: string + format: binary + example: '' + put: + operationId: crc.api.file.set_reference_file + summary: Update the contents of a named reference file. + tags: + - Files + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: + '200': + description: Returns the actual file + content: + application/octet-stream: + schema: + type: string + format: binary + example: '' + # /v1.0/workflow/0 /workflow/{workflow_id}: parameters: - name: workflow_id diff --git a/crc/api/file.py b/crc/api/file.py index 5e4b7c48..ee2a2a82 100644 --- a/crc/api/file.py +++ b/crc/api/file.py @@ -1,3 +1,4 @@ +import enum import io import os from datetime import datetime @@ -21,6 +22,10 @@ def get_files(workflow_spec_id=None, study_id=None, workflow_id=None, task_id=No results = FileService.get_files(workflow_spec_id, study_id, workflow_id, task_id, form_field_key) return FileModelSchema(many=True).dump(results) +def get_reference_files(): + results = FileService.get_files(is_reference=True) + return FileModelSchema(many=True).dump(results) + def add_file(workflow_spec_id=None, study_id=None, workflow_id=None, task_id=None, form_field_key=None): all_none = all(v is None for v in [workflow_spec_id, study_id, workflow_id, task_id, form_field_key]) @@ -43,6 +48,42 @@ def add_file(workflow_spec_id=None, study_id=None, workflow_id=None, task_id=Non return FileModelSchema().dump(file_model) +def get_reference_file(name): + file_data = FileService.get_reference_file_data(name) + return send_file( + io.BytesIO(file_data.data), + attachment_filename=file_data.file_model.name, + mimetype=file_data.file_model.content_type, + cache_timeout=-1 # Don't cache these files on the browser. + ) + + +def set_reference_file(name): + """Uses the file service to manage reference-files. They will be used in script tasks to compute values.""" + if 'file' not in connexion.request.files: + raise ApiError('invalid_file', + 'Expected a file named "file" in the multipart form request', status_code=400) + + file = connexion.request.files['file'] + + name_extension = FileService.get_extension(name) + file_extension = FileService.get_extension(file.filename) + if name_extension != file_extension: + raise ApiError('invalid_file_type', + "The file you uploaded has an extension '%s', but it should have an extension of '%s' " % + (file_extension, name_extension)) + + + file_models = FileService.get_files(name=name, is_reference=True) + if len(file_models) == 0: + file_model = FileService.add_reference_file(name, file.content_type, file.stream.read()) + else: + file_model = file_models[0] + FileService.update_file(file_models[0], file.stream.read(), file.content_type) + + return FileModelSchema().dump(file_model) + + def update_file_data(file_id): file_model = session.query(FileModel).filter_by(id=file_id).with_for_update().first() file = connexion.request.files['file'] diff --git a/crc/models/api_models.py b/crc/models/api_models.py index c8575440..1d7a4f3a 100644 --- a/crc/models/api_models.py +++ b/crc/models/api_models.py @@ -105,6 +105,7 @@ class WorkflowApi(object): self.spec_version = spec_version self.is_latest_spec = is_latest_spec + class WorkflowApiSchema(ma.Schema): class Meta: model = WorkflowApi diff --git a/crc/models/file.py b/crc/models/file.py index ce7c8a34..932b197b 100644 --- a/crc/models/file.py +++ b/crc/models/file.py @@ -66,12 +66,19 @@ class FileDataModel(db.Model): class FileModel(db.Model): + """A file model defines one of the following increasingly specific types: + * A Reference file. Which just has a name and a reference flag set to true. These are global, and available everywhere. + * A Workflow Specification (such as BPMN or DMN model or a template) + * A Script generated file in a workflow. Which is specific to a study, workflow and task. + * An Uploaded file in a workflow. specific to a study, workflow, task, AND a field value. + """ __tablename__ = 'file' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String) type = db.Column(db.Enum(FileType)) - primary = db.Column(db.Boolean) content_type = db.Column(db.String) + is_reference = db.Column(db.Boolean, nullable=False, default=False) # A global reference file. + primary = db.Column(db.Boolean) # Is this the primary BPMN in a workflow? workflow_spec_id = db.Column(db.String, db.ForeignKey('workflow_spec.id'), nullable=True) workflow_id = db.Column(db.Integer, db.ForeignKey('workflow.id'), nullable=True) study_id = db.Column(db.Integer, db.ForeignKey('study.id'), nullable=True) diff --git a/crc/services/file_service.py b/crc/services/file_service.py index 0163ab11..929f1e6b 100644 --- a/crc/services/file_service.py +++ b/crc/services/file_service.py @@ -1,14 +1,13 @@ +import hashlib import os -from datetime import datetime from uuid import UUID from xml.etree import ElementTree from crc import session -from crc.api.common import ApiErrorSchema, ApiError -from crc.models.file import FileType, FileDataModel, FileModelSchema, FileModel, CONTENT_TYPES +from crc.api.common import ApiError +from crc.models.file import FileType, FileDataModel, FileModel from crc.models.workflow import WorkflowSpecModel from crc.services.workflow_processor import WorkflowProcessor -import hashlib class FileService(object): @@ -54,25 +53,39 @@ class FileService(object): ) return FileService.update_file(file_model, binary_data, content_type) + @staticmethod + def add_reference_file(name, content_type, binary_data): + """Create a file with the given name, but not associated with a spec or workflow. + Only one file with the given reference name can exist.""" + file_model = FileModel( + name=name, + is_reference=True + ) + return FileService.update_file(file_model, binary_data, content_type) + + @staticmethod + def get_extension(file_name): + basename, file_extension = os.path.splitext(file_name) + return file_extension.lower().strip()[1:] + @staticmethod def update_file(file_model, binary_data, content_type): - file_data_model = session.query(FileDataModel).\ + file_data_model = session.query(FileDataModel). \ filter_by(file_model_id=file_model.id, version=file_model.latest_version ).with_for_update().first() md5_checksum = UUID(hashlib.md5(binary_data).hexdigest()) - if(file_data_model is not None and md5_checksum == file_data_model.md5_hash): + if (file_data_model is not None and md5_checksum == file_data_model.md5_hash): # This file does not need to be updated, it's the same file. return file_model # Verify the extension - basename, file_extension = os.path.splitext(file_model.name) - file_extension = file_extension.lower().strip()[1:] + file_extension = FileService.get_extension(file_model.name) if file_extension not in FileType._member_names_: - return ApiErrorSchema().dump(ApiError('unknown_extension', - 'The file you provided does not have an accepted extension:' + - file_extension)), 404 + raise ApiError('unknown_extension', + 'The file you provided does not have an accepted extension:' + + file_extension, status_code=404) else: file_model.type = FileType[file_extension] file_model.content_type = content_type @@ -92,8 +105,10 @@ class FileService(object): return file_model @staticmethod - def get_files(workflow_spec_id=None, study_id=None, workflow_id=None, task_id=None, form_field_key=None): - query = session.query(FileModel) + def get_files(workflow_spec_id=None, + study_id=None, workflow_id=None, task_id=None, form_field_key=None, + name=None, is_reference=False): + query = session.query(FileModel).filter_by(is_reference=is_reference) if workflow_spec_id: query = query.filter_by(workflow_spec_id=workflow_spec_id) if study_id: @@ -104,15 +119,28 @@ class FileService(object): query = query.filter_by(task_id=str(task_id)) if form_field_key: query = query.filter_by(form_field_key=form_field_key) + if name: + query = query.filter_by(name=form_field_key) results = query.all() return results @staticmethod - def get_file_data(file_id): - """Returns the file_data that is associated with the file model id""" - file_model = session.query(FileModel).filter(FileModel.id == file_id).first() - return session.query(FileDataModel)\ - .filter(FileDataModel.file_model_id == file_id)\ - .filter(FileDataModel.version == file_model.latest_version)\ + def get_file_data(file_id, file_model=None): + """Returns the file_data that is associated with the file model id, if an actual file_model + is provided, uses that rather than looking it up again.""" + if file_model is None: + file_model = session.query(FileModel).filter(FileModel.id == file_id).first() + return session.query(FileDataModel) \ + .filter(FileDataModel.file_model_id == file_id) \ + .filter(FileDataModel.version == file_model.latest_version) \ .first() + + @staticmethod + def get_reference_file_data(file_name): + file_model = session.query(FileModel). \ + filter(FileModel.is_reference == True). \ + filter(FileModel.name == file_name).first() + if not file_model: + raise ApiError("file_not_found", "There is no reference file with the name '%s'" % file_name) + return FileService.get_file_data(file_model.id, file_model) diff --git a/migrations/versions/0c8a2f8db28c_.py b/migrations/versions/0c8a2f8db28c_.py new file mode 100644 index 00000000..6a172260 --- /dev/null +++ b/migrations/versions/0c8a2f8db28c_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 0c8a2f8db28c +Revises: 8856126b6658 +Create Date: 2020-03-13 14:05:46.983484 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0c8a2f8db28c' +down_revision = '8856126b6658' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('file', sa.Column('is_reference', sa.Boolean(), nullable=False)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('file', 'is_reference') + # ### end Alembic commands ### diff --git a/tests/data/reference/irb_documents.xlsx b/tests/data/reference/irb_documents.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..21ecc9da06883cf82e8c8536e4d4bc0e3727b583 GIT binary patch literal 26113 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 NsRoAxcD;w+{vZ555~TnD literal 0 HcmV?d00001 diff --git a/tests/test_files_api.py b/tests/test_files_api.py index 57dd1dff..e666ea1b 100644 --- a/tests/test_files_api.py +++ b/tests/test_files_api.py @@ -1,9 +1,8 @@ import io import json -from datetime import datetime from crc import session -from crc.models.file import FileModel, FileType, FileModelSchema, FileDataModel +from crc.models.file import FileModel, FileType, FileModelSchema from crc.models.workflow import WorkflowSpecModel from tests.base_test import BaseTest @@ -58,6 +57,52 @@ class TestFilesApi(BaseTest): file2 = FileModelSchema().load(json_data, session=session) self.assertEqual(file, file2) + def test_set_reference_file(self): + file_name = "irb_document_types.xls" + data = {'file': (io.BytesIO(b"abcdef"), "does_not_matter.xls")} + rv = self.app.put('/v1.0/reference_file/%s' % file_name, data=data, follow_redirects=True, + content_type='multipart/form-data') + self.assert_success(rv) + self.assertIsNotNone(rv.get_data()) + json_data = json.loads(rv.get_data(as_text=True)) + file = FileModelSchema().load(json_data, session=session) + self.assertEqual(FileType.xls, file.type) + self.assertTrue(file.is_reference) + self.assertEqual("application/vnd.ms-excel", file.content_type) + + def test_set_reference_file_bad_extension(self): + file_name = "irb_document_types.xls" + data = {'file': (io.BytesIO(b"abcdef"), "does_not_matter.ppt")} + rv = self.app.put('/v1.0/reference_file/%s' % file_name, data=data, follow_redirects=True, + content_type='multipart/form-data') + self.assert_failure(rv, error_code="invalid_file_type") + + def test_get_reference_file(self): + file_name = "irb_document_types.xls" + data = {'file': (io.BytesIO(b"abcdef"), "some crazy thing do not care.xls")} + rv = self.app.put('/v1.0/reference_file/%s' % file_name, data=data, follow_redirects=True, + content_type='multipart/form-data') + rv = self.app.get('/v1.0/reference_file/%s' % file_name) + self.assert_success(rv) + data_out = rv.get_data() + self.assertEqual(b"abcdef", data_out) + + def test_list_reference_files(self): + file_name = "irb_document_types.xls" + data = {'file': (io.BytesIO(b"abcdef"), file_name)} + rv = self.app.put('/v1.0/reference_file/%s' % file_name, data=data, follow_redirects=True, + content_type='multipart/form-data') + + rv = self.app.get('/v1.0/reference_file', + follow_redirects=True, + content_type="application/json") + self.assert_success(rv) + json_data = json.loads(rv.get_data(as_text=True)) + self.assertEqual(1, len(json_data)) + file = FileModelSchema(many=True).load(json_data, session=session) + self.assertEqual(file_name, file[0].name) + self.assertTrue(file[0].is_reference) + def test_update_file_info(self): self.load_example_data() file: FileModel = session.query(FileModel).first() @@ -118,7 +163,6 @@ class TestFilesApi(BaseTest): file = FileModelSchema().load(json_data, session=session) self.assertEqual(1, file.latest_version) - def test_get_file(self): self.load_example_data() spec = session.query(WorkflowSpecModel).first() @@ -137,3 +181,4 @@ class TestFilesApi(BaseTest): rv = self.app.delete('/v1.0/file/%i' % file.id) rv = self.app.get('/v1.0/file/%i' % file.id) self.assertEqual(404, rv.status_code) +