This is an automated email from the ASF dual-hosted git repository. yasithdev pushed a commit to branch feat/generic-experiment-launcher in repository https://gitbox.apache.org/repos/asf/airavata-portals.git
commit 3b79918d2b063891e77c4ba75d3cac44d439c322 Author: yasithdev <[email protected]> AuthorDate: Fri Apr 24 21:55:06 2026 -0400 feat(launcher): /workspace/launch view + 301 redirects from old URLs Adds the SPA shell view at /workspace/launch and wires 301 redirects from the old /workspace/applications, /applications/<id>/create_experiment, and /applications/<id>/ URLs. Updates all internal reverse() calls and nav config to point to the new launch URL. Includes failing-first TDD test suite with signal-handler and Vite-loader patches to make tests self-contained. --- .../django_airavata/apps/admin/views.py | 2 +- .../django_airavata/apps/auth/signals.py | 4 +- .../django_airavata/apps/workspace/apps.py | 4 +- .../django_airavata_workspace/launch.html | 10 +++ .../apps/workspace/tests/__init__.py | 0 .../apps/workspace/tests/test_launch_view.py | 96 ++++++++++++++++++++++ .../django_airavata/apps/workspace/urls.py | 12 +-- .../django_airavata/apps/workspace/views.py | 7 ++ airavata-django-portal/django_airavata/settings.py | 2 +- 9 files changed, 126 insertions(+), 11 deletions(-) diff --git a/airavata-django-portal/django_airavata/apps/admin/views.py b/airavata-django-portal/django_airavata/apps/admin/views.py index 5468b7776..927d98b80 100644 --- a/airavata-django-portal/django_airavata/apps/admin/views.py +++ b/airavata-django-portal/django_airavata/apps/admin/views.py @@ -14,7 +14,7 @@ def app_catalog(request): # sub-routes (new, edit) still render the admin SPA editor. path = request.path.rstrip("/") if path == "/admin/applications": - return redirect(reverse("django_airavata_workspace:applications")) + return redirect(reverse("django_airavata_workspace:launch")) request.active_nav_item = "app_catalog" return render(request, "admin/admin_base.html") diff --git a/airavata-django-portal/django_airavata/apps/auth/signals.py b/airavata-django-portal/django_airavata/apps/auth/signals.py index 143a78a32..12f20f53f 100644 --- a/airavata-django-portal/django_airavata/apps/auth/signals.py +++ b/airavata-django-portal/django_airavata/apps/auth/signals.py @@ -26,8 +26,8 @@ def email_user_added_to_group(sender, user, groups, request, **kwargs): "last_name": user.lastName, "username": user.userId, "portal_title": settings.PORTAL_TITLE, - "dashboard_url": request.build_absolute_uri(reverse("django_airavata_workspace:applications")), - "experiments_url": request.build_absolute_uri(reverse("django_airavata_workspace:applications")), + "dashboard_url": request.build_absolute_uri(reverse("django_airavata_workspace:launch")), + "experiments_url": request.build_absolute_uri(reverse("django_airavata_workspace:launch")), "group_names": [g.name for g in groups], } ) diff --git a/airavata-django-portal/django_airavata/apps/workspace/apps.py b/airavata-django-portal/django_airavata/apps/workspace/apps.py index ed13e11d3..15991acc8 100644 --- a/airavata-django-portal/django_airavata/apps/workspace/apps.py +++ b/airavata-django-portal/django_airavata/apps/workspace/apps.py @@ -21,8 +21,8 @@ class WorkspaceConfig(AiravataAppConfig): { "label": "Applications", "icon": "fa fa-cubes", - "url": "django_airavata_workspace:applications", - "active_prefixes": ["applications"], + "url": "django_airavata_workspace:launch", + "active_prefixes": ["applications", "launch"], }, { "label": "Datasets", diff --git a/airavata-django-portal/django_airavata/apps/workspace/templates/django_airavata_workspace/launch.html b/airavata-django-portal/django_airavata/apps/workspace/templates/django_airavata_workspace/launch.html new file mode 100644 index 000000000..5048ab712 --- /dev/null +++ b/airavata-django-portal/django_airavata/apps/workspace/templates/django_airavata_workspace/launch.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% load django_vite %} + +{% block content %} + <div id="launch-app" data-feature-flag="{{ feature_flag|yesno:'on,off' }}"></div> +{% endblock %} + +{% block scripts %} + {% vite_asset "js/entry-launch.ts" app="WORKSPACE" %} +{% endblock %} diff --git a/airavata-django-portal/django_airavata/apps/workspace/tests/__init__.py b/airavata-django-portal/django_airavata/apps/workspace/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/airavata-django-portal/django_airavata/apps/workspace/tests/test_launch_view.py b/airavata-django-portal/django_airavata/apps/workspace/tests/test_launch_view.py new file mode 100644 index 000000000..eb7065622 --- /dev/null +++ b/airavata-django-portal/django_airavata/apps/workspace/tests/test_launch_view.py @@ -0,0 +1,96 @@ +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.contrib.auth.signals import user_logged_in +from django.test import TestCase + +# Fake authz token returned by the patched get_authz_token so that +# authz_token_middleware doesn't log out the test user. +_FAKE_AUTHZ_TOKEN = { + "accessToken": "test.eyJ1c2VybmFtZSI6ImFsaWNlIn0.sig", + "gatewayID": "test-gateway", + "userName": "alice", +} + + +class LaunchViewTest(TestCase): + def setUp(self): + # Disconnect portal signal handlers that require request.authz_token, + # which is not present on plain Django test requests. + user_logged_in.disconnect(dispatch_uid="auth_initialize_user_profile") + user_logged_in.disconnect(dispatch_uid="auth_project_auto_provisioning") + + User = get_user_model() + self.user = User.objects.create_user(username="alice", password="pw") + self.client.force_login(self.user) + + # authz_token_middleware calls get_authz_token and logs out the user if + # it returns None (because there's no real Keycloak session in tests). + # Patch it to return a fake token so the test user stays logged in. + self._authz_patcher = patch( + "django_airavata.apps.auth.middleware.utils.get_authz_token", + return_value=_FAKE_AUTHZ_TOKEN, + ) + self._authz_patcher.start() + + # {% vite_asset %} requires a built manifest entry for js/entry-launch.ts, + # which won't exist until Task 9 builds it. Patch the tag to return an + # empty string so template rendering doesn't error out in unit tests. + self._vite_patcher = patch( + "django_vite.templatetags.django_vite.DjangoViteAssetLoader.instance", + return_value=_FakeViteLoader(), + ) + self._vite_patcher.start() + + def tearDown(self): + self._authz_patcher.stop() + self._vite_patcher.stop() + + # Re-connect the signal handlers so other test suites are not affected. + from django_airavata.apps.auth.signals import initialize_user_profile, provision_user_projects + + user_logged_in.connect( + initialize_user_profile, + dispatch_uid="auth_initialize_user_profile", + ) + user_logged_in.connect( + provision_user_projects, + dispatch_uid="auth_project_auto_provisioning", + ) + + def test_launch_renders_for_authenticated_user(self): + resp = self.client.get("/workspace/launch") + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'id="launch-app"') + + def test_old_create_experiment_url_redirects_to_launch(self): + resp = self.client.get("/workspace/applications/anything/create_experiment", follow=False) + self.assertEqual(resp.status_code, 301) + self.assertEqual(resp["Location"], "/workspace/launch") + + def test_old_applications_url_redirects_to_launch(self): + resp = self.client.get("/workspace/applications", follow=False) + self.assertEqual(resp.status_code, 301) + self.assertEqual(resp["Location"], "/workspace/launch") + + def test_launch_requires_auth(self): + self.client.logout() + self._authz_patcher.stop() + self._authz_patcher = patch( + "django_airavata.apps.auth.middleware.utils.get_authz_token", + return_value=None, + ) + self._authz_patcher.start() + resp = self.client.get("/workspace/launch", follow=False) + # Login redirect — exact path depends on AUTH_LOGIN_URL but we check redirect or 401 + self.assertIn(resp.status_code, (302, 401)) + + +class _FakeViteLoader: + """Minimal stub for DjangoViteAssetLoader used in tests only.""" + + def generate_vite_asset(self, path, app=None, **kwargs): + return "" + + def generate_vite_asset_url(self, path, app=None, **kwargs): + return "" diff --git a/airavata-django-portal/django_airavata/apps/workspace/urls.py b/airavata-django-portal/django_airavata/apps/workspace/urls.py index a4669f115..d493334b5 100644 --- a/airavata-django-portal/django_airavata/apps/workspace/urls.py +++ b/airavata-django-portal/django_airavata/apps/workspace/urls.py @@ -39,19 +39,21 @@ urlpatterns = [ ), # Project edit re_path(r"^projects/(?P<project_id>[^/]+)/edit$", views.edit_project, name="edit_project"), + # Generic experiment launcher (Task 5) + re_path(r"^launch$", views.launch, name="launch"), # Applications (gateway-wide) re_path(r"^applications/new$", views.new_application, name="new_application"), re_path( r"^applications/(?P<app_module_id>[^/]+)/create_experiment$", - views.create_experiment, - name="create_experiment", + RedirectView.as_view(url="/workspace/launch", permanent=True), + name="create_experiment_redirect", ), re_path( r"^applications/(?P<app_module_id>[^/]+)/$", - views.edit_application, - name="edit_application", + RedirectView.as_view(url="/workspace/launch", permanent=True), + name="application_editor_redirect", ), - re_path(r"^applications$", views.applications, name="applications"), + re_path(r"^applications$", RedirectView.as_view(url="/workspace/launch", permanent=True), name="applications_redirect"), # Resources — storage re_path( r"^storage/(?P<storage_resource_id>[^/]+)/tree(?:/(?P<path>.*))?$", diff --git a/airavata-django-portal/django_airavata/apps/workspace/views.py b/airavata-django-portal/django_airavata/apps/workspace/views.py index a5e945602..34258b59c 100644 --- a/airavata-django-portal/django_airavata/apps/workspace/views.py +++ b/airavata-django-portal/django_airavata/apps/workspace/views.py @@ -451,6 +451,13 @@ def datasets_list(request): ) +@login_required +def launch(request): + return render(request, "django_airavata_workspace/launch.html", { + "feature_flag": getattr(settings, "FEATURE_GENERIC_LAUNCHER", False), + }) + + @login_required def experiments_list(request, project_id): request.active_nav_item = "projects" diff --git a/airavata-django-portal/django_airavata/settings.py b/airavata-django-portal/django_airavata/settings.py index 493b1875f..0a1b99268 100644 --- a/airavata-django-portal/django_airavata/settings.py +++ b/airavata-django-portal/django_airavata/settings.py @@ -253,7 +253,7 @@ WAGTAILIMAGES_JPEG_QUALITY = 100 DATA_UPLOAD_MAX_NUMBER_FIELDS = 10000 LOGIN_URL = "django_airavata_auth:login" -LOGIN_REDIRECT_URL = "django_airavata_workspace:applications" +LOGIN_REDIRECT_URL = "django_airavata_workspace:launch" LOGOUT_REDIRECT_URL = "/" AUTHENTICATION_OPTIONS = {
