Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
d218232
working on adding tenants
msouff Oct 31, 2025
4d3301a
working on adding tenants
msouff Oct 31, 2025
b647cff
simplified tethys_tenant base structure
msouff Nov 3, 2025
8336a6c
harverster or middleware won't work for tenant-aware apps
romer8 Oct 28, 2024
5eee441
more simplifications to the core tethys_tenants
msouff Nov 7, 2025
03f8244
fixing broken tests
msouff Nov 7, 2025
0fe616c
revert test_harvester.py
msouff Nov 7, 2025
8267196
tests
msouff Nov 10, 2025
b05d84a
context_processor.request is already in there
msouff Nov 11, 2025
3944006
removed tenants postgis backend. Not needed
msouff Nov 11, 2025
2b97459
tests
msouff Nov 12, 2025
baee361
linting
msouff Nov 12, 2025
ccc1f67
apply tenant migration only if has_module django tenants; more test u…
msouff Nov 13, 2025
0d62232
moved tethys_tenants app to django tenants settings
msouff Nov 13, 2025
a479437
working on documentation
msouff Nov 14, 2025
fb02b92
documentation
msouff Nov 18, 2025
a3e1b9a
fixed tests; more documentation
msouff Nov 19, 2025
54e87d6
Updated migration to only be detected if django_tenants is installed
jakeymac Dec 18, 2025
7ab9d65
Updated whats new page in docs
jakeymac Dec 18, 2025
36bc74d
Added warning in settings to make sure users are using django_tenants…
jakeymac Dec 18, 2025
0275ad3
Updated order of sections in production doc page
jakeymac Dec 18, 2025
0d372f6
Updates to documentation
jakeymac Dec 18, 2025
176258a
Fixed documentation
jakeymac Dec 18, 2025
88f2d29
Merge remote-tracking branch 'upstream/main' into tethys_tenants
jakeymac Dec 18, 2025
f788438
Updated tethys installation script to use django_tenants postgresql b…
jakeymac Dec 18, 2025
c88c451
Formatting fixes
jakeymac Dec 19, 2025
ee310b0
Fixed tests
jakeymac Dec 19, 2025
32718be
Updated configurations to display warnings to user concening tethys t…
jakeymac Dec 26, 2025
8c3c5c5
Formatting
jakeymac Dec 26, 2025
32a1eee
Merge branch 'main' into tethys_tenants
jakeymac Dec 26, 2025
a71e231
Updated documentation for tenants configurations
jakeymac Dec 26, 2025
91f6c7c
flake fix
jakeymac Dec 27, 2025
09af76a
Converted tests to pytest
jakeymac Dec 27, 2025
1c97e0c
formatting fixes
jakeymac Dec 27, 2025
21ac0b6
Updated default portal config in installation script to enable tenants
jakeymac Dec 27, 2025
ef3ddf1
Fixed config in install script
jakeymac Dec 27, 2025
2cca9e7
One more portal_config fix in install script
jakeymac Dec 27, 2025
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
1 change: 1 addition & 0 deletions environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ dependencies:
- hs_restclient # Used with HydroShare Social backend
- python-jose # required by django-mfa2 - used for onelogin backend
- django-oauth-toolkit
- django-tenants # enables multi-tenant support

# datetime dependencies for "humanize" template filter (used with MFA2)
- arrow
Expand Down
2 changes: 2 additions & 0 deletions tests/unit_tests/test_tethys_apps/test_base/test_handoff.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,13 +303,15 @@ def tear_down(self):
self.reload_urlconf()

@override_settings(PREFIX_URL="/")
@override_settings(SHOW_PUBLIC_IF_NO_TENANT_FOUND=True)
def test_test_app_handoff(self):
self.reload_urlconf()
response = self.c.get('/handoff/test-app/test_name/?csv_url=""')

self.assertEqual(302, response.status_code)

@override_settings(PREFIX_URL="test/prefix")
@override_settings(SHOW_PUBLIC_IF_NO_TENANT_FOUND=True)
def test_test_app_handoff_with_prefix(self):
self.reload_urlconf()
response = self.c.get('/test/prefix/handoff/test-app/test_name/?csv_url=""')
Expand Down
30 changes: 28 additions & 2 deletions tests/unit_tests/test_tethys_portal/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,12 +178,13 @@ def test_cors_config(self, _):
reload(settings)
self.assertListEqual(settings.CORS_ALLOWED_ORIGINS, ["http://example.com"])

@mock.patch("tethys_portal.optional_dependencies.has_module", return_value=False)
@mock.patch(
"tethys_portal.settings.yaml.safe_load",
return_value={"settings": {}},
)
@mock.patch("tethys_apps.utilities.relative_to_tethys_home")
def test_db_config_default(self, mock_home, _):
def test_db_config_default(self, mock_home, _, __):
name = mock.MagicMock()
name.exists.return_value = False
mock_home.return_value = name
Expand All @@ -196,6 +197,7 @@ def test_db_config_default(self, mock_home, _):
},
)

@mock.patch("tethys_portal.optional_dependencies.has_module", return_value=False)
@mock.patch(
"tethys_portal.settings.yaml.safe_load",
return_value={
Expand All @@ -204,7 +206,7 @@ def test_db_config_default(self, mock_home, _):
}
},
)
def test_db_config_postgres(self, _):
def test_db_config_postgres(self, _, __):
reload(settings)
self.assertDictEqual(
settings.DATABASES["default"],
Expand All @@ -218,6 +220,30 @@ def test_db_config_postgres(self, _):
},
)

@mock.patch(
"tethys_portal.settings.yaml.safe_load",
return_value={
"settings": {
"DATABASES": {
"default": {"ENGINE": "django_tenants.postgresql_backend"}
}
}
},
)
def test_db_config_tenants_postgres(self, _):
reload(settings)
self.assertDictEqual(
settings.DATABASES["default"],
{
"ENGINE": "django_tenants.postgresql_backend",
"NAME": "tethys_platform",
"USER": "tethys_default",
"PASSWORD": "pass",
"HOST": "localhost",
"PORT": 5436,
},
)

# TODO remove compatibility code tests with Tethys5.0 (or 4.2?)
@mock.patch(
"tethys_portal.settings.yaml.safe_load",
Expand Down
10 changes: 10 additions & 0 deletions tests/unit_tests/test_tethys_portal/test_views/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@ def tearDown(self):
self.reload_urlconf()
pass

@override_settings(SHOW_PUBLIC_IF_NO_TENANT_FOUND=True)
def test_get_csrf_not_authenticated(self):
"""Test get_csrf API endpoint not authenticated."""
response = self.client.get(reverse("api:get_csrf"))
self.assertEqual(response.status_code, 401)

@override_settings(SHOW_PUBLIC_IF_NO_TENANT_FOUND=True)
def test_get_csrf_authenticated(self):
"""Test get_csrf API endpoint authenticated."""
self.client.force_login(self.user)
Expand All @@ -43,11 +45,13 @@ def test_get_csrf_authenticated(self):
self.assertIsInstance(response, HttpResponse)
self.assertIn("X-CSRFToken", response.headers)

@override_settings(SHOW_PUBLIC_IF_NO_TENANT_FOUND=True)
def test_get_session_not_authenticated(self):
"""Test get_session API endpoint not authenticated."""
response = self.client.get(reverse("api:get_session"))
self.assertEqual(response.status_code, 401)

@override_settings(SHOW_PUBLIC_IF_NO_TENANT_FOUND=True)
def test_get_session_authenticated(self):
"""Test get_session API endpoint authenticated."""
self.client.force_login(self.user)
Expand All @@ -59,11 +63,13 @@ def test_get_session_authenticated(self):
self.assertIn("isAuthenticated", json)
self.assertTrue(json["isAuthenticated"])

@override_settings(SHOW_PUBLIC_IF_NO_TENANT_FOUND=True)
def test_get_whoami_not_authenticated(self):
"""Test get_whoami API endpoint not authenticated."""
response = self.client.get(reverse("api:get_whoami"))
self.assertEqual(response.status_code, 401)

@override_settings(SHOW_PUBLIC_IF_NO_TENANT_FOUND=True)
def test_get_whoami_authenticated(self):
"""Test get_whoami API endpoint authenticated."""
self.client.force_login(self.user)
Expand All @@ -84,6 +90,7 @@ def test_get_whoami_authenticated(self):
@override_settings(STATIC_URL="/static")
@override_settings(PREFIX_URL="/")
@override_settings(LOGIN_URL="/accounts/login/")
@override_settings(SHOW_PUBLIC_IF_NO_TENANT_FOUND=True)
def test_get_app_valid_id(self):
self.reload_urlconf()

Expand Down Expand Up @@ -122,6 +129,7 @@ def test_get_app_valid_id(self):
@override_settings(PREFIX_URL="test/prefix")
@override_settings(LOGIN_URL="/test/prefix/test/login/")
@override_settings(STATIC_URL="/test/prefix/test/static/")
@override_settings(SHOW_PUBLIC_IF_NO_TENANT_FOUND=True)
def test_get_app_valid_id_with_prefix(self):
self.reload_urlconf()

Expand Down Expand Up @@ -162,6 +170,7 @@ def test_get_app_valid_id_with_prefix(self):
@override_settings(STATIC_URL="/static")
@override_settings(PREFIX_URL="/")
@override_settings(LOGIN_URL="/accounts/login/")
@override_settings(SHOW_PUBLIC_IF_NO_TENANT_FOUND=True)
def test_get_app_authenticated(self):
self.client.force_login(self.user)
self.reload_urlconf()
Expand Down Expand Up @@ -209,6 +218,7 @@ def test_get_app_authenticated(self):
json["customSettings"],
)

@override_settings(SHOW_PUBLIC_IF_NO_TENANT_FOUND=True)
def test_get_app_invalid_id(self):
"""Test get_app API endpoint with invalid app id."""
response = self.client.get(reverse("api:get_app", kwargs={"app": "foo-bar"}))
Expand Down
Empty file.
113 changes: 113 additions & 0 deletions tests/unit_tests/test_tethys_tenants/test_admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import importlib
from unittest import mock
from django.test import TestCase, RequestFactory
from django.contrib import admin
from django.http import Http404
from django.contrib.admin.sites import AdminSite

from tethys_tenants import models
from tethys_tenants import admin as tethys_tenants_admin


class TethysTenantsAdminTest(TestCase):
def setUp(self):
self.factory = RequestFactory()
self.site = AdminSite()

def test_is_public_schema_function(self):
# Mock request with public tenant
public_request = mock.MagicMock()
public_request.tenant.schema_name = "public"
self.assertTrue(tethys_tenants_admin.is_public_schema(public_request))

# Mock request with non-public tenant
tenant_request = mock.MagicMock()
tenant_request.tenant.schema_name = "tenant1"
self.assertFalse(tethys_tenants_admin.is_public_schema(tenant_request))

def test_public_schema_only_decorator(self):
@tethys_tenants_admin.public_schema_only
def dummy_view(self, request):
return "success"

# Test with public schema
public_request = mock.MagicMock()
public_request.tenant.schema_name = "public"
result = dummy_view(None, public_request)
self.assertEqual(result, "success")

# Test with tenant schema - should raise Http404
tenant_request = mock.MagicMock()
tenant_request.tenant.schema_name = "tenant1"
with self.assertRaises(Http404):
dummy_view(None, tenant_request)

def test_domain_admin_registration(self):
registry = admin.site._registry
self.assertIn(models.Domain, registry)
self.assertIsInstance(registry[models.Domain], tethys_tenants_admin.DomainAdmin)

def test_tenant_admin_registration(self):
registry = admin.site._registry
self.assertIn(models.Tenant, registry)
self.assertIsInstance(registry[models.Tenant], tethys_tenants_admin.TenantAdmin)

def test_tenant_admin_configuration(self):
admin_instance = tethys_tenants_admin.TenantAdmin(models.Tenant, self.site)
self.assertEqual(admin_instance.list_display, ("name",))

def test_domain_admin_has_module_permission(self):
admin_instance = tethys_tenants_admin.DomainAdmin(models.Domain, self.site)

# Test with public schema
public_request = mock.MagicMock()
public_request.tenant.schema_name = "public"
self.assertTrue(admin_instance.has_module_permission(public_request))

# Test with tenant schema
tenant_request = mock.MagicMock()
tenant_request.tenant.schema_name = "tenant1"
self.assertFalse(admin_instance.has_module_permission(tenant_request))

def test_tenant_admin_has_module_permission(self):
admin_instance = tethys_tenants_admin.TenantAdmin(models.Tenant, self.site)

# Test with public schema
public_request = mock.MagicMock()
public_request.tenant.schema_name = "public"
self.assertTrue(admin_instance.has_module_permission(public_request))

# Test with tenant schema
tenant_request = mock.MagicMock()
tenant_request.tenant.schema_name = "tenant1"
self.assertFalse(admin_instance.has_module_permission(tenant_request))

def test_domain_admin_changelist_view_public_schema(self):
admin_instance = tethys_tenants_admin.DomainAdmin(models.Domain, self.site)

public_request = mock.MagicMock()
public_request.tenant.schema_name = "public"

with mock.patch(
"django.contrib.admin.ModelAdmin.changelist_view"
) as mock_super:
mock_super.return_value = "success"
result = admin_instance.changelist_view(public_request)
self.assertEqual(result, "success")
mock_super.assert_called_once_with(public_request, None)

def test_domain_admin_changelist_view_tenant_schema(self):
admin_instance = tethys_tenants_admin.DomainAdmin(models.Domain, self.site)

tenant_request = mock.MagicMock()
tenant_request.tenant.schema_name = "tenant1"

with self.assertRaises(Http404):
admin_instance.changelist_view(tenant_request)

@mock.patch("tethys_portal.optional_dependencies.has_module", return_value=False)
def test_admin_graceful_handling_without_django_tenants(self, mock_has_module):
importlib.reload(tethys_tenants_admin)

# Verify has_module was called
mock_has_module.assert_called()
24 changes: 24 additions & 0 deletions tests/unit_tests/test_tethys_tenants/test_apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import importlib
from unittest import mock
from django.test import TestCase
from django.apps import apps

from tethys_tenants import apps as tenant_apps


class TethysTenantsAppsTest(TestCase):
def test_tethys_tenants_config(self):
app_config = apps.get_app_config("tethys_tenants")
name = app_config.name
verbose_name = app_config.verbose_name

self.assertEqual("tethys_tenants", name)
self.assertEqual("Tethys Tenants", verbose_name)
self.assertTrue(isinstance(app_config, tenant_apps.TethysTenantsConfig))

@mock.patch("tethys_portal.optional_dependencies.has_module", return_value=False)
def test_tethys_tenants_config_unavailable(self, mock_has_module):
importlib.reload(tenant_apps)

# Verify has_module was called
mock_has_module.assert_called()
23 changes: 23 additions & 0 deletions tests/unit_tests/test_tethys_tenants/test_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import importlib
from unittest import mock
from django.test import TestCase

from tethys_tenants import models


class TethysTenantsModelsTest(TestCase):
def test_tenant_model_exists(self):
self.assertTrue(hasattr(models.Tenant, "name"))
self.assertTrue(hasattr(models.Tenant, "created_on"))
self.assertTrue(hasattr(models.Tenant, "auto_create_schema"))
self.assertTrue(models.Tenant.auto_create_schema)

def test_domain_model_exists(self):
# Domain inherits from DomainMixin, so just checking it exists
self.assertIsNotNone(models.Domain)

@mock.patch("tethys_portal.optional_dependencies.has_module", return_value=False)
def test_models_not_imported_when_django_tenants_unavailable(self, mock_has_module):
importlib.reload(models)

mock_has_module.assert_called()
Empty file added tethys_platform
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this file?

Empty file.
46 changes: 45 additions & 1 deletion tethys_portal/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,51 @@

ROOT_URLCONF = "tethys_portal.urls"

# Django Tenants settings
if has_module("django_tenants"):
TENANTS_CONFIG = portal_config_settings.pop("TENANTS", {})

# Tethys Tenants requires "django_tenants.postgresql_backend" as the database engine
# Set up in portal_config.yml
DATABASES["default"]["ENGINE"] = TENANTS_CONFIG.pop(
"DATABASE_ENGINE", "django_tenants.postgresql_backend"
)

DATABASE_ROUTERS = ("django_tenants.routers.TenantSyncRouter",)

TENANT_LIMIT_SET_CALLS = TENANTS_CONFIG.pop("TENANT_LIMIT_SET_CALLS", False)

TENANT_MODEL = "tethys_tenants.Tenant"
TENANT_DOMAIN_MODEL = "tethys_tenants.Domain"

TENANT_APPS = TENANTS_CONFIG.pop(
"TENANT_APPS_OVERRIDE",
[
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
],
)

SHARED_APPS = ("django_tenants", "tethys_tenants") + INSTALLED_APPS
TENANT_APPS = tuple(TENANT_APPS + TENANTS_CONFIG.pop("TENANT_APPS", []))

INSTALLED_APPS = tuple(
list(SHARED_APPS) + [app for app in TENANT_APPS if app not in SHARED_APPS]
)

MIDDLEWARE = list(MIDDLEWARE)
MIDDLEWARE.insert(0, "django_tenants.middleware.main.TenantMainMiddleware")
MIDDLEWARE = tuple(MIDDLEWARE)

SHOW_PUBLIC_IF_NO_TENANT_FOUND = TENANTS_CONFIG.pop(
"SHOW_PUBLIC_IF_NO_TENANT_FOUND",
False,
)

# Internationalization
LANGUAGE_CODE = "en-us"

Expand Down Expand Up @@ -429,7 +474,6 @@
}
]


# Static files (CSS, JavaScript, Images)
STATIC_URL = portal_config_settings.pop("STATIC_URL", "/static/")

Expand Down
Empty file added tethys_tenants/__init__.py
Empty file.
Loading