From c2035b7b402f933d9823b51c9a077a8572762e3c Mon Sep 17 00:00:00 2001 From: mike cullerton Date: Tue, 8 Mar 2022 16:45:19 -0500 Subject: [PATCH 01/19] Add workflow_spec_id to task_log model. This is because workflows can be deleted, so workflow_id is not dependable. Add workflow (display_name) and category (display_name) to TaskLogModelSchema. We display these to the user on study home page in the logs. --- crc/models/task_log.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crc/models/task_log.py b/crc/models/task_log.py index ded2f491..f818012a 100644 --- a/crc/models/task_log.py +++ b/crc/models/task_log.py @@ -5,6 +5,7 @@ import marshmallow from crc import db, ma from crc.models.study import StudyModel from crc.models.workflow import WorkflowModel +from crc.services.workflow_spec_service import WorkflowSpecService from sqlalchemy import func @@ -30,6 +31,7 @@ class TaskLogModel(db.Model): user_uid = db.Column(db.String) study_id = db.Column(db.Integer, db.ForeignKey(StudyModel.id), nullable=False) workflow_id = db.Column(db.Integer, db.ForeignKey(WorkflowModel.id), nullable=False) + workflow_spec_id = db.Column(db.String) task = db.Column(db.String) timestamp = db.Column(db.DateTime(timezone=True), server_default=func.now()) @@ -37,7 +39,23 @@ class TaskLogModel(db.Model): class TaskLogModelSchema(ma.Schema): class Meta: model = TaskLogModel - fields = ["id", "level", "code", "message", "study_id", "workflow_id", "user_uid", "timestamp"] + fields = ["id", "level", "code", "message", "study_id", "workflow", "workflow_id", + "workflow_spec_id", "category", "user_uid", "timestamp"] + category = marshmallow.fields.Method('get_category') + workflow = marshmallow.fields.Method('get_workflow') + + @staticmethod + def get_category(obj): + if hasattr(obj, 'workflow_spec_id') and obj.workflow_spec_id is not None: + workflow_spec = WorkflowSpecService().get_spec(obj.workflow_spec_id) + category = WorkflowSpecService().get_category(workflow_spec.category_id) + return category.display_name + + @staticmethod + def get_workflow(obj): + if hasattr(obj, 'workflow_spec_id') and obj.workflow_spec_id is not None: + workflow_spec = WorkflowSpecService().get_spec(obj.workflow_spec_id) + return workflow_spec.display_name class TaskLogQuery: @@ -69,6 +87,7 @@ class TaskLogQuery: self.has_prev = paginator.has_prev self.total = paginator.total + class TaskLogQuerySchema(ma.Schema): class Meta: model = TaskLogModel From 04ac0335b8655a006ecca9263cc3afd5bd425457 Mon Sep 17 00:00:00 2001 From: mike cullerton Date: Tue, 8 Mar 2022 16:47:54 -0500 Subject: [PATCH 02/19] Migration for the task log changes. Also populate the new workflow_spec_id column --- ...fa_add_workflow_spec_id_to_tasklogmodel.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 migrations/versions/d9a34e9d7cfa_add_workflow_spec_id_to_tasklogmodel.py diff --git a/migrations/versions/d9a34e9d7cfa_add_workflow_spec_id_to_tasklogmodel.py b/migrations/versions/d9a34e9d7cfa_add_workflow_spec_id_to_tasklogmodel.py new file mode 100644 index 00000000..891a2e68 --- /dev/null +++ b/migrations/versions/d9a34e9d7cfa_add_workflow_spec_id_to_tasklogmodel.py @@ -0,0 +1,36 @@ +"""Add workflow_spec_id to TaskLogModel + +Revision ID: d9a34e9d7cfa +Revises: cf57eba23a16 +Create Date: 2022-03-08 13:37:24.773814 + +""" +from alembic import op +import sqlalchemy as sa + +from crc.models.task_log import TaskLogModel +from crc.models.workflow import WorkflowModel + + +# revision identifiers, used by Alembic. +revision = 'd9a34e9d7cfa' +down_revision = 'cf57eba23a16' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('task_log', sa.Column('workflow_spec_id', sa.String())) + bind = op.get_bind() + session = sa.orm.Session(bind=bind) + session.flush() + task_logs = session.query(TaskLogModel).all() + for task_log in task_logs: + workflow = session.query(WorkflowModel).filter(WorkflowModel.id==task_log.workflow_id).first() + if workflow and workflow.workflow_spec_id: + task_log.workflow_spec_id = workflow.workflow_spec_id + session.commit() + + +def downgrade(): + op.drop_column('task_log', 'workflow_spec_id') From 8cab382926edea6c9d4e2a0e260915e8d8fd117e Mon Sep 17 00:00:00 2001 From: mike cullerton Date: Tue, 8 Mar 2022 16:52:37 -0500 Subject: [PATCH 03/19] The task log model expected has_prev instead of has_previous --- crc/api.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crc/api.yml b/crc/api.yml index 710a7c65..1203ef71 100755 --- a/crc/api.yml +++ b/crc/api.yml @@ -2117,7 +2117,7 @@ components: has_next: type: boolean example: true - has_previous: + has_prev: type: boolean example: false TaskLog: From bf31364b1f6a94f3d09168c9a633300adf966ff0 Mon Sep 17 00:00:00 2001 From: mike cullerton Date: Wed, 9 Mar 2022 13:07:56 -0500 Subject: [PATCH 04/19] *** WIP *** Committing to fix logging so we can use a level of 'metrics' --- crc/api.yml | 23 +++++++++++++++++++++++ crc/api/study.py | 4 ++++ crc/services/task_logging_service.py | 9 +++++++++ 3 files changed, 36 insertions(+) diff --git a/crc/api.yml b/crc/api.yml index 1203ef71..fb7c8989 100755 --- a/crc/api.yml +++ b/crc/api.yml @@ -284,6 +284,29 @@ paths: type: array items: $ref: "#/components/schemas/TaskLog" + /study/{study_id}/log/download: + parameters: + - name: study_id + in: path + required: true + description: The id of the study for which logs should be returned. + schema: + type: integer + format: int32 + put: + operationId: crc.api.study.download_logs_for_study + summary: Returns a csv file of logged events that occured within a study + tags: + - Studies + responses: + '200': + description: Returns the csv file of logged events + content: + application/octet-stream: + schema: + type: string + format: binary + /workflow-specification: get: operationId: crc.api.workflow.all_specifications diff --git a/crc/api/study.py b/crc/api/study.py index 5ff2d743..317aea03 100644 --- a/crc/api/study.py +++ b/crc/api/study.py @@ -119,6 +119,10 @@ def get_logs_for_study(study_id, body): TaskLoggingService.get_logs_for_study(study_id, task_log_query)) +def download_logs_for_study(study_id): + logs = TaskLoggingService.download_all_logs_for_study(study_id) + + def delete_study(study_id): try: StudyService.delete_study(study_id) diff --git a/crc/services/task_logging_service.py b/crc/services/task_logging_service.py index 545aa1e6..b37afd86 100644 --- a/crc/services/task_logging_service.py +++ b/crc/services/task_logging_service.py @@ -82,3 +82,12 @@ class TaskLoggingService(object): error_out=False) task_log_query.update_from_sqlalchemy_paginator(paginator) return task_log_query + + @staticmethod + def download_all_logs_for_study(study_id): + # Admins can download the logs for a study as a csv file + # In this case, we don't want to paginate + # We also provide data that we don't include when displaying logs on the study page + query = session.query(TaskLogModel).filter(TaskLogModel.study_id == study_id).all() + + print('download_all_logs_for_study') From 2224e34a94788aba1c394b37343ffa06fb179bda Mon Sep 17 00:00:00 2001 From: mike cullerton Date: Thu, 10 Mar 2022 12:10:54 -0500 Subject: [PATCH 05/19] Remove unused imports Finish up download_logs_for_study --- crc/api/study.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/crc/api/study.py b/crc/api/study.py index 317aea03..f86850d6 100644 --- a/crc/api/study.py +++ b/crc/api/study.py @@ -1,14 +1,14 @@ from datetime import datetime -from flask import g +from flask import g, send_file from sqlalchemy.exc import IntegrityError from crc import session from crc.api.common import ApiError, ApiErrorSchema -from crc.models.protocol_builder import ProtocolBuilderStatus -from crc.models.study import Study, StudyEvent, StudyEventType, StudyModel, StudySchema, StudyForUpdateSchema, \ +from crc.models.study import Study, StudyEventType, StudyModel, StudySchema, StudyForUpdateSchema, \ StudyStatus, StudyAssociatedSchema -from crc.models.task_log import TaskLogModelSchema, TaskLogQuery, TaskLogQuerySchema +from crc.models.task_log import TaskLogQuery, TaskLogQuerySchema +from crc.services.spreadsheet_service import SpreadsheetService from crc.services.study_service import StudyService from crc.services.task_logging_service import TaskLoggingService from crc.services.user_service import UserService @@ -16,6 +16,8 @@ from crc.services.workflow_processor import WorkflowProcessor from crc.services.workflow_service import WorkflowService from crc.services.workflow_spec_service import WorkflowSpecService +import io + def add_study(body): """Or any study like object. Body should include a title, and primary_investigator_id """ @@ -120,7 +122,18 @@ def get_logs_for_study(study_id, body): def download_logs_for_study(study_id): - logs = TaskLoggingService.download_all_logs_for_study(study_id) + title = f'Study {study_id}' + logs, headers = TaskLoggingService.get_all_logs_for_download(study_id) + spreadsheet = SpreadsheetService.create_spreadsheet(logs, headers, title) + + return send_file( + io.BytesIO(spreadsheet), + attachment_filename='logs.xlsx', + mimetype='xlsx', + cache_timeout=-1, # Don't cache these files on the browser. + last_modified=datetime.now(), + as_attachment=True + ) def delete_study(study_id): From 9efb4c4fb371bf04d83510ff1d120921bdc4df08 Mon Sep 17 00:00:00 2001 From: mike cullerton Date: Thu, 10 Mar 2022 12:13:10 -0500 Subject: [PATCH 06/19] New spreadsheet_service. Accepts list of dictionaries, and optional headers and title Creates spreadsheet from list of dictionaries Returns the spreadsheet as a binary stream --- crc/services/spreadsheet_service.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 crc/services/spreadsheet_service.py diff --git a/crc/services/spreadsheet_service.py b/crc/services/spreadsheet_service.py new file mode 100644 index 00000000..53f3484e --- /dev/null +++ b/crc/services/spreadsheet_service.py @@ -0,0 +1,26 @@ +from openpyxl import Workbook +from tempfile import NamedTemporaryFile + + +class SpreadsheetService(object): + + @staticmethod + def create_spreadsheet(data: list[dict], headers: list[str] = None, title: str = None): + """The length of headers must be the same as the number of items in the dictionaries, + and the order must match up. + The title is used for the worksheet, not the filename.""" + + wb = Workbook(write_only=True) + ws = wb.create_sheet() + if title: + ws.title = title + if headers: + ws.append(headers) + for row in data: + ws.append(list(row.values())) + + with NamedTemporaryFile() as tmp: + wb.save(tmp.name) + tmp.seek(0) + stream = tmp.read() + return stream From 423426c2c8790988780a54b33fb368c2207ab0e0 Mon Sep 17 00:00:00 2001 From: mike cullerton Date: Thu, 10 Mar 2022 12:14:14 -0500 Subject: [PATCH 07/19] Removed some unused imports Finished up get_all_logs_for_download --- crc/services/task_logging_service.py | 57 +++++++++++++++++----------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/crc/services/task_logging_service.py b/crc/services/task_logging_service.py index b37afd86..1d324a6a 100644 --- a/crc/services/task_logging_service.py +++ b/crc/services/task_logging_service.py @@ -1,23 +1,14 @@ -import markdown -import re - -from flask import render_template -from flask_mail import Message -from jinja2 import Template -from sqlalchemy import desc - -from crc import app, db, mail, session +from crc import app, session from crc.api.common import ApiError - -from crc.models.email import EmailModel -from crc.models.file import FileDataModel -from crc.models.study import StudyModel -from crc.models.task_log import TaskLogModel, TaskLogLevels, TaskLogQuery -from crc.models.user import UserModel - -from crc.services.jinja_service import JinjaService +from crc.models.task_log import TaskLogModel, TaskLogLevels, TaskLogQuery, TaskLogModelSchema from crc.services.user_service import UserService +from sqlalchemy import desc +from dateutil import tz + +import dateparser +import pytz + class TaskLoggingService(object): """Provides common tools for logging information from running workflows. This logging information @@ -84,10 +75,32 @@ class TaskLoggingService(object): return task_log_query @staticmethod - def download_all_logs_for_study(study_id): - # Admins can download the logs for a study as a csv file + def get_all_logs_for_download(study_id): + # Admins can download the metrics logs for a study as an Excel file # In this case, we don't want to paginate - # We also provide data that we don't include when displaying logs on the study page - query = session.query(TaskLogModel).filter(TaskLogModel.study_id == study_id).all() + logs = [] + headers = [] + result = session.query(TaskLogModel).\ + filter(TaskLogModel.study_id == study_id).\ + filter(TaskLogModel.level == 'metrics').\ + all() + schemas = TaskLogModelSchema(many=True).dump(result) + # We only use these fields + fields = ['category', 'workflow', 'level', 'code', 'message', 'user_uid', 'timestamp', 'workflow_id', 'workflow_spec_id'] + for schema in schemas: + # Build a dictionary using the items in fields + log = {} + for field in fields: + if field == 'timestamp': + # Excel doesn't accept timezones, + # so we return a local datetime without the timezone + parsed_timestamp = dateparser.parse(str(schema['timestamp'])) + localtime = parsed_timestamp.astimezone(pytz.timezone('US/Eastern')) + log[field] = localtime.strftime('%Y-%m-%d %H:%M:%S') + else: + log[field] = schema[field] + if field.capitalize() not in headers: + headers.append(field.capitalize()) + logs.append(log) - print('download_all_logs_for_study') + return logs, headers From d57d741c102d85b812c981a86f7613e88a9ebae5 Mon Sep 17 00:00:00 2001 From: mike cullerton Date: Thu, 10 Mar 2022 12:17:32 -0500 Subject: [PATCH 08/19] Added TODO --- crc/services/task_logging_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crc/services/task_logging_service.py b/crc/services/task_logging_service.py index 1d324a6a..bdee9506 100644 --- a/crc/services/task_logging_service.py +++ b/crc/services/task_logging_service.py @@ -4,7 +4,6 @@ from crc.models.task_log import TaskLogModel, TaskLogLevels, TaskLogQuery, TaskL from crc.services.user_service import UserService from sqlalchemy import desc -from dateutil import tz import dateparser import pytz @@ -94,6 +93,7 @@ class TaskLoggingService(object): if field == 'timestamp': # Excel doesn't accept timezones, # so we return a local datetime without the timezone + # TODO: detect the local timezone with something like dateutil.tz.tzlocal() parsed_timestamp = dateparser.parse(str(schema['timestamp'])) localtime = parsed_timestamp.astimezone(pytz.timezone('US/Eastern')) log[field] = localtime.strftime('%Y-%m-%d %H:%M:%S') From ec9ff4ff8a7e3f84bb433c164e4bbd4f4484b9df Mon Sep 17 00:00:00 2001 From: mike cullerton Date: Thu, 10 Mar 2022 12:19:41 -0500 Subject: [PATCH 09/19] *** WIP *** Need to finish writing tests --- tests/study/test_study_download_logs.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 tests/study/test_study_download_logs.py diff --git a/tests/study/test_study_download_logs.py b/tests/study/test_study_download_logs.py new file mode 100644 index 00000000..6ad9c83c --- /dev/null +++ b/tests/study/test_study_download_logs.py @@ -0,0 +1,14 @@ +from tests.base_test import BaseTest + +from crc.api.study import download_logs_for_study + + +class TestDownloadLogsForStudy(BaseTest): + + # TODO: finish writing tests + def test_download_logs_for_study(self): + + study_id = 6 + result = download_logs_for_study(study_id) + + print('test_download_logs_for_study') From 733e596283b78813810f2f1f935b23f2d08cb1ef Mon Sep 17 00:00:00 2001 From: mike cullerton Date: Fri, 11 Mar 2022 13:58:25 -0500 Subject: [PATCH 10/19] rename get_logs to get_logs_for_workflow to better represent what it does --- crc/scripts/{get_logs.py => get_logs_for_workflow.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crc/scripts/{get_logs.py => get_logs_for_workflow.py} (100%) diff --git a/crc/scripts/get_logs.py b/crc/scripts/get_logs_for_workflow.py similarity index 100% rename from crc/scripts/get_logs.py rename to crc/scripts/get_logs_for_workflow.py From 1bfe656817b81dac06870269862fa92e6e0bf819 Mon Sep 17 00:00:00 2001 From: mike cullerton Date: Fri, 11 Mar 2022 14:01:06 -0500 Subject: [PATCH 11/19] Remove unused API endpoint to get logs for a workflow. We have a script for this that is used (in workflows) We do use the API endpoint to get logs for a study. (On the study page) --- crc/api.yml | 28 ---------------------------- crc/api/workflow.py | 6 ------ 2 files changed, 34 deletions(-) diff --git a/crc/api.yml b/crc/api.yml index fb7c8989..4e1215ac 100755 --- a/crc/api.yml +++ b/crc/api.yml @@ -1225,34 +1225,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Workflow" - /workflow/{workflow_id}/log: - parameters: - - name: workflow_id - in: path - required: true - description: The id of the workflow for which logs should be returned. - schema: - type: integer - format: int32 - put: - operationId: crc.api.workflow.get_logs_for_workflow - summary: Provides a paginated list of logged events that occured within a study, - tags: - - Workflows and Tasks - requestBody: - description: Log Pagination Request - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/PaginatedTaskLog" - responses: - '200': - description: list of logs - events that have occured within a specific workflow. - content: - application/json: - schema: - $ref: "#/components/schemas/PaginatedTaskLog" /workflow/{workflow_id}/task/{task_id}/data: parameters: - name: workflow_id diff --git a/crc/api/workflow.py b/crc/api/workflow.py index 7187e237..ea1af58b 100644 --- a/crc/api/workflow.py +++ b/crc/api/workflow.py @@ -422,9 +422,3 @@ def _verify_user_and_role(processor, spiff_task): raise ApiError.from_task("permission_denied", f"This task must be completed by '{allowed_users}', " f"but you are {user.uid}", spiff_task) - - -def get_logs_for_workflow(workflow_id, body): - task_log_query = TaskLogQuery(**body) - return TaskLogQuerySchema().dump( - TaskLoggingService.get_logs_for_workflow(workflow_id, task_log_query)) From b753f063943b50d654949a2f7fc54b85058cd20a Mon Sep 17 00:00:00 2001 From: mike cullerton Date: Fri, 11 Mar 2022 14:03:09 -0500 Subject: [PATCH 12/19] Changes to remove pagination for the get_logs scripts. We only use pagination for the get_logs_for_study API endpoint. (Used on the study page) --- crc/api/study.py | 4 +-- crc/scripts/get_logs_for_study.py | 20 +++++++++------ crc/scripts/get_logs_for_workflow.py | 36 +++++++++++++++++---------- crc/services/task_logging_service.py | 37 ++++++++++++++++++++++------ 4 files changed, 66 insertions(+), 31 deletions(-) diff --git a/crc/api/study.py b/crc/api/study.py index f86850d6..83dd4b5b 100644 --- a/crc/api/study.py +++ b/crc/api/study.py @@ -118,12 +118,12 @@ def get_study_associates(study_id): def get_logs_for_study(study_id, body): task_log_query = TaskLogQuery(**body) return TaskLogQuerySchema().dump( - TaskLoggingService.get_logs_for_study(study_id, task_log_query)) + TaskLoggingService.get_logs_for_study_paginated(study_id, task_log_query)) def download_logs_for_study(study_id): title = f'Study {study_id}' - logs, headers = TaskLoggingService.get_all_logs_for_download(study_id) + logs, headers = TaskLoggingService.get_log_data_for_download(study_id) spreadsheet = SpreadsheetService.create_spreadsheet(logs, headers, title) return send_file( diff --git a/crc/scripts/get_logs_for_study.py b/crc/scripts/get_logs_for_study.py index 7b518e3b..4f7ace12 100644 --- a/crc/scripts/get_logs_for_study.py +++ b/crc/scripts/get_logs_for_study.py @@ -13,7 +13,7 @@ class GetLogsByWorkflow(Script): """ def do_task_validate_only(self, task, study_id, workflow_id, *args, **kwargs): - log_model = TaskLogModel(level='info', + log_model = TaskLogModel(level='metrics', code='mocked_code', message='This is my logging message', study_id=study_id, @@ -22,17 +22,21 @@ class GetLogsByWorkflow(Script): return TaskLogModelSchema(many=True).dump([log_model]) def do_task(self, task, study_id, workflow_id, *args, **kwargs): + level = None code = None - size = 10 + size = None + if 'level' in kwargs: + level = kwargs['level'] + elif len(args) > 0: + level = args[0] if 'code' in kwargs: code = kwargs['code'] - elif len(args) > 0: - code = args[0] + elif len(args) > 1: + code = args[1] if 'size' in kwargs: size = kwargs['size'] - elif len(args) > 1: - size = args[1] + elif len(args) > 2: + size = args[2] - query = TaskLogQuery(code=code, per_page=size) - log_models = TaskLoggingService.get_logs_for_study(study_id, query).items + log_models = TaskLoggingService().get_logs_for_study(study_id, level, code, size) return TaskLogModelSchema(many=True).dump(log_models) diff --git a/crc/scripts/get_logs_for_workflow.py b/crc/scripts/get_logs_for_workflow.py index d04d3376..8af426af 100644 --- a/crc/scripts/get_logs_for_workflow.py +++ b/crc/scripts/get_logs_for_workflow.py @@ -5,6 +5,25 @@ from crc.services.task_logging_service import TaskLoggingService class GetLogsByWorkflow(Script): + @staticmethod + def get_parameters(args, kwargs): + code = None + level = None + size = None + if 'level' in kwargs: + level = kwargs['level'] + elif len(args) > 0: + level = args[0] + if 'code' in kwargs: + code = kwargs['code'] + elif len(args) > 1: + code = args[1] + if 'size' in kwargs: + size = kwargs['size'] + elif len(args) > 2: + size = args[2] + + return level, code, size def get_description(self): return """Script to retrieve logs for the current workflow. @@ -22,17 +41,8 @@ class GetLogsByWorkflow(Script): TaskLogModelSchema(many=True).dump([log_model]) def do_task(self, task, study_id, workflow_id, *args, **kwargs): - code = None - size = 10 - if 'code' in kwargs: - code = kwargs['code'] - elif len(args) > 0: - code = args[0] - if 'size' in kwargs: - size = kwargs['size'] - elif len(args) > 1: - size = args[1] - - query = TaskLogQuery(code=code, per_page=size) - log_models = TaskLoggingService.get_logs_for_workflow(workflow_id, query).items + level, code, size = self.get_parameters(args, kwargs) + log_models = TaskLoggingService().get_logs_for_workflow(workflow_id=workflow_id, level=level, code=code, size=size) + # query = TaskLogQuery(code=code, per_page=size) + # log_models = TaskLoggingService.get_logs_for_workflow(workflow_id, query).items return TaskLogModelSchema(many=True).dump(log_models) diff --git a/crc/services/task_logging_service.py b/crc/services/task_logging_service.py index bdee9506..f28ae101 100644 --- a/crc/services/task_logging_service.py +++ b/crc/services/task_logging_service.py @@ -37,14 +37,35 @@ class TaskLoggingService(object): session.commit() return log_model - @staticmethod - def get_logs_for_workflow(workflow_id, tq: TaskLogQuery): - """ Returns an updated TaskLogQuery, with items in reverse chronological order by default. """ - query = session.query(TaskLogModel).filter(TaskLogModel.workflow_id == workflow_id) - return TaskLoggingService.__paginate(query, tq) + def get_logs_for_workflow(self, workflow_id: int, level: str = None, code: str = None, size: int = None): + logs = self.get_logs(workflow_id=workflow_id, level=level, code=code, size=size) + return logs + + def get_logs_for_study(self, study_id: int, level: str = None, code: str = None, size: int = None): + logs = self.get_logs(study_id=study_id, level=level, code=code, size=size) + return logs @staticmethod - def get_logs_for_study(study_id, tq: TaskLogQuery): + def get_logs(study_id: int = None, workflow_id: int = None, level: str = None, code: str = None, size: int = None): + """We should almost always get a study_id or a workflow_id. + In *very* rare circumstances, an admin may want all the logs. + This could be a *lot* of logs.""" + query = session.query(TaskLogModel) + if study_id: + query = query.filter(TaskLogModel.study_id == study_id) + if workflow_id: + query = query.filter(TaskLogModel.workflow_id == workflow_id) + if level: + query = query.filter(TaskLogModel.level == level) + if code: + query = query.filter(TaskLogModel.code == code) + if size: + query = query.limit(size) + logs = query.all() + return logs + + @staticmethod + def get_logs_for_study_paginated(study_id, tq: TaskLogQuery): """ Returns an updated TaskLogQuery, with items in reverse chronological order by default. """ query = session.query(TaskLogModel).filter(TaskLogModel.study_id == study_id) return TaskLoggingService.__paginate(query, tq) @@ -74,9 +95,9 @@ class TaskLoggingService(object): return task_log_query @staticmethod - def get_all_logs_for_download(study_id): + def get_log_data_for_download(study_id): # Admins can download the metrics logs for a study as an Excel file - # In this case, we don't want to paginate + # We only use a subset of the fields logs = [] headers = [] result = session.query(TaskLogModel).\ From 52b5e1d34a9bb955415a99bf4b7be2c91d2b1565 Mon Sep 17 00:00:00 2001 From: Dan Date: Fri, 11 Mar 2022 18:02:37 -0500 Subject: [PATCH 13/19] Fixing tests. --- crc/services/spreadsheet_service.py | 4 +++- tests/data/get_logging/get_logging.bpmn | 10 +++++----- .../get_logging_for_study.bpmn | 2 +- tests/scripts/test_task_logging.py | 16 ++++------------ 4 files changed, 13 insertions(+), 19 deletions(-) diff --git a/crc/services/spreadsheet_service.py b/crc/services/spreadsheet_service.py index 53f3484e..9ba005c9 100644 --- a/crc/services/spreadsheet_service.py +++ b/crc/services/spreadsheet_service.py @@ -1,11 +1,13 @@ from openpyxl import Workbook from tempfile import NamedTemporaryFile +from typing import List + class SpreadsheetService(object): @staticmethod - def create_spreadsheet(data: list[dict], headers: list[str] = None, title: str = None): + def create_spreadsheet(data: List[dict], headers: List[str] = None, title: str = None): """The length of headers must be the same as the number of items in the dictionaries, and the order must match up. The title is used for the worksheet, not the filename.""" diff --git a/tests/data/get_logging/get_logging.bpmn b/tests/data/get_logging/get_logging.bpmn index 3a96d188..e20d5c42 100644 --- a/tests/data/get_logging/get_logging.bpmn +++ b/tests/data/get_logging/get_logging.bpmn @@ -1,5 +1,5 @@ - + Flow_0d5wpav @@ -37,14 +37,14 @@ log_model_debug = log(level='debug', code='debug_test_code', message='This is my Flow_0d5wpav Flow_0pc42yp - logging_models_pre = get_logs() + logging_models_pre = get_logs_for_workflow() Flow_0n34cdi Flow_07j4f0v - logging_models_all_post = get_logs() -logging_models_info_post = get_logs('test_code') -logging_models_debug_post = get_logs('debug_test_code') + logging_models_all_post = get_logs_for_workflow() +logging_models_info_post = get_logs_for_workflow(code='test_code') +logging_models_debug_post = get_logs_for_workflow(code='debug_test_code') diff --git a/tests/data/get_logging_for_study/get_logging_for_study.bpmn b/tests/data/get_logging_for_study/get_logging_for_study.bpmn index e3de15e6..5e0a3b42 100644 --- a/tests/data/get_logging_for_study/get_logging_for_study.bpmn +++ b/tests/data/get_logging_for_study/get_logging_for_study.bpmn @@ -24,7 +24,7 @@ log('debug', 'debug_code', f'This message has a { some_text }!') Flow_10fc3fk Flow_1dfqchi - workflow_logs = get_logs() + workflow_logs = get_logs_for_workflow() study_logs = get_logs_for_study() diff --git a/tests/scripts/test_task_logging.py b/tests/scripts/test_task_logging.py index 1cbc13c2..af37ba46 100644 --- a/tests/scripts/test_task_logging.py +++ b/tests/scripts/test_task_logging.py @@ -130,14 +130,6 @@ class TestTaskLogging(BaseTest): self.assertEqual(self.test_uid, logs[0]['user_uid']) self.assertEqual('You forgot to include the correct data.', logs[0]['message']) - url = f'/v1.0/workflow/{workflow.id}/log' - rv = self.app.put(url, headers=self.logged_in_headers(user), content_type="application/json", - data=TaskLogQuerySchema().dump(task_log_query)) - - self.assert_success(rv) - wf_logs = json.loads(rv.get_data(as_text=True))['items'] - self.assertEqual(wf_logs, logs, "Logs returned for the workflow should be identical to those returned from study") - def test_logging_service_paginates_and_sorts(self): self.add_studies() study = session.query(StudyModel).first() @@ -155,10 +147,10 @@ class TestTaskLogging(BaseTest): TaskLog().do_task(task, study.id, workflow_model.id, level='info', code='debug_code', message=f'This is my info message # {i}.') - results = TaskLoggingService.get_logs_for_study(study.id, TaskLogQuery(per_page=100)) + results = TaskLoggingService().get_logs_for_study_paginated(study.id, TaskLogQuery(per_page=100)) self.assertEqual(40, len(results.items), "There should be 40 logs total") - logs = TaskLoggingService.get_logs_for_study(study.id, TaskLogQuery(per_page=5)) + logs = TaskLoggingService().get_logs_for_study_paginated(study.id, TaskLogQuery(per_page=5)) self.assertEqual(40, logs.total) self.assertEqual(5, len(logs.items), "I can limit results to 5") self.assertEqual(1, logs.page) @@ -167,10 +159,10 @@ class TestTaskLogging(BaseTest): self.assertEqual(True, logs.has_next) self.assertEqual(False, logs.has_prev) - logs = TaskLoggingService.get_logs_for_study(study.id, TaskLogQuery(per_page=5, sort_column="level")) + logs = TaskLoggingService.get_logs_for_study_paginated(study.id, TaskLogQuery(per_page=5, sort_column="level")) for i in range(0, 5): self.assertEqual('critical', logs.items[i].level, "It is possible to sort on a column") - logs = TaskLoggingService.get_logs_for_study(study.id, TaskLogQuery(per_page=5, sort_column="level", sort_reverse=True)) + logs = TaskLoggingService.get_logs_for_study_paginated(study.id, TaskLogQuery(per_page=5, sort_column="level", sort_reverse=True)) for i in range(0, 5): self.assertEqual('info', logs.items[i].level, "It is possible to sort on a column") \ No newline at end of file From c8e987cd3974a2efa54472fbdf18762152b4e7a7 Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 12 Mar 2022 13:35:04 -0500 Subject: [PATCH 14/19] Be sure to delete the data store items when you delete a study. --- crc/services/study_service.py | 1 + tests/test_datastore_api.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/crc/services/study_service.py b/crc/services/study_service.py index 2b149ff3..7c9d2577 100755 --- a/crc/services/study_service.py +++ b/crc/services/study_service.py @@ -231,6 +231,7 @@ class StudyService(object): session.query(StudyAssociated).filter_by(study_id=study_id).delete() session.query(EmailModel).filter_by(study_id=study_id).delete() session.query(StudyEvent).filter_by(study_id=study_id).delete() + session.query(DataStoreModel).filter_by(study_id=study_id).delete() for workflow in session.query(WorkflowModel).filter_by(study_id=study_id): StudyService.delete_workflow(workflow.id) study = session.query(StudyModel).filter_by(id=study_id).first() diff --git a/tests/test_datastore_api.py b/tests/test_datastore_api.py index 155f52a9..1487119a 100644 --- a/tests/test_datastore_api.py +++ b/tests/test_datastore_api.py @@ -106,6 +106,21 @@ class DataStoreTest(BaseTest): studyreponse = session.query(DataStoreModel).filter_by(id=oldid).first() self.assertEqual(studyreponse,None) + def test_delete_study_with_datastore(self): + study = self.create_study() + study_data = DataStoreSchema().dump(self.TEST_STUDY_ITEM) + study_data['study_id'] = study.id + self.assertEqual(0, session.query(DataStoreModel).count()) + rv = self.app.post('/v1.0/datastore', + content_type="application/json", + headers=self.logged_in_headers(), + data=json.dumps(study_data)) + self.assertEqual(1, session.query(DataStoreModel).count()) + rv = self.app.delete('/v1.0/study/%i' % study.id, headers=self.logged_in_headers()) + self.assertEqual(0, session.query(DataStoreModel).count()) + + + def test_data_crosstalk(self): """Test to make sure that data saved for user or study is not accessible from the other method""" From 00a99579c990c706a1f8b82e1d26a8454867e7e4 Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 12 Mar 2022 13:36:13 -0500 Subject: [PATCH 15/19] Revert "Be sure to delete the data store items when you delete a study." This reverts commit c8e987cd3974a2efa54472fbdf18762152b4e7a7. --- crc/services/study_service.py | 1 - tests/test_datastore_api.py | 15 --------------- 2 files changed, 16 deletions(-) diff --git a/crc/services/study_service.py b/crc/services/study_service.py index 7c9d2577..2b149ff3 100755 --- a/crc/services/study_service.py +++ b/crc/services/study_service.py @@ -231,7 +231,6 @@ class StudyService(object): session.query(StudyAssociated).filter_by(study_id=study_id).delete() session.query(EmailModel).filter_by(study_id=study_id).delete() session.query(StudyEvent).filter_by(study_id=study_id).delete() - session.query(DataStoreModel).filter_by(study_id=study_id).delete() for workflow in session.query(WorkflowModel).filter_by(study_id=study_id): StudyService.delete_workflow(workflow.id) study = session.query(StudyModel).filter_by(id=study_id).first() diff --git a/tests/test_datastore_api.py b/tests/test_datastore_api.py index 1487119a..155f52a9 100644 --- a/tests/test_datastore_api.py +++ b/tests/test_datastore_api.py @@ -106,21 +106,6 @@ class DataStoreTest(BaseTest): studyreponse = session.query(DataStoreModel).filter_by(id=oldid).first() self.assertEqual(studyreponse,None) - def test_delete_study_with_datastore(self): - study = self.create_study() - study_data = DataStoreSchema().dump(self.TEST_STUDY_ITEM) - study_data['study_id'] = study.id - self.assertEqual(0, session.query(DataStoreModel).count()) - rv = self.app.post('/v1.0/datastore', - content_type="application/json", - headers=self.logged_in_headers(), - data=json.dumps(study_data)) - self.assertEqual(1, session.query(DataStoreModel).count()) - rv = self.app.delete('/v1.0/study/%i' % study.id, headers=self.logged_in_headers()) - self.assertEqual(0, session.query(DataStoreModel).count()) - - - def test_data_crosstalk(self): """Test to make sure that data saved for user or study is not accessible from the other method""" From 015eeccb546fe65339d0b5909a8c3821da9752f9 Mon Sep 17 00:00:00 2001 From: mike cullerton Date: Sat, 12 Mar 2022 14:23:22 -0500 Subject: [PATCH 16/19] Change API endpoint to a GET --- crc/api.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crc/api.yml b/crc/api.yml index 4e1215ac..2af8e249 100755 --- a/crc/api.yml +++ b/crc/api.yml @@ -293,7 +293,7 @@ paths: schema: type: integer format: int32 - put: + get: operationId: crc.api.study.download_logs_for_study summary: Returns a csv file of logged events that occured within a study tags: From a536a79e8763d50770a11969ac40dbbfd249cc73 Mon Sep 17 00:00:00 2001 From: mike cullerton Date: Sat, 12 Mar 2022 14:29:02 -0500 Subject: [PATCH 17/19] Test for downloading logs --- tests/study/test_study_download_logs.py | 50 ++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/tests/study/test_study_download_logs.py b/tests/study/test_study_download_logs.py index 6ad9c83c..87bd3681 100644 --- a/tests/study/test_study_download_logs.py +++ b/tests/study/test_study_download_logs.py @@ -1,14 +1,54 @@ from tests.base_test import BaseTest -from crc.api.study import download_logs_for_study +from crc import session +from crc.models.task_log import TaskLogModel + +from openpyxl import load_workbook +from io import BytesIO class TestDownloadLogsForStudy(BaseTest): + @staticmethod + def add_log(study_id, workflow_id, task, workflow_spec_id, log_data): + task_log = TaskLogModel(level=log_data['level'], + code=log_data['code'], + message=log_data['message'], + study_id=study_id, + workflow_id=workflow_id, + task=task, + user_uid='joe', + workflow_spec_id=workflow_spec_id) + session.add(task_log) + session.commit() - # TODO: finish writing tests def test_download_logs_for_study(self): + workflow = self.create_workflow('empty_workflow') + workflow_api = self.get_workflow_api(workflow) + task = workflow_api.next_task + study_id = workflow.study_id - study_id = 6 - result = download_logs_for_study(study_id) + log_data = {'level': 'metrics', + 'code': 'test_code', + 'message': 'This is a message.'} + self.add_log(study_id, workflow.id, task.name, 'empty_workflow', log_data) + log_data = {'level': 'metrics', + 'code': 'another_test_code', + 'message': 'This is another message.'} + self.add_log(study_id, workflow.id, task.name, 'empty_workflow', log_data) + log_data = {'level': 'metrics', + 'code': 'a_third_test_code', + 'message': 'This is a third message.'} + self.add_log(study_id, workflow.id, task.name, 'empty_workflow', log_data) - print('test_download_logs_for_study') + rv = self.app.get(f'/v1.0/study/{study_id}/log/download', + content_type="application/json", + headers=self.logged_in_headers()) + + wb = load_workbook(BytesIO(rv.data)) + ws = wb.active + + self.assertEqual(4, ws.max_row) + self.assertEqual('Category', ws['A1'].value) + self.assertEqual('empty_workflow', ws['B2'].value) + self.assertEqual('metrics', ws['C3'].value) + self.assertEqual('a_third_test_code', ws['D4'].value) From 2fc4b44ef368e92bc0c32be0e1d4144e62165e09 Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 12 Mar 2022 16:19:07 -0500 Subject: [PATCH 18/19] Create a path to directly download the spreadsheet file (and avoid the weird dance on the front end of making an API call to get file data.) Fixing pagination. Seems the front end uses a page_index that is 0 based, and sqlAlchemy prefers to start at 1. --- crc/api.yml | 9 +++++++- crc/api/study.py | 9 ++++++-- crc/models/task_log.py | 29 ++++++++++++++++++++----- crc/services/task_logging_service.py | 2 +- tests/scripts/test_task_logging.py | 6 +++-- tests/study/test_study_download_logs.py | 20 +++++++++++++---- 6 files changed, 60 insertions(+), 15 deletions(-) diff --git a/crc/api.yml b/crc/api.yml index 2af8e249..9b73736d 100755 --- a/crc/api.yml +++ b/crc/api.yml @@ -293,14 +293,21 @@ paths: schema: type: integer format: int32 + - name : auth_token + in : query + required : true + description : User Auth Toeken + schema: + type: string get: operationId: crc.api.study.download_logs_for_study summary: Returns a csv file of logged events that occured within a study + security: [] # Will verify manually with provided Auth Token. tags: - Studies responses: '200': - description: Returns the csv file of logged events + description: Returns the spreadsheet file of logged events content: application/octet-stream: schema: diff --git a/crc/api/study.py b/crc/api/study.py index 83dd4b5b..41421ead 100644 --- a/crc/api/study.py +++ b/crc/api/study.py @@ -2,7 +2,6 @@ from datetime import datetime from flask import g, send_file from sqlalchemy.exc import IntegrityError - from crc import session from crc.api.common import ApiError, ApiErrorSchema from crc.models.study import Study, StudyEventType, StudyModel, StudySchema, StudyForUpdateSchema, \ @@ -15,6 +14,7 @@ from crc.services.user_service import UserService from crc.services.workflow_processor import WorkflowProcessor from crc.services.workflow_service import WorkflowService from crc.services.workflow_spec_service import WorkflowSpecService +from crc.api.user import verify_token import io @@ -117,11 +117,16 @@ def get_study_associates(study_id): def get_logs_for_study(study_id, body): task_log_query = TaskLogQuery(**body) + task_log_query.study_id = study_id # Force the study id return TaskLogQuerySchema().dump( TaskLoggingService.get_logs_for_study_paginated(study_id, task_log_query)) -def download_logs_for_study(study_id): +def download_logs_for_study(study_id, auth_token): + # Download links incorporate an auth token in the request for direct download + if not verify_token(auth_token): + raise ApiError('not_authenticated', 'You need to include an authorization token in the URL with this') + title = f'Study {study_id}' logs, headers = TaskLoggingService.get_log_data_for_download(study_id) spreadsheet = SpreadsheetService.create_spreadsheet(logs, headers, title) diff --git a/crc/models/task_log.py b/crc/models/task_log.py index 060f5578..a1171f00 100644 --- a/crc/models/task_log.py +++ b/crc/models/task_log.py @@ -1,6 +1,10 @@ import enum +import urllib +import flask import marshmallow +from flask import url_for +from marshmallow.fields import Method from crc import db, ma from crc.models.study import StudyModel @@ -62,9 +66,10 @@ class TaskLogModelSchema(ma.Schema): class TaskLogQuery: """Encapsulates the paginated queries and results when retrieving and filtering task logs over the API""" - def __init__(self, code="", level="", user="", page=1, per_page=10, + def __init__(self, study_id=None, code="", level="", user="", page=0, per_page=10, sort_column=None, sort_reverse=False, items=None, - pages=0, total=0, has_next=False, has_prev=False): + pages=0, total=0, has_next=False, has_prev=False, download_url=None): + self.study_id = study_id # Filter on Study. self.code = code # Filter on code. self.level = level # Filter on level. self.user = user # Filter on user. @@ -77,11 +82,12 @@ class TaskLogQuery: self.pages = pages self.has_next = False self.has_prev = False + self.download_url = None def update_from_sqlalchemy_paginator(self, paginator): """Updates this with results that are returned from the paginator""" self.items = paginator.items - self.page = paginator.page + self.page = paginator.page - 1 self.per_page = paginator.per_page self.pages = paginator.pages self.has_next = paginator.has_next @@ -94,5 +100,18 @@ class TaskLogQuerySchema(ma.Schema): model = TaskLogModel fields = ["code", "level", "user", "page", "per_page", "sort_column", "sort_reverse", "items", "pages", "total", - "has_next", "has_prev"] - items = marshmallow.fields.List(marshmallow.fields.Nested(TaskLogModelSchema)) \ No newline at end of file + "has_next", "has_prev", "download_url"] + items = marshmallow.fields.List(marshmallow.fields.Nested(TaskLogModelSchema)) + download_url = Method("get_url") + + def get_url(self, obj): + token = 'not_available' + if hasattr(obj, 'study_id') and obj.study_id is not None: + file_url = url_for("/v1_0.crc_api_study_download_logs_for_study", study_id=obj.study_id, _external=True) + if hasattr(flask.g, 'user'): + token = flask.g.user.encode_auth_token() + url = file_url + '?auth_token=' + urllib.parse.quote_plus(token) + return url + else: + return "" + diff --git a/crc/services/task_logging_service.py b/crc/services/task_logging_service.py index f28ae101..1d0f1e5e 100644 --- a/crc/services/task_logging_service.py +++ b/crc/services/task_logging_service.py @@ -89,7 +89,7 @@ class TaskLoggingService(object): sort_column = desc(task_log_query.sort_column) else: sort_column = task_log_query.sort_column - paginator = sql_query.order_by(sort_column).paginate(task_log_query.page, task_log_query.per_page, + paginator = sql_query.order_by(sort_column).paginate(task_log_query.page + 1, task_log_query.per_page, error_out=False) task_log_query.update_from_sqlalchemy_paginator(paginator) return task_log_query diff --git a/tests/scripts/test_task_logging.py b/tests/scripts/test_task_logging.py index af37ba46..e66cdcf0 100644 --- a/tests/scripts/test_task_logging.py +++ b/tests/scripts/test_task_logging.py @@ -153,7 +153,7 @@ class TestTaskLogging(BaseTest): logs = TaskLoggingService().get_logs_for_study_paginated(study.id, TaskLogQuery(per_page=5)) self.assertEqual(40, logs.total) self.assertEqual(5, len(logs.items), "I can limit results to 5") - self.assertEqual(1, logs.page) + self.assertEqual(0, logs.page) self.assertEqual(8, logs.pages) self.assertEqual(5, logs.per_page) self.assertEqual(True, logs.has_next) @@ -165,4 +165,6 @@ class TestTaskLogging(BaseTest): logs = TaskLoggingService.get_logs_for_study_paginated(study.id, TaskLogQuery(per_page=5, sort_column="level", sort_reverse=True)) for i in range(0, 5): - self.assertEqual('info', logs.items[i].level, "It is possible to sort on a column") \ No newline at end of file + self.assertEqual('info', logs.items[i].level, "It is possible to sort on a column") + + diff --git a/tests/study/test_study_download_logs.py b/tests/study/test_study_download_logs.py index 87bd3681..bd2e6b1d 100644 --- a/tests/study/test_study_download_logs.py +++ b/tests/study/test_study_download_logs.py @@ -1,7 +1,10 @@ +import json + from tests.base_test import BaseTest from crc import session -from crc.models.task_log import TaskLogModel +from crc.models.task_log import TaskLogModel, TaskLogQuery, TaskLogQuerySchema +from crc.models.user import UserModel from openpyxl import load_workbook from io import BytesIO @@ -40,10 +43,19 @@ class TestDownloadLogsForStudy(BaseTest): 'message': 'This is a third message.'} self.add_log(study_id, workflow.id, task.name, 'empty_workflow', log_data) - rv = self.app.get(f'/v1.0/study/{study_id}/log/download', - content_type="application/json", - headers=self.logged_in_headers()) + # Run the query, which should include a 'download_url' link that we can click on. + url = f'/v1.0/study/{workflow.study_id}/log' + task_log_query = TaskLogQuery() + user = session.query(UserModel).filter_by(uid=self.test_uid).first() + rv = self.app.put(url, headers=self.logged_in_headers(user), content_type="application/json", + data=TaskLogQuerySchema().dump(task_log_query)) + self.assert_success(rv) + log_query = json.loads(rv.get_data(as_text=True)) + self.assertIsNotNone(log_query['download_url']) + # Use the provided link to get the file. + rv = self.app.get(log_query['download_url']) + self.assert_success(rv) wb = load_workbook(BytesIO(rv.data)) ws = wb.active From 0510db38f1e23482cac784eaf0d78226ec066503 Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 12 Mar 2022 16:22:45 -0500 Subject: [PATCH 19/19] Dealing with merge of migrations. --- .../28752ce0775c_merge_conflicting_heads.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 migrations/versions/28752ce0775c_merge_conflicting_heads.py diff --git a/migrations/versions/28752ce0775c_merge_conflicting_heads.py b/migrations/versions/28752ce0775c_merge_conflicting_heads.py new file mode 100644 index 00000000..1042bdf2 --- /dev/null +++ b/migrations/versions/28752ce0775c_merge_conflicting_heads.py @@ -0,0 +1,24 @@ +"""merge conflicting heads + +Revision ID: 28752ce0775c +Revises: f214ee53ca26, d9a34e9d7cfa +Create Date: 2022-03-12 16:22:17.724988 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '28752ce0775c' +down_revision = ('f214ee53ca26', 'd9a34e9d7cfa') +branch_labels = None +depends_on = None + + +def upgrade(): + pass + + +def downgrade(): + pass