Skip to content
Open
Show file tree
Hide file tree
Changes from 12 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
24 changes: 24 additions & 0 deletions tests/unit_tests/test_tethys_portal/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,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
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()
48 changes: 47 additions & 1 deletion tethys_portal/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,11 +240,13 @@
"tethys_layouts",
"tethys_sdk",
"tethys_services",
"tethys_tenants",
"tethys_quotas",
"guardian",
]

for module in [
"django_tenants",
"analytical",
"axes",
"captcha",
Expand Down Expand Up @@ -359,6 +361,51 @@

ROOT_URLCONF = "tethys_portal.urls"

# Django Tenants settings
if has_module("django_tenants") and "TENANTS" in portal_config_settings:
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.db.backends.sqlite3"
)

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 = 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 +476,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.
70 changes: 70 additions & 0 deletions tethys_tenants/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""
********************************************************************************
* Name: admin.py
* Author: Michael Souffront
* Created On: 2025
* License: BSD 2-Clause
********************************************************************************
"""

from functools import wraps
from django.contrib import admin
from django.http import Http404
from tethys_portal.optional_dependencies import has_module
from tethys_tenants.models import Tenant, Domain


if has_module("django_tenants"):

def is_public_schema(request):
"""Check if current request is from public schema"""
return getattr(request.tenant, "schema_name", None) == "public"

def public_schema_only(view_func):
"""Decorator to ensure view is only accessible from public schema"""

@wraps(view_func)
def wrapper(self, request, *args, **kwargs):
if not is_public_schema(request):
raise Http404("Page not found")
return view_func(self, request, *args, **kwargs)

return wrapper

@admin.register(Domain)
class DomainAdmin(admin.ModelAdmin):
list_display = ("domain", "tenant")

def has_module_permission(self, request):
return is_public_schema(request)

@public_schema_only
def changelist_view(self, request, extra_context=None):
return super().changelist_view(request, extra_context)

@public_schema_only
def change_view(self, request, object_id, form_url="", extra_context=None):
return super().change_view(request, object_id, form_url, extra_context)

@public_schema_only
def add_view(self, request, form_url="", extra_context=None):
return super().add_view(request, form_url, extra_context)

@admin.register(Tenant)
class TenantAdmin(admin.ModelAdmin):
list_display = ("name",)

def has_module_permission(self, request):
return is_public_schema(request)

@public_schema_only
def changelist_view(self, request, extra_context=None):
return super().changelist_view(request, extra_context)

@public_schema_only
def change_view(self, request, object_id, form_url="", extra_context=None):
return super().change_view(request, object_id, form_url, extra_context)

@public_schema_only
def add_view(self, request, form_url="", extra_context=None):
return super().add_view(request, form_url, extra_context)
18 changes: 18 additions & 0 deletions tethys_tenants/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""
********************************************************************************
* Name: apps.py
* Author: Michael Souffront
* Created On: 2025
* License: BSD 2-Clause
********************************************************************************
"""

from django.apps import AppConfig
from tethys_portal.optional_dependencies import has_module


if has_module("django_tenants"):

class TethysTenantsConfig(AppConfig):
name = "tethys_tenants"
verbose_name = "Tethys Tenants"
Loading
Loading