diff --git a/SPRINTLOG.md b/SPRINTLOG.md index 4a206ccd..f9343f85 100644 --- a/SPRINTLOG.md +++ b/SPRINTLOG.md @@ -305,5 +305,6 @@ _Nothing merged in CLI during this sprint_ # 2023-10-16 - 2023-10-27 -- Change "Checksum verification successful. File integrity verified." logging level from INFO to DEBUG in order to not print for all files ([#662])(https://github.com/ScilifelabDataCentre/dds_cli/pull/661) +- Change "Checksum verification successful. File integrity verified." logging level from INFO to DEBUG in order to not print for all files ([#662](https://github.com/ScilifelabDataCentre/dds_cli/pull/662) +- New command `dds project status extend` to allow extension of project deadline ([#661](https://github.com/ScilifelabDataCentre/dds_cli/pull/661) - New version: 2.5.2 ([#660](https://github.com/ScilifelabDataCentre/dds_cli/pull/660)) diff --git a/dds_cli/__main__.py b/dds_cli/__main__.py index 86fa6fb7..fafc964d 100644 --- a/dds_cli/__main__.py +++ b/dds_cli/__main__.py @@ -1243,6 +1243,40 @@ def delete_project(click_ctx, project: str): sys.exit(1) +# -- dds project status extend -- # +@project_status.command(name="extend", no_args_is_help=True) +# Options +@project_option(required=True) +@click.option( + "--new-deadline", + required=False, + type=int, + help="Number of days to extend the deadline.", +) +@click.pass_obj +def extend_deadline(click_ctx, project: str, new_deadline: int): + """Extend a project deadline by an specified number of days. + + It consumes one of allowed times to renew data access. + """ + try: + with dds_cli.project_status.ProjectStatusManager( + project=project, + no_prompt=click_ctx.get("NO_PROMPT", False), + token_path=click_ctx.get("TOKEN_PATH"), + ) as updater: + updater.extend_deadline(new_deadline=new_deadline) + except ( + dds_cli.exceptions.APIError, + dds_cli.exceptions.AuthenticationError, + dds_cli.exceptions.DDSCLIException, + dds_cli.exceptions.ApiResponseError, + dds_cli.exceptions.ApiRequestError, + ) as err: + LOG.error(err) + sys.exit(1) + + # -- dds project status busy -- # @project_status.command(name="busy", no_args_is_help=False) # Flags diff --git a/dds_cli/project_status.py b/dds_cli/project_status.py index c06e4b42..253a61e2 100644 --- a/dds_cli/project_status.py +++ b/dds_cli/project_status.py @@ -3,6 +3,7 @@ import logging import typing import sys +from dateutil.parser import parse # Installed import pytz @@ -148,6 +149,117 @@ def update_status(self, new_status, deadline=None, is_aborted=False, no_mail=Fal dds_cli.utils.console.print(f"Project {response_json.get('message')}") + def extend_deadline(self, new_deadline=None): + """Extend the project deadline.""" + # Define initial parameters + extra_params = {"send_email": False} + + # Fetch project status and default deadline + response_json, _ = dds_cli.utils.perform_request( + endpoint=DDSEndpoint.UPDATE_PROJ_STATUS, + headers=self.token, + method="patch", + params={"project": self.project}, + json=extra_params, + error_message="Failed to extend project deadline", + ) + + # Structure of the response: + # { + # 'default_unit_days': 30, + # 'project_info': { + # 'Created by': 'First Unit User', + # 'Description': 'This is a test project', + # 'Last updated': 'Wed, 18 Oct 2023 08:40:43 GMT', + # 'PI': 'support@example.com', + # 'Project ID': 'project_1', + # 'Size': 0, + # 'Status': 'Available', + # 'Title': 'First Project' + # }, + # 'project_status': { + # 'current_deadline': 'Sat, 04 Nov 2023 23:59:59 GMT', + # 'current_status': 'Available'}, + # } + + # Check that the returned information was ok + keys = ["project_info", "project_status", "default_unit_days"] + ( + project_info, + project_status, + default_unit_days, + *_, + ) = dds_cli.utils.get_required_in_response(keys=keys, response=response_json) + + # Check and extract the required information for the operation + current_status, *_ = dds_cli.utils.get_required_in_response( + keys=["current_status"], response=project_status + ) + + # if the project is still in progress it won't have a current_deadline parameter + if not current_status == "Available": + raise exceptions.DDSCLIException( + "You can only extend the deadline for a project that has the status 'Available'." + ) + + current_deadline, *_ = dds_cli.utils.get_required_in_response( + keys=["current_deadline"], response=project_status + ) + project_id, *_ = dds_cli.utils.get_required_in_response( + keys=["Project ID"], response=project_info + ) + + # print information about the project status and table with the project info + print_info = ( + f"\nCurrent deadline: [b][green]{current_deadline}[/green][/b]\n" + f"Default deadline extension: [b][green]{default_unit_days}[/green][/b] days\n" + ) + table = self.generate_project_table(project_info=project_info) + dds_cli.utils.console.print(table) + dds_cli.utils.console.print(print_info) + + # If it wasnt provided during the command click, ask the user for the new deadline + if not new_deadline: + # Question number of days to extend the deadline + prompt_question = ( + "How many days would you like to extend the project deadline with? " + "Leave empty in order to choose the default" + ) + new_deadline = rich.prompt.IntPrompt.ask(prompt_question, default=default_unit_days) + + # Confirm operation question + new_deadline_date = parse(current_deadline) + datetime.timedelta(days=new_deadline) + new_deadline_date = new_deadline_date.strftime("%a,%d %b %Y %H:%M:%S") + prompt_question = ( + f"\nThe new deadline for project {project_id} will be: [b][blue]{new_deadline_date}[/b][/blue]" + "\n\n[b][blue]Are you sure [/b][/blue]you want to perform this operation? " + "\nYou can only extend the data availability a maximum of " + "[b][blue]3 times[/b][/blue], this consumes one of those times." + ) + + if not rich.prompt.Confirm.ask(prompt_question): + LOG.info("Probably for the best. Exiting.") + sys.exit(0) + + # Update parameters for the second request + extra_params = {**extra_params, "confirmed": True, "new_deadline_in": new_deadline} + + response_json, _ = dds_cli.utils.perform_request( + endpoint=DDSEndpoint.UPDATE_PROJ_STATUS, + headers=self.token, + method="patch", + params={"project": self.project}, + json=extra_params, + error_message="Failed to extend project deadline", + ) + message = response_json.get("message") + if not message: + raise exceptions.DDSCLIException( + "No message returned from API. Cannot verify extension of project deadline." + ) + + LOG.info(message) + class ProjectBusyStatusManager(base.DDSBaseClass): """Project Busy Status manager class.""" diff --git a/dds_cli/utils.py b/dds_cli/utils.py index c04e487d..1b004537 100644 --- a/dds_cli/utils.py +++ b/dds_cli/utils.py @@ -165,6 +165,8 @@ def perform_request( request_method = requests.post elif method == "delete": request_method = requests.delete + elif method == "patch": + request_method = requests.patch def transform_paths(json_input): """Make paths serializable.""" diff --git a/tests/test_project_status.py b/tests/test_project_status.py index 0d76cc5d..46dead02 100644 --- a/tests/test_project_status.py +++ b/tests/test_project_status.py @@ -1,11 +1,13 @@ import pytest from requests_mock.mocker import Mocker +import unittest from dds_cli import DDSEndpoint from dds_cli import project_status from _pytest.logging import LogCaptureFixture from _pytest.capture import CaptureFixture import logging from dds_cli.exceptions import ApiResponseError, DDSCLIException +import datetime import typing @@ -34,7 +36,33 @@ "message": f"{project_name} updated to status Available. An e-mail notification has been sent." } + +deadline = str(datetime.datetime.now() + datetime.timedelta(days=1)) +default_unit_days = 30 +returned_response_extend_deadline_fetch_information = { + "project_info": returned_response_get_info, + "default_unit_days": default_unit_days, + "warning": "Operation must be confirmed before proceding.", + "project_status": {"current_deadline": deadline, "current_status": "Available"}, +} +returned_response_extend_deadline_fetch_information_in_progress = { + **returned_response_extend_deadline_fetch_information, + "project_status": {"current_status": "In progress"}, +} + +returned_response_extend_deadline_ok: typing.Dict = { + "message": f"Project {project_name} has been given a new deadline." +} + + ######### +def check_table_proj_info(table_output): + assert "┏━━━━━" in table_output.out # A table has generated + assert f"{returned_response_get_info['Project ID']}" in table_output.out + assert f"{returned_response_get_info['Created by']}" in table_output.out + assert f"{returned_response_get_info['Status']}" in table_output.out + assert f"{returned_response_get_info['Last updated']}" in table_output.out + assert f"{returned_response_get_info['Size']}" in table_output.out def perform_archive_delete_operation(new_status, confirmed, mock, json_project_info=None): @@ -75,12 +103,21 @@ def check_output_project_info(new_status, captured_output, caplog_tuples=None): assert f"{project_name}" in captured_output.out assert f"{new_status}" - assert "┏━━━━━" in captured_output.out # A table has generated - assert f"{returned_response_get_info['Project ID']}" in captured_output.out - assert f"{returned_response_get_info['Created by']}" in captured_output.out - assert f"{returned_response_get_info['Status']}" in captured_output.out - assert f"{returned_response_get_info['Last updated']}" in captured_output.out - assert f"{returned_response_get_info['Size']}" in captured_output.out + check_table_proj_info(table_output=captured_output) + # if not confirmed operation + if caplog_tuples: + assert ( + "dds_cli.project_status", + logging.INFO, + "Probably for the best. Exiting.", + ) in caplog_tuples + + +def check_output_extend_deadline(captured_output, caplog_tuples=None): + assert "Current deadline:" in captured_output.out + assert "Default deadline extension:" in captured_output.out + + check_table_proj_info(table_output=captured_output) # if not confirmed operation if caplog_tuples: @@ -296,3 +333,158 @@ def test_update_extra_params(capsys: CaptureFixture, monkeypatch, caplog: LogCap status_mngr.update_status(new_status="Archived", is_aborted=True, deadline=10) assert returned_response_archived_ok["message"] in capsys.readouterr().out + + +def test_extend_deadline_no_confirmed( + capsys: CaptureFixture, monkeypatch, caplog: LogCaptureFixture +): + """The user decided to not accept the extension""" + + confirmed = False + caplog.set_level(logging.INFO) + + # Create mocker + with Mocker() as mock: + # set confirmation object to false + monkeypatch.setattr("rich.prompt.Confirm.ask", lambda question: confirmed) + # Set number of days to extend deadline - Bc of the default value in the function we need to create a mock answer + with unittest.mock.patch("rich.prompt.IntPrompt.ask") as deadline: + deadline.return_value = 2 + + # Create first mocked request - not confirmed + mock.patch( + DDSEndpoint.UPDATE_PROJ_STATUS, + status_code=200, + json=returned_response_extend_deadline_fetch_information, + ) + + # capture system exit on not accepting operation + with pytest.raises(SystemExit): + with project_status.ProjectStatusManager( + project=project_name, no_prompt=True, authenticate=False + ) as status_mngr: + status_mngr.token = {} # required, otherwise none + status_mngr.extend_deadline() + + check_output_extend_deadline( + captured_output=capsys.readouterr(), caplog_tuples=caplog.record_tuples + ) + + +def test_extend_deadline_no_available( + capsys: CaptureFixture, monkeypatch, caplog: LogCaptureFixture +): + """If the project is not in status available the operation should fail""" + + confirmed = False + caplog.set_level(logging.INFO) + + # Create mocker + with Mocker() as mock: + # set confirmation object to false + monkeypatch.setattr("rich.prompt.Confirm.ask", lambda question: confirmed) + # Set number of days to extend deadline - Bc of the default value in the function we need to create a mock answer + with unittest.mock.patch("rich.prompt.IntPrompt.ask") as deadline: + deadline.return_value = 2 + + # Create first mocked request - not confirmed + mock.patch( + DDSEndpoint.UPDATE_PROJ_STATUS, + status_code=200, + json=returned_response_extend_deadline_fetch_information_in_progress, + ) + + with pytest.raises(DDSCLIException) as err: + with project_status.ProjectStatusManager( + project=project_name, no_prompt=True, authenticate=False + ) as status_mngr: + status_mngr.token = {} # required, otherwise none + status_mngr.extend_deadline() + + assert ( + "You can only extend the deadline for a project that has the status 'Available'." + in str(err.value) + ) + + +def test_extend_deadline_confirmed_ok( + capsys: CaptureFixture, monkeypatch, caplog: LogCaptureFixture +): + """test that the operation is performed - ok""" + + confirmed = True + caplog.set_level(logging.INFO) + + # Create mocker + with Mocker() as mock: + # set confirmation object to true + monkeypatch.setattr("rich.prompt.Confirm.ask", lambda question: confirmed) + # Set number of days to extend deadline - Bc of the default value in the function we need to create a mock answer + with unittest.mock.patch("rich.prompt.IntPrompt.ask") as deadline: + deadline.return_value = default_unit_days - 1 + + # Mock a dyanic request, the second call should return a different response thatn the first one (operation is confirmed) + mock.patch( + DDSEndpoint.UPDATE_PROJ_STATUS, + [ + { + "status_code": 200, + "json": returned_response_extend_deadline_fetch_information, + }, + {"status_code": 200, "json": returned_response_extend_deadline_ok}, + ], + ) + + with project_status.ProjectStatusManager( + project=project_name, no_prompt=True, authenticate=False + ) as status_mngr: + status_mngr.token = {} # required, otherwise none + status_mngr.extend_deadline() + + captured_output = capsys.readouterr() + assert ( + "dds_cli.project_status", + logging.INFO, + returned_response_extend_deadline_ok["message"], + ) in caplog.record_tuples + check_output_extend_deadline(captured_output=captured_output, caplog_tuples=None) + + +def test_extend_deadline_no_msg_returned_request( + capsys: CaptureFixture, monkeypatch, caplog: LogCaptureFixture +): + """Error - no message returned from request""" + + confirmed = True + caplog.set_level(logging.INFO) + + # Create mocker + with Mocker() as mock: + # set confirmation object to true + monkeypatch.setattr("rich.prompt.Confirm.ask", lambda question: confirmed) + # Set number of days to extend deadline - Bc of the default value in the function we need to create a mock answer + with unittest.mock.patch("rich.prompt.IntPrompt.ask") as deadline: + deadline.return_value = 1 + + # Mock a dyanic request, the second call should return a different response thatn the first one (operation is confirmed) + mock.patch( + DDSEndpoint.UPDATE_PROJ_STATUS, + [ + { + "status_code": 200, + "json": returned_response_extend_deadline_fetch_information, + }, + {"status_code": 200, "json": {}}, # empty response + ], + ) + + with pytest.raises(DDSCLIException) as err: + with project_status.ProjectStatusManager( + project=project_name, no_prompt=True, authenticate=False + ) as status_mngr: + status_mngr.token = {} # required, otherwise none + status_mngr.extend_deadline() + + captured_output = capsys.readouterr() + check_output_extend_deadline(captured_output=captured_output, caplog_tuples=None) + assert "No message returned from API." in str(err.value)