Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Standalone App Mode #1015

Merged
merged 26 commits into from
Apr 1, 2024
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4038ab0
initial implementation working for stand alone apps
ckrew Mar 6, 2024
1e4023b
new site setting for standalone_app_mode
ckrew Mar 7, 2024
ba5d1cc
updated setting name to STANDALONE_APP and removed the necessity to a…
ckrew Mar 8, 2024
8553288
additional updates and url fixes
ckrew Mar 8, 2024
02c5b67
liniting
ckrew Mar 8, 2024
a520a82
keep brand text for portal customization
ckrew Mar 8, 2024
2184f34
cleaned up urls and fixed testing issues with urls
ckrew Mar 11, 2024
283c1ee
added new python tests
ckrew Mar 11, 2024
ebb4723
updated the STANDALONE_APP setting to be a boolean MULTIPLE_APP_MODE…
ckrew Mar 11, 2024
5681a01
reverted old site_command code
ckrew Mar 12, 2024
ba0392c
added standalone app settings so users can decide which app to set as…
ckrew Mar 12, 2024
c1ad7b3
Merge branch 'main' into standalone_apps
ckrew Mar 12, 2024
6f2cba4
MULTIPLE_APP_MODE defaults to True now so no breaking changes happen
ckrew Mar 14, 2024
ea2c917
linted and black format
ckrew Mar 14, 2024
19feefa
removed namespace for standalone apps
ckrew Mar 14, 2024
cc66aab
lint and black format
ckrew Mar 14, 2024
b105731
cleaned up redirect functions for apps
ckrew Mar 14, 2024
ba4e9f6
fixed extension and websocket urls for single app mode
ckrew Mar 18, 2024
d328d82
reverted some conditional statements for get_active_app
ckrew Mar 18, 2024
6bb51bd
added new portal configurations to the documentation
ckrew Mar 22, 2024
b748752
Merge branch 'main' into standalone_apps
ckrew Mar 22, 2024
82b6349
black formatted
ckrew Mar 22, 2024
1f8a778
removed old test file
ckrew Apr 1, 2024
317ea92
Merge branch 'main' into standalone_apps
ckrew Apr 1, 2024
3476d67
moved user menu to its own template
ckrew Apr 1, 2024
1825cc4
black formatted and linted
ckrew Apr 1, 2024
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
2 changes: 2 additions & 0 deletions docs/tethys_portal/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ STATIC_ROOT the Django `STATIC_ROOT <http
STATICFILES_USE_NPM serves JavaScript dependencies through Tethys rather than using a content delivery network (CDN) when ``True``. Defaults to ``False``. When set to ``True`` then you must run ``tethys gen package_json`` to npm install the JS dependencies locally so they can be served by Tethys.
ADDITIONAL_TEMPLATE_DIRS a list of dot-paths to template directories. These will be prepended to Tethys's list of template directories so specific templates can be overriden.
ADDITIONAL_URLPATTERNS a list of dot-paths to list or tuples that define additional URL patterns to register in the portal. Additional URL patterns will precede default URL patterns so URLs will first match against user specified URL patterns.
MULTIPLE_APP_MODE boolean indicating if the portal should host multiple apps or be configured for a single standalone app.
STANDALONE_APP configured app for when ``MULTIPLE_APP_MODE`` is set to ``False``. If ``None``, then the first configured app in the DB will be used.
================================================== ================================================================================

SESSION_CONFIG
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from django.core.exceptions import ValidationError
from unittest import mock
from conda.cli.python_api import Commands
from tethys_apps.cli import install_commands
from tethys_cli import install_commands
ckrew marked this conversation as resolved.
Show resolved Hide resolved

FNULL = open(os.devnull, "w")

Expand Down
63 changes: 63 additions & 0 deletions tests/unit_tests/test_tethys_apps/test_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ def reload_urlconf(self, urlconf=None):
if urlconf is None:
urlconf = self.settings.ROOT_URLCONF
if urlconf in self.sys.modules:
self.reload(self.sys.modules["tethys_apps.urls"])
self.reload(self.sys.modules[urlconf])
else:
self.import_module(urlconf)
Expand Down Expand Up @@ -109,3 +110,65 @@ def test_urls(self):
self.assertEqual(
"tethysext.test_extension.controllers", resolver.func.__module__
)


@override_settings(MULTIPLE_APP_MODE=False)
class TestUrlsWithStandaloneApp(TethysTestCase):
import sys
from importlib import reload, import_module
from django.conf import settings
from django.urls import clear_url_caches

@classmethod
def reload_urlconf(self, urlconf=None):
self.clear_url_caches()
if urlconf is None:
urlconf = self.settings.ROOT_URLCONF
if urlconf in self.sys.modules:
self.reload(self.sys.modules["tethys_apps.urls"])
self.reload(self.sys.modules[urlconf])
else:
self.import_module(urlconf)

def set_up(self):
self.reload_urlconf()
pass

@override_settings(MULTIPLE_APP_MODE=True)
def tearDown(self):
self.reload_urlconf()
pass

def test_urls(self):
# This executes the code at the module level
url = reverse("home")
resolver = resolve(url)
self.assertEqual("/", url)
self.assertEqual("home", resolver.func.__name__)
self.assertEqual("tethysapp.test_app.controllers", resolver.func.__module__)

url = reverse("app_library")
resolver = resolve(url)
self.assertEqual("/apps/", url)
self.assertEqual("RedirectView", resolver.func.__name__)
self.assertEqual("home", resolver.func.view_initkwargs["pattern_name"])

url = reverse("send_beta_feedback")
resolver = resolve(url)
self.assertEqual("/send-beta-feedback/", url)
self.assertEqual("send_beta_feedback_email", resolver.func.__name__)
self.assertEqual("tethys_apps.views", resolver.func.__module__)

url = reverse("test_app:home")
resolver = resolve(url)
self.assertEqual("/", url)
self.assertEqual("home", resolver.func.__name__)
self.assertEqual("tethysapp.test_app.controllers", resolver.func.__module__)

url = reverse("test_extension:home", kwargs={"var1": "foo", "var2": "bar"})
resolver = resolve(url)
self.assertEqual("/extensions/foo/bar/", url)
self.assertEqual("home", resolver.func.__name__)
self.assertEqual(
"tethysext.test_extension.controllers", resolver.func.__module__
)
51 changes: 51 additions & 0 deletions tests/unit_tests/test_tethys_apps/test_utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from tethys_sdk.testing import TethysTestCase
from tethys_apps import utilities
from django.core.signing import Signer
from django.test import override_settings
from channels.generic.websocket import AsyncWebsocketConsumer, WebsocketConsumer


Expand Down Expand Up @@ -142,6 +143,18 @@ def test_get_active_app_request(self, mock_app):
result = utilities.get_active_app(request=mock_request)
self.assertEqual(mock_app.objects.get(), result)

@override_settings(MULTIPLE_APP_MODE=False)
@mock.patch("tethys_apps.utilities.get_configured_standalone_app")
def test_get_active_app_request_standalone_app(self, mock_first_app):
# Mock up for TethysApp, and request
mock_tethysapp = mock.MagicMock(root_url="test-app")
mock_first_app.return_value = mock_tethysapp
mock_request = mock.MagicMock()
mock_request.path = "/test-app/"

result = utilities.get_active_app(request=mock_request)
self.assertEqual(mock_tethysapp, result)

@mock.patch("tethys_apps.models.TethysApp")
def test_get_active_app_url(self, mock_app):
# Mock up for TethysApp
Expand Down Expand Up @@ -1027,6 +1040,44 @@ def test_secrets_signed_unsigned_value_with_secrets(
)
self.assertEqual(unsigned_secret, mock_val)

@override_settings(MULTIPLE_APP_MODE=False)
def test_get_configured_standalone_app_no_app_name(self):
from tethys_apps.models import TethysApp

with mock.patch(
"tethys_apps.models.TethysApp", wraps=TethysApp
) as mock_tethysapp:
result = utilities.get_configured_standalone_app()

self.assertEqual(result.package, "test_app")
mock_tethysapp.objects.first.assert_called_once()

@override_settings(MULTIPLE_APP_MODE=False, STANDALONE_APP="test_app")
def test_get_configured_standalone_app_given_app_name(self):
from tethys_apps.models import TethysApp

with mock.patch(
"tethys_apps.models.TethysApp", wraps=TethysApp
) as mock_tethysapp:
result = utilities.get_configured_standalone_app()

self.assertEqual(result.package, "test_app")
mock_tethysapp.objects.get.assert_called_with(package="test_app")

@override_settings(MULTIPLE_APP_MODE=False)
def test_get_configured_standalone_app_no_app_name_no_installed(self):
from tethys_apps.models import TethysApp
from django.core.exceptions import ObjectDoesNotExist

with mock.patch(
"tethys_apps.models.TethysApp", wraps=TethysApp
) as mock_tethysapp:
mock_tethysapp.objects.first.return_value = []
with self.assertRaises(ObjectDoesNotExist):
utilities.get_configured_standalone_app()

mock_tethysapp.objects.first.assert_called_once()

def test_update_decorated_websocket_consumer_class(self):
class TestConsumer(WebsocketConsumer):
def authorized_connect(self):
Expand Down
2 changes: 1 addition & 1 deletion tests/unit_tests/test_tethys_portal/test_asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def test_websocket_path(self):
)

def test_handlers_path(self):
expected_path = r"^test/prefix/apps/0/"
expected_path = r"^test/prefix/apps/test-app/"

# Get the URLRouter for "http" from the application
asgi_app = asgi.application.application_mapping["http"]
Expand Down
24 changes: 24 additions & 0 deletions tests/unit_tests/test_tethys_portal/test_context_processors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import unittest
from django.test import override_settings
from tethys_portal import context_processors


class TestStaticDependency(unittest.TestCase):
def setUp(self):
pass

def tearDown(self):
pass

@override_settings(MULTIPLE_APP_MODE=False)
def test_check_single_app_mode_single(self):
single_app_mode, single_app_name = context_processors.check_single_app_mode()

self.assertTrue(single_app_mode)
self.assertTrue(single_app_name == "Test App")

def test_check_single_app_mode_multiple(self):
single_app_mode, single_app_name = context_processors.check_single_app_mode()

self.assertFalse(single_app_mode)
self.assertTrue(single_app_name is None)
12 changes: 12 additions & 0 deletions tests/unit_tests/test_tethys_portal/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,18 @@ def test_bokeh_django_staticfiles_finder(self, _):
"bokeh_django.static.BokehExtensionFinder", settings.STATICFILES_FINDERS
)

@mock.patch(
"tethys_portal.settings.yaml.safe_load",
return_value={
"settings": {"TETHYS_PORTAL_CONFIG": {"MULTIPLE_APP_MODE": False}}
},
)
def test_portal_config_settings_standalone_app(self, _):
reload(settings)

self.assertTrue(settings.STANDALONE_APP is None)
self.assertTrue(settings.BYPASS_TETHYS_HOME_PAGE)

@mock.patch("tethys_portal.optional_dependencies.optional_import")
def test_bokehjsdir_compatibility(self, mock_oi):
mock_bokeh_settings = mock.MagicMock()
Expand Down
18 changes: 12 additions & 6 deletions tethys_apps/harvester.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,18 +84,21 @@ def harvest_apps(self):
except Exception:
"""DO NOTHING"""

def get_url_patterns(self):
def get_url_patterns(self, url_namespaces=None):
"""
Generate the url pattern lists for each app and namespace them accordingly.
"""
app_url_patterns = dict()
ext_url_patterns = dict()
ws_url_patterns = dict()
apps = self.apps
if url_namespaces:
apps = [app for app in apps if app.url_namespace in url_namespaces]

for app in self.apps:
for app in apps:
app_url_patterns.update(app.url_patterns["http"])

for app in self.apps:
for app in apps:
ws_url_patterns.update(app.url_patterns["websocket"])

for extension in self.extensions:
Expand All @@ -109,17 +112,20 @@ def get_url_patterns(self):

return url_patterns

def get_handler_patterns(self):
def get_handler_patterns(self, url_namespaces=None):
"""
Generate the url handler pattern lists for each app and namespace them accordingly.
"""
http_handler_patterns = dict()
ws_handler_patterns = dict()
apps = self.apps
if url_namespaces:
apps = [app for app in apps if app.url_namespace in url_namespaces]

for app in self.apps:
for app in apps:
http_handler_patterns.update(app.handler_patterns["http"])

for app in self.apps:
for app in apps:
ws_handler_patterns.update(app.handler_patterns["websocket"])

handler_patterns = {
Expand Down
73 changes: 65 additions & 8 deletions tethys_apps/templates/tethys_apps/app_base.html
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,25 @@
background: var(--app-primary-color, '#7ec1f7');
}

#app-header .btn-user-profile {
background-color: rgba(255, 255, 255, 0.08);
border: none;
color: {{ site_globals.primary_text_color }};
}

#app-header .btn-user-profile:hover {
background-color: rgba(255, 255, 255, 0.30);
}

#app-header .user-menu-button .dropdown-menu .dropdown-item {
color:#3f3f3f;
}

#app-header .user-menu-button .dropdown-menu .dropdown-item:active {
background-color: {{ site_globals.primary_color }};
color: #dddddd;
}

#app-navigation .nav li a {
color: var(--app-primary-color, '#7ec1f7');
}
Expand Down Expand Up @@ -192,16 +211,54 @@
{% endif %}
{% endblock %}
{% block settings_button_override %}
{% if request.user.is_staff %}
<div class="header-button settings-button">
<a href="javascript:void(0);" onclick="TETHYS_APP_BASE.exit_app('{% url 'admin:index' %}tethys_apps/tethysapp/{{ tethys_app.id }}/change/');" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Settings"><i class="bi bi-gear"></i></a>
</div>
{% endif %}
{% if request.user.is_staff %}
<div class="header-button settings-button">
<a href="javascript:void(0);" onclick="TETHYS_APP_BASE.exit_app('{% url 'admin:index' %}tethys_apps/tethysapp/{{ tethys_app.id }}/change/');" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Settings"><i class="bi bi-gear"></i></a>
</div>
{% endif %}
{% endblock %}
{% block exit_button_override %}
<div class="header-button exit-button">
<a href="javascript:void(0);" onclick="TETHYS_APP_BASE.exit_app('{% url 'app_library' %}');"data-bs-toggle="tooltip" data-bs-placement="bottom" title="Exit"><i class="bi bi-x"></i></a>
</div>
{% if not single_app_mode %}
ckrew marked this conversation as resolved.
Show resolved Hide resolved
<div class="header-button exit-button">
<a href="javascript:void(0);" onclick="TETHYS_APP_BASE.exit_app('{% url 'app_library' %}');"data-bs-toggle="tooltip" data-bs-placement="bottom" title="Exit"><i class="bi bi-x"></i></a>
</div>
{% endif %}
{% endblock %}
{% block user_menu %}
{% if user.is_authenticated and user.is_active and single_app_mode %}
<div class="user-menu-button">
<a id="user-profile" class="btn btn-light btn-user-profile" href="{% url 'user:profile' %}" title="User Profile">
<span>
{% if user.first_name %}{{ user.first_name }}{% else %}{{ user.username }}{% endif %}
{% if has_gravatar %}{% include "gravatar.html" with image_size=25 %}{% endif %}
</span>
</a>
<button type="button" class="btn btn-light btn-user-profile dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item" href="{% url 'user:profile' %}" title="User Settings">
<i class="bi bi-person-fill"></i><span class="ms-2">User Profile</span>
</a>
</li>
{% if user.is_staff %}
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="{% url 'admin:index' %}" title="System Admin Settings">
<i class="bi bi-gear-wide-connected"></i><span class="ms-2">Site Admin</span>
</a>
</li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="{% url 'accounts:logout' %}" title="Log Out">
<i class="bi bi-door-closed-fill"></i><span class="ms-2">Log Out</span>
</a>
</li>
</ul>
</div>
{% endif %}
{% endblock %}
</div>
{% endblock %}
Expand Down
Loading
Loading