diff --git a/docs/tethys_cli.rst b/docs/tethys_cli.rst index 8a100fbf1..fa2df66b9 100644 --- a/docs/tethys_cli.rst +++ b/docs/tethys_cli.rst @@ -26,6 +26,7 @@ Command Line Interface tethys_cli/link tethys_cli/list tethys_cli/manage + tethys_cli/paths tethys_cli/scaffold tethys_cli/schedulers tethys_cli/services diff --git a/docs/tethys_cli/paths.rst b/docs/tethys_cli/paths.rst new file mode 100644 index 000000000..88c7e93f5 --- /dev/null +++ b/docs/tethys_cli/paths.rst @@ -0,0 +1,13 @@ +.. _paths_cmd: + +paths command +************** +Get information about Tethys paths from the Tethys Paths API and manage them. It can be used to list paths for specific apps or users and add files to those destinations. + +For more info on the Paths API, see: :ref:`tethys_paths_api`. + +.. argparse:: + :module: tethys_cli + :func: tethys_command_parser + :prog: tethys + :path: paths \ No newline at end of file diff --git a/docs/tethys_sdk/paths.rst b/docs/tethys_sdk/paths.rst index cfefd25ba..f12198622 100644 --- a/docs/tethys_sdk/paths.rst +++ b/docs/tethys_sdk/paths.rst @@ -236,6 +236,63 @@ For the ``workspace`` and ``media`` paths the location of the paths from all app The ``public`` and the ``resources`` directories are relative to the source code of the app (i.e. not centralized). Even when the ``collectstatic`` command is used to copy all static files to a central location the :ref:`tethys_paths_api` will return the the public directory that is relative to the app source code. +Command Line Interface +========================== +The Paths API can be accessed through the command line interface (CLI) using the ``paths`` command. This command provides a way to list paths for specific apps or users and add files to those destinations. + +Examples +-------- + +**Command:** + +.. code-block:: bash + + tethys paths get -t app_workspace -a my_app + +**Output:** + +.. code-block:: console + + App Workspace for app 'my_app': + /home/user/.tethys/tethys/workspaces/my_app/app_workspace + +**Command:** + +.. code-block:: bash + + tethys paths get -t user_workspace -a my_app -u my_user + +**Output:** + +.. code-block:: console + + User Workspace for user 'my_user' and app 'my_app': + /home/user/.tethys/tethys/workspaces/my_app/user_workspaces/my_user + +**Command:** + +.. code-block:: bash + + tethys paths add -t user_media -a my_app -u my_user -f /path/to/file.txt + +**Output:** + +.. code-block:: console + + File 'file.txt' has been added to the User Media at '/home/user/.tethys/tethys/media/my_app/user/my_user/file.txt' + +**Command:** + +.. code-block:: bash + + tethys paths add -t app_media -a my_app -f /path/to/file.txt + +**Output:** + +.. code-block:: console + + File 'file.txt' has been added to the App Media at '/home/user/.tethys/tethys/media/my_app/app/file.txt'` + .. _tethys_quotas_workspace_manage: Handling Workspace/Media Clearing diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_paths.py b/tests/unit_tests/test_tethys_apps/test_base/test_paths.py index a1aff64e1..4325acda3 100644 --- a/tests/unit_tests/test_tethys_apps/test_base/test_paths.py +++ b/tests/unit_tests/test_tethys_apps/test_base/test_paths.py @@ -3,13 +3,14 @@ import unittest from unittest import mock +import sys + from django.conf import settings from django.http import HttpRequest from django.test import override_settings from django.core.exceptions import PermissionDenied from django.contrib.auth import get_user_model - import tethys_apps.base.app_base as tethys_app_base from tethys_apps.base import paths from tethys_apps.base.paths import TethysPath, _check_app_quota, _check_user_quota @@ -218,12 +219,10 @@ def setUp(self): self.mock_app_base_class = tethys_app_base.TethysAppBase() self.mock_request = mock.Mock(spec=HttpRequest) - self.mock_app_class = TethysApp(name="test_app", package="test_app") - self.mock_app = mock.MagicMock() + self.mock_app = TethysApp(name="test_app", package="test_app_package") self.user = User(username="tester") self.mock_request.user = self.user - self.mock_app.package = "app_package" def tearDown(self): pass @@ -243,9 +242,9 @@ def test_resolve_app_class(self, mock_get_active_app, mock_get_app_class): self.assertEqual(a2, self.mock_app) mock_get_active_app.assert_called_with(self.mock_request, get_class=True) - a3 = paths._resolve_app_class(self.mock_app_class) + a3 = paths._resolve_app_class(self.mock_app) self.assertEqual(a3, self.mock_app) - mock_get_app_class.assert_called_with(self.mock_app_class) + mock_get_app_class.assert_called_with(self.mock_app) with self.assertRaises(ValueError): paths._resolve_app_class(None) @@ -273,13 +272,11 @@ def test__get_user_workspace_unauthenticated(self, mock_ru, _): def test_get_app_workspace_root(self): p = paths._get_app_workspace_root(self.mock_app) - self.assertEqual(p, Path(settings.TETHYS_WORKSPACES_ROOT + "/app_package")) + self.assertEqual(p, Path(settings.TETHYS_WORKSPACES_ROOT + "/test_app_package")) @override_settings(USE_OLD_WORKSPACES_API=True) @override_settings(DEBUG=True) def test_old_get_app_workspace_root(self): - import sys - p = paths._get_app_workspace_root(self.mock_app) self.assertEqual(p, Path(sys.modules[self.mock_app.__module__].__file__).parent) @@ -289,8 +286,6 @@ def test_old_get_app_workspace_root(self): @mock.patch("tethys_apps.utilities.get_app_model") @mock.patch("tethys_apps.utilities.get_app_class") def test___get_app_workspace_old(self, mock_ac, mock_am, mock_tw): - import sys - mock_ac.return_value = self.mock_app mock_am.return_value = self.mock_app p = paths._get_app_workspace(self.mock_app_base_class, bypass_quota=True) @@ -318,8 +313,11 @@ def test__get_app_workspace_old( @override_settings(USE_OLD_WORKSPACES_API=True) @override_settings(DEBUG=True) @mock.patch("tethys_apps.base.workspace.TethysWorkspace") - def test___get_user_workspace_old(self, mock_tw): - import sys + @mock.patch("tethys_apps.utilities.get_app_model") + @mock.patch("tethys_apps.utilities.get_app_class") + def test___get_user_workspace_old(self, mock_ac, mock_am, mock_tw): + mock_am.return_value = self.mock_app + mock_ac.return_value = self.mock_app p = paths._get_user_workspace( self.mock_app_base_class, self.user, bypass_quota=True @@ -348,7 +346,7 @@ def test__get_user_workspace_old( @override_settings(MEDIA_ROOT="media_root") def test_get_app_media_root(self): p = paths._get_app_media_root(self.mock_app) - self.assertEqual(p, Path(settings.MEDIA_ROOT + "/app_package")) + self.assertEqual(p, Path(settings.MEDIA_ROOT + "/test_app_package")) @mock.patch("tethys_apps.utilities.get_app_model") @mock.patch("tethys_apps.base.paths.passes_quota", return_value=True) diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_workspace.py b/tests/unit_tests/test_tethys_apps/test_base/test_workspace.py index bffa5d5f6..0966c034b 100644 --- a/tests/unit_tests/test_tethys_apps/test_base/test_workspace.py +++ b/tests/unit_tests/test_tethys_apps/test_base/test_workspace.py @@ -1,4 +1,5 @@ import unittest +from tethys_apps.models import TethysApp import tethys_apps.base.workspace as base_workspace import shutil from pathlib import Path @@ -47,7 +48,9 @@ def setUp(self): self.test_root = self.root / "test_workspace" self.test_root_a = self.test_root / "test_workspace_a" self.test_root2 = self.root / "test_workspace2" - self.app = tethys_app_base.TethysAppBase() + self.app_base = tethys_app_base.TethysAppBase() + self.app = TethysApp(name="test_app", package="test_app") + self.user = UserFactory() def tearDown(self): @@ -150,26 +153,32 @@ def test_TethysWorkspace(self): self.assertEqual(str(self.test_root), workspace.path) @mock.patch("tethys_apps.base.workspace.TethysWorkspace") - def test__get_user_workspace_user(self, mock_tws): - ret = _get_user_workspace(self.app, self.user) + @mock.patch("tethys_apps.utilities.get_app_class") + def test__get_user_workspace_user(self, mock_gac, mock_tws): + mock_gac.return_value = self.app + ret = _get_user_workspace(self.app_base, self.user) expected_path = Path("workspaces") / "user_workspaces" / self.user.username rts_call_args = mock_tws.call_args_list self.assertEqual(ret, mock_tws()) self.assertIn(str(expected_path), rts_call_args[0][0][0]) @mock.patch("tethys_apps.base.workspace.TethysWorkspace") - def test__get_user_workspace_http(self, mock_tws): + @mock.patch("tethys_apps.utilities.get_app_class") + def test__get_user_workspace_http(self, mock_gac, mock_tws): request = HttpRequest() request.user = self.user - ret = _get_user_workspace(self.app, request) + mock_gac.return_value = self.app + ret = _get_user_workspace(self.app_base, request) expected_path = Path("workspaces") / "user_workspaces" / self.user.username rts_call_args = mock_tws.call_args_list self.assertEqual(ret, mock_tws()) self.assertIn(str(expected_path), rts_call_args[0][0][0]) @mock.patch("tethys_apps.base.workspace.TethysWorkspace") - def test__get_user_workspace_none(self, mock_tws): - ret = _get_user_workspace(self.app, None) + @mock.patch("tethys_apps.utilities.get_app_class") + def test__get_user_workspace_none(self, mock_gac, mock_tws): + mock_gac.return_value = self.app + ret = _get_user_workspace(self.app_base, None) expected_path = Path("workspaces") / "user_workspaces" / "anonymous_user" rts_call_args = mock_tws.call_args_list self.assertEqual(ret, mock_tws()) @@ -177,55 +186,61 @@ def test__get_user_workspace_none(self, mock_tws): def test__get_user_workspace_error(self): with self.assertRaises(ValueError) as context: - _get_user_workspace(self.app, "not_user_or_request") + _get_user_workspace(self.app_base, "not_user_or_request") self.assertEqual( str(context.exception), "Invalid type for argument 'user': must be either an User or HttpRequest object.", ) - def test__get_user_workspace_old_not_authenticated(self): + @mock.patch("tethys_apps.utilities.get_app_model") + def test__get_user_workspace_old_not_authenticated(self, mock_gam): request = HttpRequest() request.user = mock.MagicMock() request.user.is_anonymous = True + mock_gam.return_value = self.app with self.assertRaises(PermissionDenied) as err: - _get_user_workspace_old(self.app, request) + _get_user_workspace_old(self.app_base, request) self.assertEqual(str(err.exception), "User is not authenticated.") @mock.patch("tethys_apps.base.workspace.passes_quota", return_value=True) @mock.patch("tethys_apps.base.workspace._get_user_workspace") - def test_get_user_workspace_aor_app_instance(self, mock_guw, mock_pq): + @mock.patch("tethys_apps.utilities.get_app_model") + def test_get_user_workspace_aor_app_instance(self, mock_gam, mock_guw, mock_pq): mock_workspace = mock.MagicMock() mock_guw.return_value = mock_workspace - ret = _get_user_workspace_old(self.app, self.user) + mock_gam.return_value = self.app + ret = _get_user_workspace_old(self.app_base, self.user) self.assertEqual(ret, mock_workspace) mock_pq.assert_called_with(self.user, "user_workspace_quota") mock_guw.assert_called_with(self.app, self.user) @mock.patch("tethys_apps.base.workspace.passes_quota", return_value=True) @mock.patch("tethys_apps.base.workspace._get_user_workspace") - def test_get_user_workspace_aor_app_class(self, mock_guw, mock_pq): + @mock.patch("tethys_apps.utilities.get_app_model") + def test_get_user_workspace_aor_app_class(self, mock_gam, mock_guw, mock_pq): mock_workspace = mock.MagicMock() mock_guw.return_value = mock_workspace + mock_gam.return_value = self.app ret = _get_user_workspace_old(TethysAppChild, self.user) self.assertEqual(ret, mock_workspace) mock_pq.assert_called_with(self.user, "user_workspace_quota") - mock_guw.assert_called_with(TethysAppChild, self.user) + mock_guw.assert_called_with(self.app, self.user) - @mock.patch("tethys_apps.utilities.get_active_app") + @mock.patch("tethys_apps.utilities.get_app_model") @mock.patch("tethys_apps.base.workspace.passes_quota", return_value=True) @mock.patch("tethys_apps.base.workspace._get_user_workspace") - def test_get_user_workspace_aor_request(self, mock_guw, mock_pq, mock_gaa): + def test_get_user_workspace_aor_request(self, mock_guw, mock_pq, mock_gam): request = HttpRequest() request.user = self.user mock_workspace = mock.MagicMock() mock_guw.return_value = mock_workspace mock_app = mock.MagicMock() - mock_gaa.return_value = mock_app + mock_gam.return_value = mock_app ret = _get_user_workspace_old(request, self.user) self.assertEqual(ret, mock_workspace) - mock_gaa.assert_called_with(request, get_class=True) + mock_gam.assert_called_with(request) mock_pq.assert_called_with(self.user, "user_workspace_quota") mock_guw.assert_called_with(mock_app, self.user) @@ -235,7 +250,7 @@ def test_get_user_workspace_aor_error(self): self.assertEqual( str(context.exception), - 'Argument "app_class_or_request" must be of type TethysAppBase or HttpRequest: "" given.', + 'Argument "app_or_request" must be of type HttpRequest, TethysAppBase, or TethysApp: "" given.', ) @mock.patch("tethys_apps.base.workspace.passes_quota", return_value=True) @@ -250,19 +265,23 @@ def test_get_user_workspace_uor_user(self, mock_guw, mock_pq): @mock.patch("tethys_apps.base.workspace.passes_quota", return_value=True) @mock.patch("tethys_apps.base.workspace._get_user_workspace") - def test_get_user_workspace_uor_request(self, mock_guw, mock_pq): + @mock.patch("tethys_apps.utilities.get_app_model") + def test_get_user_workspace_uor_request(self, mock_gam, mock_guw, mock_pq): request = HttpRequest() request.user = self.user mock_workspace = mock.MagicMock() mock_guw.return_value = mock_workspace - ret = _get_user_workspace_old(self.app, request) + mock_gam.return_value = self.app + ret = _get_user_workspace_old(self.app_base, request) self.assertEqual(ret, mock_workspace) mock_pq.assert_called_with(self.user, "user_workspace_quota") mock_guw.assert_called_with(self.app, self.user) - def test_get_user_workspace_uor_error(self): + @mock.patch("tethys_apps.utilities.get_app_model") + def test_get_user_workspace_uor_error(self, mock_gam): + mock_gam.return_value = self.app with self.assertRaises(ValueError) as context: - _get_user_workspace_old(self.app, "not_user_or_request") + _get_user_workspace_old(self.app_base, "not_user_or_request") self.assertEqual( str(context.exception), @@ -292,9 +311,9 @@ def test_user_workspace_decorator_HttpRequest_not_given(self): @mock.patch("tethys_apps.base.workspace.TethysWorkspace") @mock.patch("tethys_apps.utilities.get_app_class") def test__get_app_workspace(self, mock_ac, mock_tws): - mock_ac.return_value = self.app.__class__ - ret = _get_app_workspace(self.app) - self.assertEqual(ret, mock_tws(self.app)) + mock_ac.return_value = self.app_base.__class__ + ret = _get_app_workspace(self.app_base) + self.assertEqual(ret, mock_tws(self.app_base)) expected_workspace_path = Path("workspaces") / "app_workspace" rts_call_args = mock_tws.call_args_list self.assertIn(str(expected_workspace_path), rts_call_args[0][0][0]) @@ -310,7 +329,7 @@ def test_get_app_workspace_app_instance( mock_app = mock.MagicMock() mock_gaw.return_value = mock_workspace mock_gam.return_value = mock_app - ret = _get_app_workspace_old(self.app) + ret = _get_app_workspace_old(self.app_base) self.assertEqual(ret, mock_workspace) mock_gaa.assert_not_called() mock_pq.assert_called_with(mock_app, "tethysapp_workspace_quota") @@ -352,7 +371,7 @@ def test_get_app_workspace_error(self): self.assertEqual( str(context.exception), - 'Argument "app_or_request" must be of type HttpRequest or TethysAppBase: "" given.', + 'Argument "app_or_request" must be of type HttpRequest, TethysAppBase, or TethysApp: "" given.', ) @mock.patch("tethys_apps.utilities.get_active_app") diff --git a/tests/unit_tests/test_tethys_cli/test_paths_commands.py b/tests/unit_tests/test_tethys_cli/test_paths_commands.py new file mode 100644 index 000000000..ac97cfab3 --- /dev/null +++ b/tests/unit_tests/test_tethys_cli/test_paths_commands.py @@ -0,0 +1,796 @@ +from pathlib import Path +from unittest import mock + +from django.test import TestCase, override_settings +from tethys_cli.paths_commands import add_file_to_path, get_path + + +class TestPathsCommandGetPath(TestCase): + def setUp(self): + from django.contrib.auth import get_user_model + from tethys_apps.models import TethysApp + + self.app = TethysApp.objects.create( + name="An App", + package="an_app", + ) + self.app.save() + + User = get_user_model() + self.user = User.objects.create_user( + username="test_user", email="testuser@example.com", password="testpassword" + ) + self.user.save() + + @override_settings(USE_OLD_WORKSPACES_API=True) + @mock.patch("tethys_cli.paths_commands.write_info") + @mock.patch("tethys_cli.paths_commands.write_msg") + @mock.patch("tethys_cli.paths_commands._get_app_workspace_old") + def test_app_workspace_old(self, mock_gaw, mock_wm, mock_wi): + mock_return = mock.MagicMock() + mock_return.path = "test_app_workspace_path" + mock_gaw.return_value = mock_return + args = mock.MagicMock(type="app_workspace", app=self.app.package) + get_path(args) + mock_gaw.assert_called_with(self.app, bypass_quota=True) + mock_wi.assert_called_with(f"App Workspace for app '{self.app.package}':") + mock_wm.assert_called_with("test_app_workspace_path") + + @override_settings(USE_OLD_WORKSPACES_API=False) + @mock.patch("tethys_cli.paths_commands.write_info") + @mock.patch("tethys_cli.paths_commands.write_msg") + @mock.patch("tethys_cli.paths_commands._get_app_workspace") + def test_app_workspace(self, mock_gaw, mock_wm, mock_wi): + mock_return = mock.MagicMock() + mock_return.path = "test_app_workspace_path" + mock_gaw.return_value = mock_return + args = mock.MagicMock(type="app_workspace", app=self.app.package) + get_path(args) + mock_gaw.assert_called_with(self.app, bypass_quota=True) + mock_wi.assert_called_with(f"App Workspace for app '{self.app.package}':") + mock_wm.assert_called_with("test_app_workspace_path") + + @override_settings(USE_OLD_WORKSPACES_API=True) + @mock.patch("tethys_cli.paths_commands.write_info") + @mock.patch("tethys_cli.paths_commands.write_msg") + @mock.patch("tethys_cli.paths_commands._get_user_workspace_old") + def test_user_workspace_old(self, mock_guw, mock_wm, mock_wi): + mock_return = mock.MagicMock() + mock_return.path = "test_user_workspace_path" + mock_guw.return_value = mock_return + args = mock.MagicMock( + type="user_workspace", app=self.app.package, user=self.user + ) + get_path(args) + mock_guw.assert_called_with(self.app, self.user, bypass_quota=True) + mock_wi.assert_called_with( + f"User Workspace for user '{self.user.username}' and app '{self.app.package}':" + ) + mock_wm.assert_called_with("test_user_workspace_path") + + @override_settings(USE_OLD_WORKSPACES_API=False) + @mock.patch("tethys_cli.paths_commands.write_info") + @mock.patch("tethys_cli.paths_commands.write_msg") + @mock.patch("tethys_cli.paths_commands._get_user_workspace") + def test_user_workspace(self, mock_guw, mock_wm, mock_wi): + mock_return = mock.MagicMock() + mock_return.path = "test_user_workspace_path" + mock_guw.return_value = mock_return + args = mock.MagicMock( + type="user_workspace", app=self.app.package, user=self.user + ) + get_path(args) + mock_guw.assert_called_with(self.app, self.user, bypass_quota=True) + mock_wi.assert_called_with( + f"User Workspace for user '{self.user.username}' and app '{self.app.package}':" + ) + mock_wm.assert_called_with("test_user_workspace_path") + + @mock.patch("tethys_cli.paths_commands.write_info") + @mock.patch("tethys_cli.paths_commands.write_msg") + @mock.patch("tethys_cli.paths_commands._get_app_media") + def test_app_media(self, mock_gam, mock_wm, mock_wi): + mock_return = mock.MagicMock() + mock_return.path = "test_app_media_path" + mock_gam.return_value = mock_return + args = mock.MagicMock(type="app_media", app=self.app.package) + get_path(args) + mock_gam.assert_called_with(self.app, bypass_quota=True) + mock_wi.assert_called_with(f"App Media for app '{self.app.package}':") + mock_wm.assert_called_with("test_app_media_path") + + @mock.patch("tethys_cli.paths_commands.write_info") + @mock.patch("tethys_cli.paths_commands.write_msg") + @mock.patch("tethys_cli.paths_commands._get_user_media") + def test_user_media(self, mock_gum, mock_wm, mock_wi): + mock_return = mock.MagicMock() + mock_return.path = "test_user_media_path" + mock_gum.return_value = mock_return + args = mock.MagicMock(type="user_media", app=self.app.package, user=self.user) + get_path(args) + mock_gum.assert_called_with(self.app, self.user, bypass_quota=True) + mock_wi.assert_called_with( + f"User Media for user '{self.user.username}' and app '{self.app.package}':" + ) + mock_wm.assert_called_with("test_user_media_path") + + @mock.patch("tethys_cli.paths_commands.write_info") + @mock.patch("tethys_cli.paths_commands.write_msg") + @mock.patch("tethys_cli.paths_commands.get_app_public") + def test_app_public(self, mock_gap, mock_wm, mock_wi): + mock_return = mock.MagicMock() + mock_return.path = "test_app_public_path" + mock_gap.return_value = mock_return + args = mock.MagicMock(type="app_public", app=self.app.package) + get_path(args) + mock_gap.assert_called_with(self.app) + mock_wi.assert_called_with(f"App Public for app '{self.app.package}':") + mock_wm.assert_called_with("test_app_public_path") + + @mock.patch("tethys_cli.paths_commands.write_info") + @mock.patch("tethys_cli.paths_commands.write_msg") + @mock.patch("tethys_cli.paths_commands.get_app_resources") + def test_app_resources(self, mock_gar, mock_wm, mock_wi): + mock_return = mock.MagicMock() + mock_return.path = "test_app_resources_path" + mock_gar.return_value = mock_return + args = mock.MagicMock(type="app_resources", app=self.app.package) + get_path(args) + mock_gar.assert_called_with(self.app) + mock_wi.assert_called_with(f"App Resources for app '{self.app.package}':") + mock_wm.assert_called_with("test_app_resources_path") + + @mock.patch("tethys_cli.paths_commands.write_error") + def test_no_user_provided_for_user_workspace(self, mock_we): + args = mock.MagicMock(type="user_workspace", app=self.app.package, user=None) + get_path(args) + mock_we.assert_called_with( + "The '--user' argument is required for path type 'user_workspace'." + ) + + @mock.patch("tethys_cli.paths_commands.write_error") + def test_no_user_provided_for_user_media(self, mock_we): + args = mock.MagicMock(type="user_media", app=self.app.package, user=None) + get_path(args) + mock_we.assert_called_with( + "The '--user' argument is required for path type 'user_media'." + ) + + @mock.patch("tethys_cli.paths_commands.write_error") + def test_invalid_path_type(self, mock_we): + args = mock.MagicMock(type="invalid_path_type", app=self.app.package) + get_path(args) + mock_we.assert_called_with("Invalid path type: 'invalid_path_type'.") + + @mock.patch("tethys_cli.paths_commands.write_warning") + def test_user_not_found(self, mock_ww): + args = mock.MagicMock( + type="user_workspace", app=self.app.package, user="nonexistent_user" + ) + get_path(args) + mock_ww.assert_called_with("User 'nonexistent_user' was not found.") + + @mock.patch("tethys_cli.paths_commands.write_warning") + def test_app_not_found(self, mock_ww): + args = mock.MagicMock(type="app_workspace", app="nonexistent_app") + get_path(args) + mock_ww.assert_called_with("Tethys app 'nonexistent_app' is not installed.") + + @mock.patch("tethys_cli.paths_commands.write_error") + @mock.patch("tethys_cli.paths_commands.resolve_path") + def test_path_not_found(self, mock_resolve_path, mock_we): + mock_resolve_path.return_value = None + args = mock.MagicMock(type="app_workspace", app=self.app.package) + get_path(args) + mock_we.assert_called_with("Could not find App Workspace.") + + +class TestPathsCommandAddFileToPath(TestCase): + def setUp(self): + from django.contrib.auth import get_user_model + from tethys_apps.models import TethysApp + + self.app = TethysApp.objects.create( + name="An App", + package="an_app", + ) + self.app.save() + + User = get_user_model() + self.user = User.objects.create_user( + username="test_user", email="testuser@example.com", password="testpassword" + ) + self.user.save() + + @override_settings(USE_OLD_WORKSPACES_API=True) + @mock.patch("tethys_cli.paths_commands.Path") + @mock.patch("tethys_cli.paths_commands.shutil.copy") + @mock.patch("tethys_cli.paths_commands.can_add_file_to_path") + @mock.patch("tethys_cli.paths_commands.get_resource_available") + @mock.patch("tethys_cli.paths_commands._get_app_workspace_old") + @mock.patch("tethys_cli.paths_commands.write_success") + def test_add_app_workspace_old( + self, mock_wr, mock_gaw, mock_gra, mock_caf, mock_copy, mock_path + ): + file_name = "test_file.txt" + workspace = "test_app_workspace_path" + + mock_file = mock.MagicMock() + mock_file.is_file.return_value = True + mock_file.name = file_name + mock_file.stat.return_value.st_size = 1024 + + mock_file.exists.return_value = False + mock_file.__str__.return_value = file_name + + mock_path.return_value = mock_file + + mock_gra.return_value = {"resource_available": 1500} + mock_caf.return_value = True + + mock_return = mock.MagicMock() + mock_return.path = Path(workspace) + mock_gaw.return_value = mock_return + + args = mock.MagicMock( + type="app_workspace", + app=self.app.package, + file="test_file.txt", + ) + add_file_to_path(args) + + mock_gaw.assert_called_with(self.app, bypass_quota=True) + mock_caf.assert_called() + mock_copy.assert_called_once() + mock_wr.assert_called_with( + f"File '{file_name}' has been added to the App Workspace at '{workspace}/{file_name}'." + ) + + @override_settings(USE_OLD_WORKSPACES_API=False) + @mock.patch("tethys_cli.paths_commands.Path") + @mock.patch("tethys_cli.paths_commands.shutil.copy") + @mock.patch("tethys_cli.paths_commands.can_add_file_to_path") + @mock.patch("tethys_cli.paths_commands.get_resource_available") + @mock.patch("tethys_cli.paths_commands._get_app_workspace") + @mock.patch("tethys_cli.paths_commands.write_success") + def test_add_app_workspace( + self, mock_wr, mock_gaw, mock_gra, mock_caf, mock_copy, mock_path + ): + file_name = "test_file.txt" + workspace = "test_app_workspace_path" + + mock_file = mock.MagicMock() + mock_file.is_file.return_value = True + mock_file.name = file_name + mock_file.stat.return_value.st_size = 1024 + + mock_file.exists.return_value = False + mock_file.__str__.return_value = file_name + + mock_path.return_value = mock_file + + mock_gra.return_value = {"resource_available": 1500} + mock_caf.return_value = True + + mock_return = mock.MagicMock() + mock_return.path = Path(workspace) + mock_gaw.return_value = mock_return + + args = mock.MagicMock( + type="app_workspace", + app=self.app.package, + file="test_file.txt", + ) + add_file_to_path(args) + + mock_gaw.assert_called_with(self.app, bypass_quota=True) + mock_caf.assert_called() + mock_copy.assert_called_once() + mock_wr.assert_called_with( + f"File '{file_name}' has been added to the App Workspace at '{workspace}/{file_name}'." + ) + + @override_settings(USE_OLD_WORKSPACES_API=True) + @mock.patch("tethys_cli.paths_commands.Path") + @mock.patch("tethys_cli.paths_commands.shutil.copy") + @mock.patch("tethys_cli.paths_commands.can_add_file_to_path") + @mock.patch("tethys_cli.paths_commands.get_resource_available") + @mock.patch("tethys_cli.paths_commands._get_user_workspace_old") + @mock.patch("tethys_cli.paths_commands.write_success") + def test_add_user_workspace_old( + self, mock_wr, mock_guw, mock_gra, mock_caf, mock_copy, mock_path + ): + file_name = "test_file.txt" + workspace = "test_user_workspace_path" + + mock_file = mock.MagicMock() + mock_file.is_file.return_value = True + mock_file.name = file_name + mock_file.stat.return_value.st_size = 1024 + + mock_file.exists.return_value = False + mock_file.__str__.return_value = file_name + + mock_path.return_value = mock_file + + mock_gra.return_value = {"resource_available": 1500} + mock_caf.return_value = True + + mock_return = mock.MagicMock() + mock_return.path = Path(workspace) + mock_guw.return_value = mock_return + + args = mock.MagicMock( + type="user_workspace", + app=self.app.package, + user=self.user, + file="test_file.txt", + ) + add_file_to_path(args) + + mock_guw.assert_called_with(self.app, self.user, bypass_quota=True) + mock_caf.assert_called() + mock_copy.assert_called_once() + mock_wr.assert_called_with( + f"File '{file_name}' has been added to the User Workspace at '{workspace}/{file_name}'." + ) + + @mock.patch("tethys_cli.paths_commands.Path") + @mock.patch("tethys_cli.paths_commands.shutil.copy") + @mock.patch("tethys_cli.paths_commands.can_add_file_to_path") + @mock.patch("tethys_cli.paths_commands.get_resource_available") + @mock.patch("tethys_cli.paths_commands._get_app_media") + @mock.patch("tethys_cli.paths_commands.write_success") + def test_add_app_media( + self, mock_wr, mock_gam, mock_gra, mock_caf, mock_copy, mock_path + ): + file_name = "test_file.txt" + media = "test_app_media_path" + + mock_file = mock.MagicMock() + mock_file.is_file.return_value = True + mock_file.name = file_name + mock_file.stat.return_value = 1024 + + mock_file.exists.return_value = False + mock_file.__str__.return_value = file_name + + mock_path.return_value = mock_file + + mock_gra.return_value = {"resource_available": 1500} + mock_caf.return_value = True + + mock_return = mock.MagicMock() + mock_return.path = Path(media) + mock_gam.return_value = mock_return + + args = mock.MagicMock( + type="app_media", + app=self.app.package, + file="test_file.txt", + ) + add_file_to_path(args) + + mock_gam.assert_called_with(self.app, bypass_quota=True) + mock_caf.assert_called() + mock_copy.assert_called_once() + mock_wr.assert_called_with( + f"File '{file_name}' has been added to the App Media at '{media}/{file_name}'." + ) + + @mock.patch("tethys_cli.paths_commands.Path") + @mock.patch("tethys_cli.paths_commands.shutil.copy") + @mock.patch("tethys_cli.paths_commands.can_add_file_to_path") + @mock.patch("tethys_cli.paths_commands.get_resource_available") + @mock.patch("tethys_cli.paths_commands._get_user_media") + @mock.patch("tethys_cli.paths_commands.write_success") + def test_add_user_media( + self, mock_wr, mock_gum, mock_gra, mock_caf, mock_copy, mock_path + ): + file_name = "test_file.txt" + media = "test_user_media_path" + + mock_file = mock.MagicMock() + mock_file.is_file.return_value = True + mock_file.name = file_name + mock_file.stat.return_value = 1024 + + mock_file.exists.return_value = False + mock_file.__str__.return_value = file_name + + mock_path.return_value = mock_file + + mock_gra.return_value = {"resource_available": 1500} + mock_caf.return_value = True + + mock_return = mock.MagicMock() + mock_return.path = Path(media) + mock_gum.return_value = mock_return + + args = mock.MagicMock( + type="user_media", + app=self.app.package, + user=self.user, + file="test_file.txt", + ) + add_file_to_path(args) + + mock_gum.assert_called_with(self.app, self.user, bypass_quota=True) + mock_caf.assert_called() + mock_copy.assert_called_once() + mock_wr.assert_called_with( + f"File '{file_name}' has been added to the User Media at '{media}/{file_name}'." + ) + + @mock.patch("tethys_cli.paths_commands.Path") + @mock.patch("tethys_cli.paths_commands.shutil.copy") + @mock.patch("tethys_cli.paths_commands.can_add_file_to_path") + @mock.patch("tethys_cli.paths_commands.get_resource_available") + @mock.patch("tethys_cli.paths_commands.get_app_public") + @mock.patch("tethys_cli.paths_commands.write_success") + def test_add_app_public( + self, mock_wr, mock_gap, mock_gra, mock_caf, mock_copy, mock_path + ): + file_name = "test_file.txt" + public = "test_app_public_path" + + mock_file = mock.MagicMock() + mock_file.is_file.return_value = True + mock_file.name = file_name + mock_file.stat.return_value = 1024 + + mock_file.exists.return_value = False + mock_file.__str__.return_value = file_name + + mock_path.return_value = mock_file + + mock_gra.return_value = {"resource_available": 1500} + mock_caf.return_value = True + + mock_return = mock.MagicMock() + mock_return.path = Path(public) + mock_gap.return_value = mock_return + + args = mock.MagicMock( + type="app_public", + app=self.app.package, + file="test_file.txt", + ) + add_file_to_path(args) + + mock_gap.assert_called_with(self.app) + mock_caf.assert_called() + mock_copy.assert_called_once() + mock_wr.assert_called_with( + f"File '{file_name}' has been added to the App Public at '{public}/{file_name}'." + ) + + @mock.patch("tethys_cli.paths_commands.Path") + @mock.patch("tethys_cli.paths_commands.shutil.copy") + @mock.patch("tethys_cli.paths_commands.can_add_file_to_path") + @mock.patch("tethys_cli.paths_commands.get_resource_available") + @mock.patch("tethys_cli.paths_commands.get_app_resources") + @mock.patch("tethys_cli.paths_commands.write_success") + def test_add_app_resources( + self, mock_wr, mock_gar, mock_gra, mock_caf, mock_copy, mock_path + ): + file_name = "test_file.txt" + resources = "test_app_resources_path" + + mock_file = mock.MagicMock() + mock_file.is_file.return_value = True + mock_file.name = file_name + mock_file.stat.return_value = 1024 + + mock_file.exists.return_value = False + mock_file.__str__.return_value = file_name + + mock_path.return_value = mock_file + + mock_gra.return_value = {"resource_available": 1500} + mock_caf.return_value = True + + mock_return = mock.MagicMock() + mock_return.path = Path(resources) + mock_gar.return_value = mock_return + + args = mock.MagicMock( + type="app_resources", + app=self.app.package, + file="test_file.txt", + ) + add_file_to_path(args) + + mock_gar.assert_called_with(self.app) + mock_caf.assert_called() + mock_copy.assert_called_once() + mock_wr.assert_called_with( + f"File '{file_name}' has been added to the App Resources at '{resources}/{file_name}'." + ) + + @mock.patch("tethys_cli.paths_commands.write_error") + def test_no_user_provided_for_user_workspace(self, mock_we): + args = mock.MagicMock( + type="user_workspace", app=self.app.package, user=None, file="test_file.txt" + ) + add_file_to_path(args) + mock_we.assert_called_with( + "The '--user' argument is required for path type 'user_workspace'." + ) + + @mock.patch("tethys_cli.paths_commands.write_error") + def test_no_user_provided_for_user_media(self, mock_we): + args = mock.MagicMock( + type="user_media", app=self.app.package, user=None, file="test_file.txt" + ) + add_file_to_path(args) + mock_we.assert_called_with( + "The '--user' argument is required for path type 'user_media'." + ) + + @mock.patch("tethys_cli.paths_commands.write_error") + def test_invalid_path_type(self, mock_we): + args = mock.MagicMock( + type="invalid_path_type", app=self.app.package, file="test_file.txt" + ) + add_file_to_path(args) + mock_we.assert_called_with("Invalid path type: 'invalid_path_type'.") + + @mock.patch("tethys_cli.paths_commands.write_warning") + def test_user_not_found(self, mock_ww): + args = mock.MagicMock( + type="user_workspace", + app=self.app.package, + user="nonexistent_user", + file="test_file.txt", + ) + add_file_to_path(args) + mock_ww.assert_called_with("User 'nonexistent_user' was not found.") + + @mock.patch("tethys_cli.paths_commands.write_warning") + def test_app_not_found(self, mock_ww): + args = mock.MagicMock( + type="app_workspace", app="nonexistent_app", file="test_file.txt" + ) + add_file_to_path(args) + mock_ww.assert_called_with("Tethys app 'nonexistent_app' is not installed.") + + @mock.patch("tethys_cli.paths_commands.write_error") + @mock.patch("tethys_cli.paths_commands.resolve_path") + def test_path_not_found(self, mock_resolve_path, mock_we): + mock_resolve_path.return_value = None + args = mock.MagicMock( + type="app_workspace", app=self.app.package, file="test_file.txt" + ) + add_file_to_path(args) + mock_we.assert_called_with("Could not find App Workspace.") + + @override_settings(USE_OLD_WORKSPACES_API=False) + @mock.patch("tethys_cli.paths_commands.Path") + @mock.patch("tethys_cli.paths_commands._get_app_workspace") + @mock.patch("tethys_cli.paths_commands.write_error") + def test_nonexistent_file(self, mock_we, mock_gaw, mock_path): + file_name = "test_file.txt" + workspace = "test_app_workspace_path" + + mock_file = mock.MagicMock() + # Mock the file not existing + mock_file.is_file.return_value = False + + mock_path.return_value = mock_file + + mock_return = mock.MagicMock() + mock_return.path = Path(workspace) + mock_gaw.return_value = mock_return + + args = mock.MagicMock( + type="app_workspace", + app=self.app.package, + file="test_file.txt", + ) + + add_file_to_path(args) + + mock_we.assert_called_with( + f"The specified file '{file_name}' does not exist or is not a file." + ) + + @override_settings(USE_OLD_WORKSPACES_API=False) + @mock.patch("tethys_cli.paths_commands.Path") + @mock.patch("tethys_cli.paths_commands._get_app_workspace") + @mock.patch("tethys_cli.paths_commands.write_warning") + def test_already_exists_destination(self, mock_ww, mock_gaw, mock_path): + file_name = "test_file.txt" + workspace = "test_app_workspace_path" + + mock_file = mock.MagicMock() + mock_file.is_file.return_value = True + mock_file.name = file_name + mock_file.stat.return_value = 1024 + + mock_file.is_file.return_value = True + # Mock the destination file already existing + mock_file.exists.return_value = True + mock_path.return_value = mock_file + + mock_return = mock.MagicMock() + mock_return.path = Path(workspace) + mock_gaw.return_value = mock_return + + args = mock.MagicMock( + type="app_workspace", + app=self.app.package, + file="test_file.txt", + ) + + add_file_to_path(args) + + mock_ww.assert_called_with( + f"The file '{file_name}' already exists in the intended App Workspace." + ) + + @override_settings(USE_OLD_WORKSPACES_API=False) + @mock.patch("tethys_cli.paths_commands.can_add_file_to_path") + @mock.patch("tethys_cli.paths_commands.get_resource_available") + @mock.patch("tethys_cli.paths_commands.Path") + @mock.patch("tethys_cli.paths_commands._get_user_workspace") + @mock.patch("tethys_cli.paths_commands.write_error") + def test_user_quota_met(self, mock_we, mock_guw, mock_path, mock_gra, mock_caf): + file_name = "test_file.txt" + workspace = "test_user_workspace_path" + + mock_file = mock.MagicMock() + mock_file.is_file.return_value = True + mock_file.name = file_name + mock_file.stat.return_value = 1024 + + mock_file.exists.return_value = False + mock_file.__str__.return_value = file_name + + mock_path.return_value = mock_file + + # Mock the user quota being exceeded + mock_gra.return_value = {"resource_available": 0} + + mock_caf.return_value = True + + mock_return = mock.MagicMock() + mock_return.path = Path(workspace) + mock_guw.return_value = mock_return + + args = mock.MagicMock( + type="user_workspace", + app=self.app.package, + user=self.user, + file="test_file.txt", + ) + add_file_to_path(args) + + mock_we.assert_called_with( + "Cannot add file to User Workspace. Quota has already been met." + ) + + @override_settings(USE_OLD_WORKSPACES_API=False) + @mock.patch("tethys_cli.paths_commands.can_add_file_to_path") + @mock.patch("tethys_cli.paths_commands.get_resource_available") + @mock.patch("tethys_cli.paths_commands.Path") + @mock.patch("tethys_cli.paths_commands._get_user_workspace") + @mock.patch("tethys_cli.paths_commands.write_error") + def test_user_will_exceed_quota( + self, mock_we, mock_guw, mock_path, mock_gra, mock_caf + ): + file_name = "test_file.txt" + workspace = "test_user_workspace_path" + + mock_file = mock.MagicMock() + mock_file.is_file.return_value = True + mock_file.name = file_name + mock_file.stat.return_value.st_size = 1024 + + mock_file.exists.return_value = False + mock_file.__str__.return_value = file_name + + mock_path.return_value = mock_file + + # Mock the resource avilable as 300 bytes so 1024 will exceed it + mock_caf.return_value = False + mock_gra.return_value = {"resource_available": 0.0000002794} + + mock_return = mock.MagicMock() + mock_return.path = Path(workspace) + mock_guw.return_value = mock_return + + args = mock.MagicMock( + type="user_workspace", + app=self.app.package, + user=self.user, + file="test_file.txt", + ) + add_file_to_path(args) + + mock_we.assert_called_with( + "Cannot add file to User Workspace. File size (1 KB) exceeds available quota (300 bytes)." + ) + + @override_settings(USE_OLD_WORKSPACES_API=False) + @mock.patch("tethys_cli.paths_commands.can_add_file_to_path") + @mock.patch("tethys_cli.paths_commands.get_resource_available") + @mock.patch("tethys_cli.paths_commands.Path") + @mock.patch("tethys_cli.paths_commands._get_app_workspace") + @mock.patch("tethys_cli.paths_commands.write_error") + def test_app_quota_met(self, mock_we, mock_gaw, mock_path, mock_gra, mock_caf): + file_name = "test_file.txt" + workspace = "test_app_workspace_path" + + mock_file = mock.MagicMock() + mock_file.is_file.return_value = True + mock_file.name = file_name + mock_file.stat.return_value = 1024 + + mock_file.exists.return_value = False + mock_file.__str__.return_value = file_name + + mock_path.return_value = mock_file + + # Mock the user quota being exceeded + mock_gra.return_value = {"resource_available": 0} + + mock_caf.return_value = True + + mock_return = mock.MagicMock() + mock_return.path = Path(workspace) + mock_gaw.return_value = mock_return + + args = mock.MagicMock( + type="app_workspace", + app=self.app.package, + user=self.user, + file="test_file.txt", + ) + add_file_to_path(args) + + mock_we.assert_called_with( + "Cannot add file to App Workspace. Quota has already been met." + ) + + @override_settings(USE_OLD_WORKSPACES_API=False) + @mock.patch("tethys_cli.paths_commands.can_add_file_to_path") + @mock.patch("tethys_cli.paths_commands.get_resource_available") + @mock.patch("tethys_cli.paths_commands.Path") + @mock.patch("tethys_cli.paths_commands._get_app_workspace") + @mock.patch("tethys_cli.paths_commands.write_error") + def test_app_will_exceed_quota( + self, mock_we, mock_gaw, mock_path, mock_gra, mock_caf + ): + file_name = "test_file.txt" + workspace = "test_app_workspace_path" + + mock_file = mock.MagicMock() + mock_file.is_file.return_value = True + mock_file.name = file_name + mock_file.stat.return_value.st_size = 1024 + + mock_file.exists.return_value = False + mock_file.__str__.return_value = file_name + + mock_path.return_value = mock_file + + # Mock the resource avilable as 300 bytes so 1024 will exceed it + mock_caf.return_value = False + mock_gra.return_value = {"resource_available": 0.0000002794} + + mock_return = mock.MagicMock() + mock_return.path = Path(workspace) + mock_gaw.return_value = mock_return + + args = mock.MagicMock( + type="app_workspace", + app=self.app.package, + user=self.user, + file="test_file.txt", + ) + add_file_to_path(args) + + mock_we.assert_called_with( + "Cannot add file to App Workspace. File size (1 KB) exceeds available quota (300 bytes)." + ) diff --git a/tests/unit_tests/test_tethys_quotas/test_utilities.py b/tests/unit_tests/test_tethys_quotas/test_utilities.py index a44591f04..ec945464d 100644 --- a/tests/unit_tests/test_tethys_quotas/test_utilities.py +++ b/tests/unit_tests/test_tethys_quotas/test_utilities.py @@ -188,3 +188,74 @@ def test_get_quota_staff(self, mock_rq): ret = utilities.get_quota(user, "codename") self.assertEqual(None, ret["quota"]) + + def test_can_add_file_invalid_codename(self): + with self.assertRaises(ValueError) as context: + utilities.can_add_file_to_path(TethysApp(), "invalid_codename", 100) + + self.assertEqual(str(context.exception), "Invalid codename: invalid_codename") + + def test_can_add_file_invalid_not_app(self): + with self.assertRaises(ValueError) as context: + utilities.can_add_file_to_path( + "not an app or user", "tethysapp_workspace_quota", 100 + ) + + self.assertEqual( + str(context.exception), + "Invalid entity type for codename tethysapp_workspace_quota, expected TethysApp, got str", + ) + + def test_can_add_file_invalid_not_user(self): + with self.assertRaises(ValueError) as context: + utilities.can_add_file_to_path( + "not an app or user", "user_workspace_quota", 100 + ) + + self.assertEqual( + str(context.exception), + "Invalid entity type for codename user_workspace_quota, expected User, got str", + ) + + @mock.patch("tethys_quotas.utilities.get_resource_available") + def test_can_add_file_quota_met(self, mock_get_resource_available): + mock_get_resource_available.return_value = { + "resource_available": 0, + "units": "GB", + } + result = utilities.can_add_file_to_path( + TethysApp(), "tethysapp_workspace_quota", "file.txt" + ) + self.assertFalse(result) + + @mock.patch("tethys_quotas.utilities.get_resource_available") + def test_can_add_file_exceeds_quota(self, mock_get_resource_available): + mock_get_resource_available.return_value = { + "resource_available": 1, + "units": "GB", + } + mock_file = mock.MagicMock() + mock_file.stat.return_value.st_size = 2147483648 # 2 GB + result = utilities.can_add_file_to_path( + TethysApp(), "tethysapp_workspace_quota", mock_file + ) + self.assertFalse(result) + + @mock.patch("tethys_quotas.utilities.get_resource_available") + def test_can_add_file_within_quota(self, mock_get_resource_available): + mock_get_resource_available.return_value = { + "resource_available": 2, + "units": "GB", + } + mock_file = mock.MagicMock() + mock_file.stat.return_value.st_size = 1073741824 # 1 GB + result = utilities.can_add_file_to_path( + TethysApp(), "tethysapp_workspace_quota", mock_file + ) + self.assertTrue(result) + + def test__convert_to_bytes(self): + self.assertEqual(1073741824, utilities._convert_to_bytes("gb", 1)) + self.assertEqual(1048576, utilities._convert_to_bytes("mb", 1)) + self.assertEqual(1024, utilities._convert_to_bytes("kb", 1)) + self.assertIsNone(utilities._convert_to_bytes("tb", 1)) diff --git a/tethys_apps/base/paths.py b/tethys_apps/base/paths.py index 907bf90c0..8b5157f5f 100644 --- a/tethys_apps/base/paths.py +++ b/tethys_apps/base/paths.py @@ -251,7 +251,7 @@ def _resolve_app_class(app_or_request): app = get_app_class(app_or_request) else: raise ValueError( - f'Argument "app_or_request" must be of type TethysAppBase, TethysApp, or HttpRequest: ' + f'Argument "app_or_request" must be of type HttpRequest, TethysAppBase, or TethysApp: ' f'"{type(app_or_request)}" given.' ) diff --git a/tethys_apps/base/workspace.py b/tethys_apps/base/workspace.py index 43fd50148..29cc49d5d 100644 --- a/tethys_apps/base/workspace.py +++ b/tethys_apps/base/workspace.py @@ -224,6 +224,7 @@ def _get_user_workspace(app_class, user_or_request): username = "" from django.contrib.auth.models import User + from tethys_apps.utilities import get_app_class if isinstance(user_or_request, User) or isinstance( user_or_request, SimpleLazyObject @@ -238,7 +239,8 @@ def _get_user_workspace(app_class, user_or_request): "Invalid type for argument 'user': must be either an User or HttpRequest object." ) - project_directory = Path(sys.modules[app_class.__module__].__file__).parent + app = get_app_class(app_class) + project_directory = Path(sys.modules[app.__module__].__file__).parent workspace_directory = ( project_directory / "workspaces" / "user_workspaces" / username ) @@ -252,7 +254,7 @@ def _get_user_workspace_old( Get the dedicated user workspace for the given app. If an HttpRequest is given, the workspace of the logged-in user will be returned (i.e. request.user). Args: - app_class_or_request (TethysAppBase or HttpRequest): The Tethys app class that is defined in app.py or HttpRequest to app endpoint. + app_class_or_request (TethysAppBase, TethysApp, or HttpRequest): The Tethys app class that is defined in app.py or HttpRequest to app endpoint. user_or_request (User or HttpRequest): Either an HttpRequest with active user session or Django User object. Raises: @@ -271,24 +273,12 @@ def some_function(user): user_workspace = get_user_workspace(App, user) ... """ # noqa: E501 - from tethys_apps.base.app_base import TethysAppBase - from tethys_apps.utilities import get_active_app + from tethys_apps.utilities import get_app_model from django.contrib.auth.models import User from django.core.exceptions import PermissionDenied # Get app - if isinstance(app_class_or_request, TethysAppBase) or ( - isinstance(app_class_or_request, type) - and issubclass(app_class_or_request, TethysAppBase) - ): - app = app_class_or_request - elif isinstance(app_class_or_request, HttpRequest): - app = get_active_app(app_class_or_request, get_class=True) - else: - raise ValueError( - f'Argument "app_class_or_request" must be of type TethysAppBase or HttpRequest: ' - f'"{type(app_class_or_request)}" given.' - ) + app = get_app_model(app_class_or_request) # Get user if isinstance(user_or_request, User) or isinstance( @@ -316,7 +306,7 @@ def user_workspace(controller): **Decorator:** Get the file workspace (directory) for the given User. Add an argument named "user_workspace" to your controller. The TethysWorkspace will be passed to via this argument. Returns: - TethysWorkspace: An object representing the workspace. + TethysWorkspace: An object representing the workspace. **Example:** @@ -385,10 +375,10 @@ def _get_app_workspace_old(app_or_request, bypass_quota=False) -> TethysWorkspac Get the app workspace for the active app of the given HttpRequest or the given Tethys App class. Args: - app_or_request (TethysAppBase | HttpRequest): The Tethys App class or an HttpRequest to an app endpoint. + app_or_request (TethysAppBase, TethysApp, or HttpRequest): The Tethys app class that is defined in app.py or HttpRequest to an app endpoint. Raises: - ValueError: if object of type other than HttpRequest or TethysAppBase given. + ValueError: if object of type other than HttpRequest, TethysAppBase, or TethysApp given. AssertionError: if quota for the app workspace has been exceeded. Returns: diff --git a/tethys_apps/utilities.py b/tethys_apps/utilities.py index 0c6f1c330..9f8e5da08 100644 --- a/tethys_apps/utilities.py +++ b/tethys_apps/utilities.py @@ -142,9 +142,11 @@ def get_app_model(app_or_request): isinstance(app_or_request, type) and issubclass(app_or_request, TethysAppBase) ): app = TethysApp.objects.get(root_url=app_or_request.root_url) + elif isinstance(app_or_request, TethysApp): + app = app_or_request else: raise ValueError( - f'Argument "app_or_request" must be of type HttpRequest or TethysAppBase: ' + f'Argument "app_or_request" must be of type HttpRequest, TethysAppBase, or TethysApp: ' f'"{type(app_or_request)}" given.' ) diff --git a/tethys_cli/__init__.py b/tethys_cli/__init__.py index 81adb6136..097ebb029 100644 --- a/tethys_cli/__init__.py +++ b/tethys_cli/__init__.py @@ -19,6 +19,7 @@ from tethys_cli.link_commands import add_link_parser from tethys_cli.list_command import add_list_parser from tethys_cli.manage_commands import add_manage_parser +from tethys_cli.paths_commands import add_paths_parser from tethys_cli.scaffold_commands import add_scaffold_parser from tethys_cli.scheduler_commands import add_scheduler_parser from tethys_cli.services_commands import add_services_parser @@ -52,6 +53,7 @@ def tethys_command_parser(): add_link_parser(subparsers) add_list_parser(subparsers) add_manage_parser(subparsers) + add_paths_parser(subparsers) add_scaffold_parser(subparsers) add_scheduler_parser(subparsers) add_services_parser(subparsers) diff --git a/tethys_cli/paths_commands.py b/tethys_cli/paths_commands.py new file mode 100644 index 000000000..ddb6266ee --- /dev/null +++ b/tethys_cli/paths_commands.py @@ -0,0 +1,339 @@ +import argparse +from django.conf import settings +from pathlib import Path +import shutil + +from tethys_cli.cli_colors import ( + write_msg, + write_info, + write_warning, + write_success, + write_error, +) +from tethys_quotas.utilities import ( + _convert_storage_units, + can_add_file_to_path, + get_resource_available, +) + +from tethys_apps.base.paths import ( + _get_app_workspace, + _get_user_workspace, + _get_app_media, + _get_user_media, + get_app_public, + get_app_resources, +) +from tethys_apps.base.workspace import _get_app_workspace_old, _get_user_workspace_old + + +def get_path_config(path_type): + from tethys_cli.cli_helpers import setup_django + + setup_django() + + if settings.USE_OLD_WORKSPACES_API: + app_workspace_func = _get_app_workspace_old + user_workspace_func = _get_user_workspace_old + else: + app_workspace_func = _get_app_workspace + user_workspace_func = _get_user_workspace + + path_types = { + "app_workspace": { + "function": app_workspace_func, + "path_name": "App Workspace", + "quota": True, + }, + "user_workspace": { + "function": user_workspace_func, + "path_name": "User Workspace", + "quota": True, + }, + "app_media": { + "function": _get_app_media, + "path_name": "App Media", + "quota": True, + }, + "user_media": { + "function": _get_user_media, + "path_name": "User Media", + "quota": True, + }, + "app_public": { + "function": get_app_public, + "path_name": "App Public", + "quota": False, + }, + "app_resources": { + "function": get_app_resources, + "path_name": "App Resources", + "quota": False, + }, + } + + return path_types.get(path_type, None) + + +def add_paths_parser(subparsers): + # Paths API commands + paths_parser = subparsers.add_parser( + "paths", + help="Get Tethys Paths information and manage Tethys Paths.", + formatter_class=argparse.RawTextHelpFormatter, + ) + + paths_subparsers = paths_parser.add_subparsers( + title="Paths Commands", dest="paths_command" + ) + paths_subparsers.required = True + + # Get Path Command + get_parser = paths_subparsers.add_parser( + "get", + help="Get Tethys Paths information.", + formatter_class=argparse.RawTextHelpFormatter, + ) + get_parser.add_argument( + "-t", + "--type", + required=True, + choices=[ + "app_workspace", + "user_workspace", + "app_media", + "user_media", + "app_public", + "app_resources", + ], + help="Type of path to get.", + ) + get_parser.add_argument("-a", "--app", required=True, help="Tethys app name.") + get_parser.add_argument( + "-u", "--user", help="Username (required for user-specific paths)." + ) + get_parser.set_defaults(func=get_path) + + # Add File to Path Command + add_parser = paths_subparsers.add_parser( + "add", + help="Add files to Tethys Paths.", + formatter_class=argparse.RawTextHelpFormatter, + ) + add_parser.add_argument( + "-t", + "--type", + required=True, + choices=[ + "app_workspace", + "user_workspace", + "app_media", + "user_media", + "app_public", + "app_resources", + ], + help="Type of path to add file to.", + ) + add_parser.add_argument("-a", "--app", required=True, help="Tethys app name.") + add_parser.add_argument( + "-u", "--user", help="Username (required for user-specific paths)." + ) + add_parser.add_argument( + "-f", "--file", required=True, help="Path to the file to add." + ) + add_parser.set_defaults(func=add_file_to_path) + + +def get_path(args): + """ + Get Tethys path based on command line arguments. + """ + if args.type in ["user_workspace", "user_media"] and not args.user: + write_error(f"The '--user' argument is required for path type '{args.type}'.") + return + + path_config = get_path_config(args.type) + if not path_config: + write_error(f"Invalid path type: '{args.type}'.") + return + + is_user_path = args.type in ["user_workspace", "user_media"] + + if is_user_path: + user = get_user(args.user) + if not user: + write_warning(f"User '{args.user}' was not found.") + + app = get_tethys_app(args.app) + if not app: + write_warning(f"Tethys app '{args.app}' is not installed.") + + if not app or is_user_path and not user: + return + + if is_user_path: + path = resolve_path(path_config, app, user) + else: + path = resolve_path(path_config, app) + + if not path: + write_error(f"Could not find {path_config.get('path_name')}.") + return + + if is_user_path: + write_info( + f"{path_config.get('path_name')} for user '{args.user}' and app '{args.app}':" + ) + else: + write_info(f"{path_config.get('path_name')} for app '{args.app}':") + + write_msg(path.path) + + +def add_file_to_path(args): + """ + Add file to Tethys path based on command line arguments. + """ + if args.type in ["user_workspace", "user_media"] and not args.user: + write_error(f"The '--user' argument is required for path type '{args.type}'.") + return + + path_config = get_path_config(args.type) + if not path_config: + write_error(f"Invalid path type: '{args.type}'.") + return + + is_user_path = args.type in ["user_workspace", "user_media"] + + if is_user_path: + user = get_user(args.user) + if not user: + write_warning(f"User '{args.user}' was not found.") + + app = get_tethys_app(args.app) + if not app: + write_warning(f"Tethys app '{args.app}' is not installed.") + + if not app or is_user_path and not user: + return + + if is_user_path: + path = resolve_path(path_config, app, user) + else: + path = resolve_path(path_config, app) + + if not path: + write_error(f"Could not find {path_config.get('path_name')}.") + return + + source_file = Path(args.file) + if not source_file.is_file(): + write_error( + f"The specified file '{args.file}' does not exist or is not a file." + ) + return + + destination_file = path.path / source_file.name + if Path(destination_file).exists(): + write_warning( + f"The file '{source_file.name}' already exists in the intended {path_config.get('path_name')}." + ) + return + + if is_user_path: + codename = "user_workspace_quota" + can_add_file = can_add_file_to_path(user, codename, source_file) + resource_available = get_resource_available(user, codename) + + if resource_available and resource_available["resource_available"] <= 0: + write_error( + f"Cannot add file to {path_config.get('path_name')}. Quota has already been met." + ) + return + + elif not can_add_file: + file_size = _convert_storage_units("bytes", source_file.stat().st_size) + resource_available = _convert_storage_units( + "GB", resource_available["resource_available"] + ) + write_error( + f"Cannot add file to {path_config.get('path_name')}. File size ({file_size}) exceeds available quota ({resource_available})." + ) + return + + else: + shutil.copy(source_file, destination_file) + write_success( + f"File '{source_file}' has been added to the {path_config.get('path_name')} at '{destination_file}'." + ) + return + + else: + codename = "tethysapp_workspace_quota" + can_add_file = can_add_file_to_path(app, codename, source_file) + resource_available = get_resource_available(app, codename) + if resource_available and resource_available["resource_available"] <= 0: + write_error( + f"Cannot add file to {path_config.get('path_name')}. Quota has already been met." + ) + return + + elif not can_add_file: + file_size = _convert_storage_units("bytes", source_file.stat().st_size) + resource_available = _convert_storage_units( + "GB", resource_available["resource_available"] + ) + write_error( + f"Cannot add file to {path_config.get('path_name')}. File size ({file_size}) exceeds available quota ({resource_available})." + ) + return + + else: + shutil.copy(source_file, destination_file) + write_success( + f"File '{source_file}' has been added to the {path_config.get('path_name')} at '{destination_file}'." + ) + return + + +def get_tethys_app(app_name): + """ + Get Tethys app from database. + """ + import django + + django.setup() + from tethys_apps.models import TethysApp + + db_app = TethysApp.objects.filter(package=app_name).first() + return db_app + + +def get_user(username): + """ + Get Django user from database. + """ + import django + + django.setup() + from django.contrib.auth.models import User + + user = User.objects.filter(username=username).first() + return user + + +def resolve_path(path_config, app, user=None): + """ + Resolve the path using a path config, app, and user. + """ + func = path_config["function"] + has_quota = path_config["quota"] + + args = {} + if has_quota: + args["bypass_quota"] = True + + if user: + return func(app, user, **args) + + return func(app, **args) diff --git a/tethys_quotas/utilities.py b/tethys_quotas/utilities.py index 608864a26..27d584f86 100644 --- a/tethys_quotas/utilities.py +++ b/tethys_quotas/utilities.py @@ -203,11 +203,20 @@ def get_quota(entity, codename): def _convert_storage_units(units, amount): base_units = _get_storage_units() - base_conversion = [item[0] for item in base_units if units.upper() in item[1]] + for item in base_units: + if isinstance(item[1], str): + if units.strip().lower() == item[1].strip().lower(): + base_conversion = item[0] + break + elif isinstance(item[1], tuple): + if units.strip().lower() in [s.strip().lower() for s in item[1]]: + base_conversion = item[0] + break + if not base_conversion: return None - amount = amount * base_conversion[0] + amount = amount * base_conversion for factor, suffix in base_units: # noqa: B007 if amount >= factor: @@ -231,3 +240,63 @@ def _get_storage_units(): (1024**1, " KB"), (1024**0, (" byte", " bytes")), ] + + +def _convert_to_bytes(units, amount): + conversion = { + "gb": 1024**3, + "mb": 1024**2, + "kb": 1024**1, + } + + if units.strip().lower() in conversion: + return amount * conversion[units.strip().lower()] + else: + return None + + +def can_add_file_to_path(app_or_user, codename, source_file): + """ + Checks if a file can be added to a path based on the quota for that path. + + Args: + app_or_user (User or TethysApp): the entity on which to perform quota check. + codename (str): codename of the path to check. + path_dict (dict): A dictionary containing information about the path. + source_file (Path): The file being added. + Returns: + bool: True if the file can be added, False otherwise. + """ + from tethys_quotas.utilities import get_resource_available + + from django.contrib.auth.models import User + from tethys_apps.models import TethysApp + + entity_types = { + "tethysapp_workspace_quota": TethysApp, + "user_workspace_quota": User, + } + + if codename not in entity_types.keys(): + raise ValueError(f"Invalid codename: {codename}") + + if not isinstance(app_or_user, entity_types[codename]): + raise ValueError( + f"Invalid entity type for codename {codename}, expected {entity_types[codename].__name__}, got {type(app_or_user).__name__}" + ) + + resource_available = get_resource_available(app_or_user, codename) + + if resource_available is not None: + if resource_available["resource_available"] == 0: + return False + + resource_available_bytes = _convert_to_bytes( + "gb", resource_available["resource_available"] + ) + + file_size = source_file.stat().st_size + if file_size > resource_available_bytes: + return False + + return True