diff --git a/app/commands/file.py b/app/commands/file.py index b3590f3d..e83baa27 100644 --- a/app/commands/file.py +++ b/app/commands/file.py @@ -167,8 +167,7 @@ def file_put(**kwargs): # noqa: C901 message_handler.SrvOutPutHandler.cancel_upload() exit(1) - project_path = click.prompt('ProjectCode') if not project_path else project_path - project_code, target_folder = identify_target_folder(project_path) + project_code, folder_type, target_folder = identify_target_folder(project_path) srv_manifest = SrvFileManifests() upload_val_event = { 'zone': zone, @@ -207,8 +206,8 @@ def file_put(**kwargs): # noqa: C901 f, target_folder, project_code, + folder_type, zone, - zipping, ) upload_event = { diff --git a/app/models/item.py b/app/models/item.py new file mode 100644 index 00000000..a7a91029 --- /dev/null +++ b/app/models/item.py @@ -0,0 +1,38 @@ +# Copyright (C) 2023-2024 Indoc Systems +# +# Contact Indoc Systems for any questions regarding the use of this source code. + +from enum import Enum + + +class ItemType(str, Enum): + """The class to reflect the type of item in database.""" + + FILE = 'file' + Folder = 'folder' + NAMEFOLDER = 'name_folder' + SHAREDFOLDER = 'project_folder' + + @classmethod + def get_type_from_keyword(self, keyword: str): + """The function will return the type of the item based on the keyword. + + - name folder will have keyword 'namefolder' as input + - project folder will not have any keyword + """ + + alternative_mapping = { + 'projectfolder': self.SHAREDFOLDER, + } + + return alternative_mapping.get(keyword, self.NAMEFOLDER) + + def get_prefix_by_type(self) -> str: + """Get the prefix for the folder type.""" + + prefix = { + self.NAMEFOLDER: '', + self.SHAREDFOLDER: 'shared/', + } + + return prefix.get(self.value, '') diff --git a/app/resources/custom_help.py b/app/resources/custom_help.py index 75e77563..3e4b17e6 100644 --- a/app/resources/custom_help.py +++ b/app/resources/custom_help.py @@ -43,7 +43,11 @@ class HelpPage: 'FILE_SYNC_ZIP': 'Download files as a zip.', 'FILE_SYNC_I': 'Enable downloading by geid.', 'FILE_SYNC_Z': 'Target Zone (i.e., core/greenroom).', - 'FILE_UPLOAD_P': 'Project folder path starting from Project Code. (i.e., indoctestproject/user/folder)', + 'FILE_UPLOAD_P': ( + 'Project folder path starting from Project Code(i.e. /user/folder). ' + 'A new key word `projectfolder` is required to specify project folder(i.e. ' + '/projectfolder/folder1)' + ), 'FILE_UPLOAD_A': 'Add attributes to the file using a File Attribute Template.', 'FILE_UPLOAD_T': 'Add tags to the file using a Tag file.', 'FILE_UPLOAD_M': 'The message used to comment on the purpose of uploading your processed file.', diff --git a/app/services/file_manager/file_list.py b/app/services/file_manager/file_list.py index 39982cd9..2131903a 100644 --- a/app/services/file_manager/file_list.py +++ b/app/services/file_manager/file_list.py @@ -9,6 +9,7 @@ import app.services.logger_services.log_functions as logger from app.configs.app_config import AppConfig from app.configs.user_config import UserConfig +from app.models.item import ItemType from app.models.service_meta_class import MetaService from app.services.output_manager.error_handler import ECustomizedError from app.services.output_manager.error_handler import SrvErrorHandler @@ -59,10 +60,20 @@ def list_files(self, paths, zone, page, page_size): # then format the console output files, folders = '', '' for f in res: - if 'file' == f.get('type'): + item_type = ItemType(f.get('type')) + # if there is space within the nane add double quotation to aviod confusion + if ' ' in f.get('name'): + f['name'] = f'"{f.get("name")}"' + + if item_type == ItemType.FILE: files = files + f.get('name') + ' ...' - elif f.get('type') in ['folder', 'name_folder', 'project_folder']: + else: + # add [p] in front of the project folder + if item_type == ItemType.SHAREDFOLDER: + f['name'] = f'[p]{f.get("name")}' + folders = folders + f"\033[34m{f.get('name')}\033[0m ..." + f_string = folders + files return f_string diff --git a/app/services/file_manager/file_upload/file_upload.py b/app/services/file_manager/file_upload/file_upload.py index 15cd5219..952ec259 100644 --- a/app/services/file_manager/file_upload/file_upload.py +++ b/app/services/file_manager/file_upload/file_upload.py @@ -18,6 +18,7 @@ import app.services.logger_services.log_functions as logger import app.services.output_manager.message_handler as mhandler from app.configs.app_config import AppConfig +from app.models.item import ItemType from app.services.file_manager.file_upload.models import FileObject from app.services.file_manager.file_upload.models import ItemStatus from app.services.file_manager.file_upload.models import UploadType @@ -43,7 +44,7 @@ def compress_folder_to_zip(path): def assemble_path( - f: str, target_folder: str, project_code: str, zone: str, zipping: bool = False + f: str, target_folder: str, project_code: str, folder_type: ItemType, zone: str ) -> Tuple[str, Dict, bool, str]: ''' Summary: @@ -61,7 +62,6 @@ def assemble_path( - target_folder(str): the folder on the platform - project_code(str): the unique identifier of project - zone(str): the zone label eg.greenroom/core - - zipping(bool): default False. The flag to indicate if upload as a zip Return: - current_file_path: the format file path on platform - parent_folder: the item information of longest parent folder @@ -72,18 +72,16 @@ def assemble_path( current_file_path = target_folder + '/' + f.rstrip('/').split('/')[-1] # set name folder as first parent folder - name_folder = target_folder.split('/')[0] - parent_folder = search_item(project_code, zone, name_folder).get('result', {}) + root_folder = target_folder.split('/')[0] + parent_folder = search_item(project_code, zone, root_folder).get('result', {}) # if f input is a file then current_folder_node is target_folder # otherwise it is target_folder + f input name current_folder_node = target_folder if os.path.isfile(f) else current_file_path create_folder_flag = False - # always add `shared/` as prefix to folder/file if - # they directly under the project root folder - if parent_folder.get('type') == 'project_folder': - current_folder_node = 'shared/' + current_folder_node - target_folder = 'shared/' + target_folder + # add prefix to folder + current_folder_node = folder_type.get_prefix_by_type() + current_folder_node + target_folder = folder_type.get_prefix_by_type() + target_folder if len(current_file_path.split('/')) > 2: sub_path = target_folder.split('/') diff --git a/app/utils/aggregated.py b/app/utils/aggregated.py index 83bd7520..e338ae5f 100644 --- a/app/utils/aggregated.py +++ b/app/utils/aggregated.py @@ -8,6 +8,7 @@ from typing import Any from typing import Dict from typing import List +from typing import Tuple import httpx import requests @@ -16,6 +17,7 @@ from app.configs.app_config import AppConfig from app.configs.config import ConfigClass from app.configs.user_config import UserConfig +from app.models.item import ItemType from app.services.output_manager.error_handler import ECustomizedError from app.services.output_manager.error_handler import SrvErrorHandler from app.services.user_authentication.decorator import require_valid_token @@ -164,19 +166,48 @@ def get_file_in_folder(path): return files_list -def identify_target_folder(project_path): - project_code = project_path.split('/')[0] - if len(project_path.split('/')) > 1: - target_folder = '/'.join(project_path.split('/')[1:]) - for f in target_folder.split('/'): - f = f.strip(' ') - valid = validate_folder_name(f) - if not valid: - SrvErrorHandler.customized_handle(ECustomizedError.INVALID_FOLDERNAME, True) +def identify_target_folder(project_path: str) -> Tuple[str, ItemType, str]: + ''' + Summary: + the function will validate if input folder path doesn't + contain invalid characters and return the project code and target folder + Parameters: + - project_path: + - for project folder the input folder path (eg. /projectfolder/) + - for name folder the input folder path will be (eg. /) + Return: + - project_code: the project code + - folder_type: the folder type + - target_folder: the target folder + ''' + # split into project_code, folder_type, folder + temp_paths = project_path.split('/', 2) + project_code, folder_type, folder_name = temp_paths[0], '', '' + + # check folder type if is project folder or name folder + # there will be a extra string for project folder between project code and folder name + if len(temp_paths) == 2: + folder_type = ItemType.NAMEFOLDER + folder_name = temp_paths[1] + elif len(temp_paths) >= 3: + folder_type = ItemType.get_type_from_keyword(temp_paths[1]) + if folder_type == ItemType.SHAREDFOLDER: + folder_name = temp_paths[2] + else: + folder_name = os.path.join(temp_paths[1], temp_paths[2]) else: SrvErrorHandler.customized_handle(ECustomizedError.INVALID_NAMEFOLDER, True) target_folder = '' - return project_code, target_folder + + # first check if folder names are valid + target_folder = '/'.join(folder_name.split('/')) + for f in target_folder.split('/'): + f = f.strip(' ') + valid = validate_folder_name(f) + if not valid: + SrvErrorHandler.customized_handle(ECustomizedError.INVALID_FOLDERNAME, True) + + return project_code, folder_type, target_folder def batch_generator(iterable: List[Any], batch_size=1): diff --git a/pyproject.toml b/pyproject.toml index e91b9d00..61fd3306 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "app" -version = "2.9.11" +version = "2.10.2" description = "This service is designed to support pilot platform" authors = ["Indoc Systems"] diff --git a/tests/app/commands/test_file.py b/tests/app/commands/test_file.py index d90912d1..ab9e64bc 100644 --- a/tests/app/commands/test_file.py +++ b/tests/app/commands/test_file.py @@ -24,10 +24,6 @@ def test_file_upload_command_success_with_attribute(mocker, cli_runner): - project_code = 'test_project' - target_folder = 'admin' - - mocker.patch('app.commands.file.identify_target_folder', return_value=(project_code, target_folder)) mocker.patch('app.commands.file.validate_upload_event', return_value={'source_file': '', 'attribute': 'test'}) mocker.patch('app.commands.file.assemble_path', return_value=('test', {'id': 'id'}, True, 'test')) @@ -48,8 +44,10 @@ def test_file_upload_command_success_with_attribute(mocker, cli_runner): json.dump({'template': {'attr1': 'value'}}, f) result = cli_runner.invoke( - file_put, ['--project-path', 'test', '--thread', 1, '--attribute', 'template.json', 'test.txt'] + file_put, + ['--project-path', 'test_project/admin', '--thread', 1, '--attribute', 'template.json', 'test.txt'], ) + assert result.exit_code == 0 simple_upload_mock.assert_called_once() attribute_mock.assert_called_once() @@ -187,7 +185,9 @@ def test_file_list_with_pagination_with_name_project_folder(requests_mock, mocke 'result': [ {'type': 'folder', 'name': 'folder1'}, {'type': 'name_folder', 'name': 'name_folder1'}, - {'type': 'project_folder', 'name': 'project_folder1'}, + {'type': 'project_folder', 'name': 'project folder1'}, + {'type': 'folder', 'name': 'test folder2'}, + {'type': 'project_folder', 'name': 'project folder2'}, ], }, ) @@ -195,7 +195,8 @@ def test_file_list_with_pagination_with_name_project_folder(requests_mock, mocke questionary.select.return_value.ask.return_value = 'exit' result = cli_runner.invoke(file_list, ['testproject/admin', '-z', 'greenroom']) outputs = result.output.split('\n') - assert outputs[0] == 'folder1 name_folder1 project_folder1 ' + assert outputs[0] == 'folder1 name_folder1 [p]"project folder1" ' + assert outputs[1] == '"test folder2" [p]"project folder2" ' def test_empty_file_list_with_pagination(requests_mock, mocker, cli_runner): diff --git a/tests/app/services/file_manager/file_upload/test_file_upload.py b/tests/app/services/file_manager/file_upload/test_file_upload.py index 17b3e474..fb385057 100644 --- a/tests/app/services/file_manager/file_upload/test_file_upload.py +++ b/tests/app/services/file_manager/file_upload/test_file_upload.py @@ -3,6 +3,7 @@ # Contact Indoc Systems for any questions regarding the use of this source code. from app.configs.app_config import AppConfig +from app.models.item import ItemType from app.services.file_manager.file_upload.file_upload import assemble_path from app.services.file_manager.file_upload.file_upload import resume_upload from app.services.file_manager.file_upload.file_upload import simple_upload @@ -17,7 +18,6 @@ def test_assemble_path_at_name_folder(mocker): target_folder = 'admin' project_code = 'test_project' zone = 0 - resumable_id = None mocker.patch( 'app.services.file_manager.file_upload.file_upload.search_item', @@ -34,7 +34,7 @@ def test_assemble_path_at_name_folder(mocker): ) current_file_path, parent_folder, create_folder_flag, _ = assemble_path( - local_file_path, target_folder, project_code, zone, resumable_id + local_file_path, target_folder, project_code, ItemType.NAMEFOLDER, zone ) assert current_file_path == 'admin/file.txt' assert parent_folder.get('name') == 'admin' @@ -46,7 +46,6 @@ def test_assemble_path_at_exsting_folder(mocker): target_folder = 'admin/test_folder_exist' project_code = 'test_project' zone = 0 - resumable_id = None node_list = [ { @@ -72,9 +71,8 @@ def test_assemble_path_at_exsting_folder(mocker): ] mocker.patch('app.services.file_manager.file_upload.file_upload.search_item', side_effect=node_list) - current_file_path, parent_folder, create_folder_flag, _ = assemble_path( - local_file_path, target_folder, project_code, zone, resumable_id + local_file_path, target_folder, project_code, ItemType.NAMEFOLDER, zone ) assert current_file_path == 'admin/test_folder_exist/file.txt' assert parent_folder.get('name') == 'test_folder_exist' @@ -86,7 +84,6 @@ def test_assemble_path_at_non_existing_folder(mocker): target_folder = 'admin/test_folder_not_exist' project_code = 'test_project' zone = 0 - resumable_id = None node_list = [ { @@ -106,7 +103,7 @@ def test_assemble_path_at_non_existing_folder(mocker): mocker.patch('app.services.file_manager.file_upload.file_upload.click.confirm', return_value=None) current_file_path, parent_folder, create_folder_flag, _ = assemble_path( - local_file_path, target_folder, project_code, zone, resumable_id + local_file_path, target_folder, project_code, ItemType.NAMEFOLDER, zone ) assert current_file_path == 'admin/test_folder_not_exist' assert parent_folder.get('name') == 'admin' @@ -118,7 +115,6 @@ def test_assemble_path_at_project_folder(mocker): target_folder = 'project_folder' project_code = 'test_project' zone = 0 - resumable_id = None mocker.patch( 'app.services.file_manager.file_upload.file_upload.search_item', @@ -135,7 +131,7 @@ def test_assemble_path_at_project_folder(mocker): ) current_file_path, parent_folder, create_folder_flag, target_folder = assemble_path( - local_file_path, target_folder, project_code, zone, resumable_id + local_file_path, target_folder, project_code, ItemType.SHAREDFOLDER, zone ) assert current_file_path == 'shared/project_folder/file.txt' assert parent_folder.get('name') == 'project_folder' diff --git a/tests/app/utils/test_aggregated.py b/tests/app/utils/test_aggregated.py index 00ec31e5..91454cbb 100644 --- a/tests/app/utils/test_aggregated.py +++ b/tests/app/utils/test_aggregated.py @@ -5,7 +5,9 @@ import pytest from app.configs.app_config import AppConfig +from app.models.item import ItemType from app.utils.aggregated import check_item_duplication +from app.utils.aggregated import identify_target_folder from app.utils.aggregated import search_item from app.utils.aggregated import validate_folder_name from tests.conftest import decoded_token @@ -129,3 +131,24 @@ def test_check_duplicate_fail_with_error_code(httpx_mock, mocker, capsys): def test_validate_folder_name(folder_name): valid = validate_folder_name(folder_name) assert valid is False + + +@pytest.mark.parametrize( + 'input_path,expected_result', + [ + ('project_code/username', ('project_code', ItemType.NAMEFOLDER, 'username')), + ('project_code/username/folder1', ('project_code', ItemType.NAMEFOLDER, 'username/folder1')), + ('project_code/projectfolder/folder1', ('project_code', ItemType.SHAREDFOLDER, 'folder1')), + ('project_code/projectfolder/folder1/folder2', ('project_code', ItemType.SHAREDFOLDER, 'folder1/folder2')), + ], +) +def test_identify_target_folder_success_with_different_path(mocker, input_path, expected_result): + mocker.patch('app.utils.aggregated.validate_folder_name', return_value=True) + result = identify_target_folder(input_path) + assert result == expected_result + + +def test_identify_target_folder_fail_with_invalid_input(mocker): + mocker.patch('app.utils.aggregated.validate_folder_name', return_value=False) + with pytest.raises(SystemExit): + identify_target_folder('project_code')