Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions tests/unit_tests/test_tethys_cli/test_cli_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,40 +301,40 @@ def test_gen_salt_string_for_setting_with_secrets_deleted_or_changed(

@mock.patch("tethys_cli.cli_helpers.input")
@pytest.mark.django_db
def test_prompt_yes_or_no__accept_default_yes(mock_input, test_app):
def test_prompt_yes_or_no__yes(mock_input):
question = "How are you?"
mock_input.return_value = None
mock_input.return_value = "y"
test_val = cli_helper.prompt_yes_or_no(question)
assert test_val
mock_input.assert_called_once()


@mock.patch("tethys_cli.cli_helpers.input")
@pytest.mark.django_db
def test_prompt_yes_or_no__accept_default_no(mock_input, test_app):
def test_prompt_yes_or_no__no(mock_input):
question = "How are you?"
mock_input.return_value = None
test_val = cli_helper.prompt_yes_or_no(question, default="n")
mock_input.return_value = "no"
test_val = cli_helper.prompt_yes_or_no(question)
assert not test_val
mock_input.assert_called_once()


@mock.patch("tethys_cli.cli_helpers.input")
@pytest.mark.django_db
def test_prompt_yes_or_no__invalid_first(mock_input, test_app):
def test_prompt_yes_or_no__invalid_first(mock_input):
question = "How are you?"
mock_input.side_effect = ["invalid", "y"]
test_val = cli_helper.prompt_yes_or_no(question, default="n")
test_val = cli_helper.prompt_yes_or_no(question)
assert test_val
assert mock_input.call_count == 2


@mock.patch("tethys_cli.cli_helpers.input")
@pytest.mark.django_db
def test_prompt_yes_or_no__system_exit(mock_input, test_app):
def test_prompt_yes_or_no__system_exit(mock_input):
question = "How are you?"
mock_input.side_effect = SystemExit
test_val = cli_helper.prompt_yes_or_no(question, default="n")
test_val = cli_helper.prompt_yes_or_no(question)
assert test_val is None
mock_input.assert_called_once()

Expand Down
197 changes: 179 additions & 18 deletions tests/unit_tests/test_tethys_cli/test_install_commands.py

Large diffs are not rendered by default.

32 changes: 30 additions & 2 deletions tests/unit_tests/test_tethys_portal/test_context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ def has_terms_side_effect(module_name):
}
return modules.get(module_name, False)

@override_settings(MULTIPLE_APP_MODE=True)
@mock.patch("tethys_portal.context_processors.has_module")
@override_settings(MULTIPLE_APP_MODE=True)
def test_context_processors_multiple_app_mode(self, mock_has_module):
mock_user = mock.MagicMock(is_authenticated=True, is_active=True)
mock_request = mock.MagicMock(user=mock_user)
Expand Down Expand Up @@ -92,10 +92,10 @@ def test_context_processors_multiple_app_mode_no_request_user(
}
self.assertDictEqual(context, expected_context)

@override_settings(MULTIPLE_APP_MODE=False)
@mock.patch("tethys_portal.context_processors.has_module")
@mock.patch("tethys_portal.context_processors.messages")
@mock.patch("tethys_portal.context_processors.get_configured_standalone_app")
@override_settings(MULTIPLE_APP_MODE=False)
def test_context_processors_single_app_mode(
self, mock_get_configured_standalone_app, mock_messages, mock_has_module
):
Expand Down Expand Up @@ -150,3 +150,31 @@ def test_context_processors_debug_mode_true(self, mock_has_module):
"debug_mode": True,
}
self.assertDictEqual(context, expected_context)

@mock.patch(
"tethys_portal.context_processors.get_configured_standalone_app",
return_value=None,
)
@mock.patch("tethys_portal.context_processors.has_module", return_value=False)
@override_settings(DEBUG=True)
@override_settings(MULTIPLE_APP_MODE=False)
def test_context_processors_no_optional_modules(self, _, __):
mock_user = mock.MagicMock(is_authenticated=True, is_active=True)
mock_request = mock.MagicMock(user=mock_user)
context = context_processors.tethys_portal_context(mock_request)

expected_context = {
"has_analytical": False,
"has_cookieconsent": False,
"has_terms": False,
"has_mfa": False,
"has_gravatar": False,
"has_session_security": False,
"has_oauth2_provider": False,
"show_app_library_button": False,
"single_app_mode": True,
"configured_single_app": None,
"idp_backends": {}.keys(),
"debug_mode": True,
}
self.assertDictEqual(context, expected_context)
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,33 @@ def test__execute_lifecycle_commands_called_process_error(
self.assertEqual(mock_unpatched_run.call_count, 2)
mock_sleep.assert_not_called()

@patch("tethys_portal.views.app_lifecycle.re")
@patch("tethys_portal.views.app_lifecycle.get_channel_layer")
@patch("tethys_portal.views.app_lifecycle.unpatched_run")
@patch("tethys_portal.views.app_lifecycle.run")
@patch("tethys_portal.views.app_lifecycle.sleep")
def test__execute_lifecycle_commands_from_import(
self, mock_sleep, mock_run, mock_unpatched_run, mock_get_channel_layer, mock_re
):
mock_channel_layer = MagicMock()
mock_get_channel_layer.return_value = mock_channel_layer
mock_run.return_value.stdout = b"Successfully installed test_app"
commands = [("echo test", "Restarting server...")]
output = MagicMock(
stdout="Successfully installed test_app into your active Tethys Portal."
)
mock_unpatched_run.return_value = output
mock_match = MagicMock()
mock_re.search.return_value = mock_match
app_lifecycle._execute_lifecycle_commands(
"test_app", commands, from_import=True
)
self.assertTrue(mock_unpatched_run.called)
mock_run.assert_called_once()
mock_sleep.assert_called_once()
mock_re.search.assert_called_once()
mock_match.group.assert_called_once_with(mock_match.lastindex)

def test__execute_lifecycle_commands_cleanup_fails(self):
cleanup = MagicMock()
cleanup.side_effect = Exception()
Expand Down
2 changes: 1 addition & 1 deletion tethys_apps/base/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,7 @@ def page(
name: The internal name of the page, using only letters and underscores. Must be unique within the app. Defaults to the name of the function being decorated.
url: The page portion (i.e. slug) of the URL associated with this page (e.g. http://localhost:8000/app/my-app/<url>). Defaults to the name of the function being decorated, with underscores replaced by dashes (e.g. ``my_page`` becomes "my-page").
regex: Custom regex pattern(s) for url variables. If a string is provided, it will be applied to all variables. If a list or tuple is provided, they will be applied in variable order.
handler: Dot-notation path to a handler function that will process the actual request. This is for an advanced escape-hatch pattern to get back to Django templating.
handler: Function that will process the actual request. This is for an advanced escape-hatch pattern to get back to Django templating.
login_required: If user is required to be logged in to access the controller. Default is `True`.
redirect_field_name: URL query string parameter for the redirect path. Default is "next".
login_url: URL to send users to in order to authenticate. This defaults to the built-in login page of your Tethys Portal.
Expand Down
4 changes: 2 additions & 2 deletions tethys_cli/cli_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ def wrapped(*args, **kwargs):
return wrapped


def prompt_yes_or_no(question, default="y"):
def prompt_yes_or_no(question):
"""Handles a yes/no question cli prompt

Returns:
Expand All @@ -183,7 +183,7 @@ def prompt_yes_or_no(question, default="y"):
valid = False
while not valid:
try:
response = input(f"{question} [y/n]: ") or default
response = input(f"{question} [y/n]: ")
except (KeyboardInterrupt, SystemExit):
return None

Expand Down
3 changes: 2 additions & 1 deletion tethys_cli/gen_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -730,7 +730,8 @@ def render_template(file_type, context, destination_path):


def write_path_to_console(file_path, args):
write_info(f'File generated at "{file_path}".')
action_performed = getattr(args, "action_performed", "generated")
write_info(f'File {action_performed} at "{file_path}".')


def get_target_tethys_app_dir(args):
Expand Down
74 changes: 71 additions & 3 deletions tethys_cli/install_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from collections.abc import Mapping
import sys

from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, ValidationError

from tethys_cli.cli_colors import (
Expand All @@ -16,19 +17,22 @@
write_warning,
write_success,
)
from tethys_cli.settings_commands import settings_command
from tethys_cli.services_commands import services_list_command
from tethys_cli.cli_helpers import (
setup_django,
generate_salt_string,
load_conda_commands,
conda_run_command,
conda_available,
prompt_yes_or_no,
)
from tethys_apps.utilities import (
link_service_to_app_setting,
get_app_settings,
get_service_model_from_type,
get_tethys_home_dir,
get_installed_tethys_items,
)

from .gen_commands import download_vendor_static_files
Expand Down Expand Up @@ -880,8 +884,12 @@ def install_command(args):

return

multiple_app_mode_check(app_name, quiet_mode=args.quiet)

if args.no_db_sync:
write_success(f"Successfully installed {app_name}.")
write_success(
f"Successfully installed {app_name} into the active Tethys Portal."
)
return

call(["tethys", "db", "sync"])
Expand Down Expand Up @@ -927,8 +935,7 @@ def install_command(args):
process = Popen(str(path_to_post), shell=True, stdout=PIPE)
stdout = process.communicate()[0]
write_msg("Post Script Result: {}".format(stdout))

write_success(f"Successfully installed {app_name}.")
write_success(f"Successfully installed {app_name} into the active Tethys Portal.")


def validate_schema(check_str, check_list):
Expand Down Expand Up @@ -962,3 +969,64 @@ def assign_json_value(value):
else:
write_error(f"The current value: {value} is not a dict or a valid file path")
return None


def multiple_app_mode_check(new_app_name, quiet_mode=False):
"""
Check if MULTIPLE_APP_MODE needs to be updated based on the number of installed apps.
"""
if settings.MULTIPLE_APP_MODE:
return
setup_django()
if quiet_mode:
update_settings_args = Namespace(
set_kwargs=[
(
"TETHYS_PORTAL_CONFIG",
f"""
MULTIPLE_APP_MODE: False
STANDALONE_APP: {new_app_name}
""",
)
]
)
settings_command(update_settings_args)
write_msg(f"STANDALONE_APP set to {new_app_name}.")
elif len(get_installed_tethys_items(apps=True)) > 1:
response = prompt_yes_or_no(
"Your portal has multiple apps installed, but MULTIPLE_APP_MODE is set to False. Would you like to change that to True now?"
)
if response is True:
update_settings_args = Namespace(
set_kwargs=[
(
"TETHYS_PORTAL_CONFIG",
"""
MULTIPLE_APP_MODE: True
""",
)
]
)
settings_command(update_settings_args)
write_msg("MULTIPLE_APP_MODE set to True.")
elif response is False:
write_msg("MULTIPLE_APP_MODE left unchanged as False.")
response = prompt_yes_or_no(
f"Would you like to set the STANDALONE_APP to the newly installed app: {new_app_name}?"
)
if response is True:
update_settings_args = Namespace(
set_kwargs=[
(
"TETHYS_PORTAL_CONFIG",
f"""
MULTIPLE_APP_MODE: False
STANDALONE_APP: {new_app_name}
""",
)
]
)
settings_command(update_settings_args)
write_msg(f"STANDALONE_APP set to {new_app_name}.")
elif response is False:
write_msg("STANDALONE_APP left unchanged.")
1 change: 1 addition & 0 deletions tethys_cli/settings_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ def write_settings(tethys_settings):
tethys_portal_settings=portal_settings,
directory=None,
overwrite=True,
action_performed="updated",
)
generate_command(args)

Expand Down
23 changes: 18 additions & 5 deletions tethys_portal/views/app_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ def unpatched_run(main):
asyncio.run(main)


def _execute_lifecycle_commands(app_package, command_message_tuples, cleanup=None):
def _execute_lifecycle_commands(
app_package, command_message_tuples, from_import=False, cleanup=None
):
if cleanup and not callable(cleanup):
raise ValueError('The "cleanup" argument must be a function or callable.')

Expand Down Expand Up @@ -95,9 +97,13 @@ def _execute_lifecycle_commands(app_package, command_message_tuples, cleanup=Non
) # So the websocket has time to send the message prior to killing the server
result = run(command, shell=True, check=True, capture_output=True)
output = str(result.stdout)
if "Successfully installed " in output:
match = re.search(r"Successfully installed ([\w_]+)", output)
revised_app_package = match.group(match.lastindex)
if from_import:
match = re.search(
r"Successfully installed ([\w_]+) into your active Tethys Portal.",
output,
)
if match:
revised_app_package = match.group(match.lastindex)

except CalledProcessError as e:
unpatched_run(
Expand Down Expand Up @@ -194,7 +200,12 @@ def import_app(request):
Timer(
1,
_execute_lifecycle_commands,
args=[app_package, command_message_tuples, lambda: rmtree(tmpdir)],
args=[
app_package,
command_message_tuples,
True,
lambda: rmtree(tmpdir),
],
).start()

context["app_name"] = import_name
Expand Down Expand Up @@ -252,6 +263,8 @@ def create_app(request):
context["app_package"] = project_name
del context["form"]

context["project_location"] = Path.cwd() / f"{APP_PREFIX}-<Project Name>"

return render(request, "tethys_portal/create_app.html", context)


Expand Down