From d21823236923c083a9832ec36185f82b3b3b9c61 Mon Sep 17 00:00:00 2001 From: Michael Souffront <17328135+msouff@users.noreply.github.com> Date: Fri, 31 Oct 2025 16:55:36 -0600 Subject: [PATCH 01/35] working on adding tenants --- tethys_portal/settings.py | 49 +++++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index bb4d9985c..57efb319e 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -121,6 +121,29 @@ DATABASES.setdefault("default", {}) DEFAULT_DB = DATABASES["default"] +# Django Tenants db settings +if has_module("django_tenants"): + DATABASES["default"]["ENGINE"] = "django_tenants.postgresql_backend" + DATABASE_ROUTERS = ("django_tenants.routers.TenantSyncRouter",) + + TENANT_MODEL = "tethys_tenants.Tenant" # app.Model + TENANT_DOMAIN_MODEL = "tethys_tenants.Domain" # app.Model + + TENANT_APPS = portal_config_settings.pop( + "TENANT_APPS_OVERRIDE", + [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "tethys_tenants.tenant_models" + ], + ) + + TENANT_APPS = tuple(TENANT_APPS + portal_config_settings.pop("TENANT_APPS", [])) + # ########### # backwards compatibility logic # TODO remove compatibility code with Tethys 5.0 @@ -240,11 +263,13 @@ "tethys_layouts", "tethys_sdk", "tethys_services", + "tethys_tenants", "tethys_quotas", "guardian", ] for module in [ + "django_tenants", "analytical", "axes", "captcha", @@ -265,14 +290,19 @@ default_installed_apps.append(module) -INSTALLED_APPS = portal_config_settings.pop( +SHARED_APPS = portal_config_settings.pop( "INSTALLED_APPS_OVERRIDE", default_installed_apps, ) -INSTALLED_APPS = tuple( - INSTALLED_APPS + portal_config_settings.pop("INSTALLED_APPS", []) -) +SHARED_APPS = tuple(SHARED_APPS + portal_config_settings.pop("INSTALLED_APPS", [])) + +if has_module("django_tenants"): + INSTALLED_APPS = tuple( + list(SHARED_APPS) + [app for app in TENANT_APPS if app not in SHARED_APPS] + ) +else: + INSTALLED_APPS = SHARED_APPS MIDDLEWARE = portal_config_settings.pop( "MIDDLEWARE_OVERRIDE", @@ -302,6 +332,10 @@ ) # TODO: Templates need to be upgraded if has_module("axes"): MIDDLEWARE.append("axes.middleware.AxesMiddleware") +if has_module("django_tenants"): + MIDDLEWARE.insert( + 0, "django_tenants.middleware.main.TenantMainMiddleware" + ) # Must be first in the list MIDDLEWARE = tuple(MIDDLEWARE + portal_config_settings.pop("MIDDLEWARE", [])) @@ -403,6 +437,12 @@ CONTEXT_PROCESSORS + portal_config_settings.pop("CONTEXT_PROCESSORS", []) ) +if has_module("django_tenants"): + CONTEXT_PROCESSORS = ( + "django.template.context_processors.request", + *CONTEXT_PROCESSORS, + ) + # Templates ADDITIONAL_TEMPLATE_DIRS = [ @@ -429,7 +469,6 @@ } ] - # Static files (CSS, JavaScript, Images) STATIC_URL = portal_config_settings.pop("STATIC_URL", "/static/") From 4d3301a59ffe0f035caf6b4fc5cfd722cf97f27a Mon Sep 17 00:00:00 2001 From: Michael Souffront <17328135+msouff@users.noreply.github.com> Date: Fri, 31 Oct 2025 16:56:08 -0600 Subject: [PATCH 02/35] working on adding tenants --- tethys_apps/harvester.py | 52 ++++++++++++++++++- tethys_tenants/__init__.py | 0 tethys_tenants/migrations/0001_initial.py | 40 ++++++++++++++ tethys_tenants/migrations/__init__.py | 0 tethys_tenants/models.py | 17 ++++++ tethys_tenants/tenant_models/__init__.py | 0 .../tenant_models/migrations/0001_initial.py | 30 +++++++++++ .../tenant_models/migrations/__init__.py | 0 tethys_tenants/tenant_models/models.py | 20 +++++++ 9 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 tethys_tenants/__init__.py create mode 100644 tethys_tenants/migrations/0001_initial.py create mode 100644 tethys_tenants/migrations/__init__.py create mode 100644 tethys_tenants/models.py create mode 100644 tethys_tenants/tenant_models/__init__.py create mode 100644 tethys_tenants/tenant_models/migrations/0001_initial.py create mode 100644 tethys_tenants/tenant_models/migrations/__init__.py create mode 100644 tethys_tenants/tenant_models/models.py diff --git a/tethys_apps/harvester.py b/tethys_apps/harvester.py index 60d67645b..529f07035 100644 --- a/tethys_apps/harvester.py +++ b/tethys_apps/harvester.py @@ -16,6 +16,9 @@ from django.core.exceptions import ObjectDoesNotExist from tethys_apps.base import TethysAppBase, TethysExtensionBase from tethys_apps.base.testing.environment import is_testing_environment +from tethys_portal.optional_dependencies import has_module + + tethys_log = logging.getLogger("tethys." + __name__) @@ -61,9 +64,13 @@ def harvest_extensions(self): except Exception: """DO NOTHING""" - def harvest_apps(self): + def harvest_apps(self, tenant_schema=None): """ - Searches the apps package for apps + Searches the apps package for apps, optionally filtered by schema. + + Args: + tenant_schema (str): Only load apps for this tenant schema + """ # Notify user harvesting is taking place @@ -77,12 +84,53 @@ def harvest_apps(self): for _, modname, ispkg in pkgutil.iter_modules(tethysapp.__path__): if ispkg: tethys_apps[modname] = "tethysapp.{}".format(modname) + + # Filter apps by tenant if django-tenants is available and tenant is specified + if has_module("django_tenants") and tenant_schema: + tethys_apps = self._filter_apps_by_tenant(tethys_apps, tenant_schema) # Harvest App Instances self._harvest_app_instances(tethys_apps) except Exception: """DO NOTHING""" + + def _filter_apps_by_tenant(self, available_apps, tenant_schema): + """ + Filter apps based on tenant schema. + + Args: + available_apps (dict): Dictionary of available app packages + tenant_schema (str): Tenant schema name + + Returns: + dict: Filtered apps for the tenant + """ + try: + from tethys_tenants.models import Tenant + from tethys_tenants.tenant_models.models import App as TenantApp + + # Get the current tenant + tenant = Tenant.objects.get(schema_name=tenant_schema) + + # Get enabled apps for this tenant + tenant_apps = TenantApp.objects.filter( + client=tenant, + enabled=True + ).values_list('app_package', flat=True) + + # Filter available apps to only include tenant-specific apps + filtered_apps = {} + for app_name, app_package in available_apps.items(): + if app_package in tenant_apps: + filtered_apps[app_name] = app_package + + return filtered_apps + + except Exception as e: + tethys_log.warning(f"Error filtering apps by tenant: {e}") + # Return all apps if filtering fails + return available_apps def get_url_patterns(self, url_namespaces=None): """ diff --git a/tethys_tenants/__init__.py b/tethys_tenants/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tethys_tenants/migrations/0001_initial.py b/tethys_tenants/migrations/0001_initial.py new file mode 100644 index 000000000..8f0975fcb --- /dev/null +++ b/tethys_tenants/migrations/0001_initial.py @@ -0,0 +1,40 @@ +# Generated by Django 5.2.7 on 2025-10-31 22:25 + +import django.db.models.deletion +import django_tenants.postgresql_backend.base +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Tenant', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('schema_name', models.CharField(db_index=True, max_length=63, unique=True, validators=[django_tenants.postgresql_backend.base._check_schema_name])), + ('name', models.CharField(max_length=100)), + ('created_on', models.DateField(auto_now_add=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Domain', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('domain', models.CharField(db_index=True, max_length=253, unique=True)), + ('is_primary', models.BooleanField(db_index=True, default=True)), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='domains', to='tethys_tenants.tenant')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/tethys_tenants/migrations/__init__.py b/tethys_tenants/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tethys_tenants/models.py b/tethys_tenants/models.py new file mode 100644 index 000000000..bae439de6 --- /dev/null +++ b/tethys_tenants/models.py @@ -0,0 +1,17 @@ +from django.db import models +from tethys_portal.optional_dependencies import optional_import, has_module + +TenantMixin = optional_import("TenantMixin", from_module="django_tenants.models") +DomainMixin = optional_import("DomainMixin", from_module="django_tenants.models") + +if has_module(TenantMixin): + class Tenant(TenantMixin): + name = models.CharField(max_length=100) + created_on = models.DateField(auto_now_add=True) + + # default true, schema will be automatically created and synced when it is saved + auto_create_schema = True + +if has_module(DomainMixin): + class Domain(DomainMixin): + pass diff --git a/tethys_tenants/tenant_models/__init__.py b/tethys_tenants/tenant_models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tethys_tenants/tenant_models/migrations/0001_initial.py b/tethys_tenants/tenant_models/migrations/0001_initial.py new file mode 100644 index 000000000..9173aea03 --- /dev/null +++ b/tethys_tenants/tenant_models/migrations/0001_initial.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.7 on 2025-10-31 22:25 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('tethys_tenants', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='App', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('app_package', models.CharField(max_length=200)), + ('enabled', models.BooleanField(default=True)), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tenant_apps', to='tethys_tenants.tenant')), + ], + options={ + 'verbose_name': 'Tenant App', + 'verbose_name_plural': 'Tenant Apps', + 'unique_together': {('tenant', 'app_package')}, + }, + ), + ] diff --git a/tethys_tenants/tenant_models/migrations/__init__.py b/tethys_tenants/tenant_models/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tethys_tenants/tenant_models/models.py b/tethys_tenants/tenant_models/models.py new file mode 100644 index 000000000..15a40735c --- /dev/null +++ b/tethys_tenants/tenant_models/models.py @@ -0,0 +1,20 @@ +from django.db import models +from tethys_tenants.models import Tenant +from tethys_portal.optional_dependencies import has_module + + +if has_module("django_tenants"): + class App(models.Model): + """Model to associate apps with specific tenants""" + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='tenant_apps') + app_package = models.CharField(max_length=200) # e.g., "tethysapp.my_app" + enabled = models.BooleanField(default=True) + + class Meta: + app_label = 'tenant_models' # This tells Django which app this belongs to + verbose_name = "Tenant App" + verbose_name_plural = "Tenant Apps" + unique_together = ('tenant', 'app_package') + + def __str__(self): + return f"{self.tenant.schema_name}: {self.app_package}" From b647cffbb0d13e1a8c755da460e45e790aaa06e3 Mon Sep 17 00:00:00 2001 From: Michael Souffront <17328135+msouff@users.noreply.github.com> Date: Mon, 3 Nov 2025 14:35:51 -0700 Subject: [PATCH 03/35] simplified tethys_tenant base structure --- tethys_apps/harvester.py | 2 +- tethys_portal/settings.py | 56 +++++++++---------- tethys_tenants/migrations/0001_initial.py | 16 +++++- tethys_tenants/models.py | 26 ++++++++- tethys_tenants/tenant_models/__init__.py | 0 .../tenant_models/migrations/0001_initial.py | 30 ---------- .../tenant_models/migrations/__init__.py | 0 tethys_tenants/tenant_models/models.py | 20 ------- 8 files changed, 66 insertions(+), 84 deletions(-) delete mode 100644 tethys_tenants/tenant_models/__init__.py delete mode 100644 tethys_tenants/tenant_models/migrations/0001_initial.py delete mode 100644 tethys_tenants/tenant_models/migrations/__init__.py delete mode 100644 tethys_tenants/tenant_models/models.py diff --git a/tethys_apps/harvester.py b/tethys_apps/harvester.py index 529f07035..0d68e2a60 100644 --- a/tethys_apps/harvester.py +++ b/tethys_apps/harvester.py @@ -108,7 +108,7 @@ def _filter_apps_by_tenant(self, available_apps, tenant_schema): """ try: from tethys_tenants.models import Tenant - from tethys_tenants.tenant_models.models import App as TenantApp + from tethys_tenants.models import App as TenantApp # Get the current tenant tenant = Tenant.objects.get(schema_name=tenant_schema) diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index 57efb319e..df46126cc 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -121,29 +121,6 @@ DATABASES.setdefault("default", {}) DEFAULT_DB = DATABASES["default"] -# Django Tenants db settings -if has_module("django_tenants"): - DATABASES["default"]["ENGINE"] = "django_tenants.postgresql_backend" - DATABASE_ROUTERS = ("django_tenants.routers.TenantSyncRouter",) - - TENANT_MODEL = "tethys_tenants.Tenant" # app.Model - TENANT_DOMAIN_MODEL = "tethys_tenants.Domain" # app.Model - - TENANT_APPS = portal_config_settings.pop( - "TENANT_APPS_OVERRIDE", - [ - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", - "django.contrib.staticfiles", - "tethys_tenants.tenant_models" - ], - ) - - TENANT_APPS = tuple(TENANT_APPS + portal_config_settings.pop("TENANT_APPS", [])) - # ########### # backwards compatibility logic # TODO remove compatibility code with Tethys 5.0 @@ -290,19 +267,38 @@ default_installed_apps.append(module) -SHARED_APPS = portal_config_settings.pop( +INSTALLED_APPS = portal_config_settings.pop( "INSTALLED_APPS_OVERRIDE", default_installed_apps, ) -SHARED_APPS = tuple(SHARED_APPS + portal_config_settings.pop("INSTALLED_APPS", [])) - +# Django Tenants settings if has_module("django_tenants"): + DATABASES["default"]["ENGINE"] = "django_tenants.postgresql_backend" + DATABASE_ROUTERS = ("django_tenants.routers.TenantSyncRouter",) + + TENANT_MODEL = "tethys_tenants.Tenant" + TENANT_DOMAIN_MODEL = "tethys_tenants.Domain" + + TENANT_APPS = portal_config_settings.pop( + "TENANT_APPS_OVERRIDE", + [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "tethys_tenants", + ], + ) + + SHARED_APPS = INSTALLED_APPS + TENANT_APPS = tuple(TENANT_APPS + portal_config_settings.pop("TENANT_APPS", [])) + INSTALLED_APPS = tuple( list(SHARED_APPS) + [app for app in TENANT_APPS if app not in SHARED_APPS] ) -else: - INSTALLED_APPS = SHARED_APPS MIDDLEWARE = portal_config_settings.pop( "MIDDLEWARE_OVERRIDE", @@ -333,9 +329,7 @@ if has_module("axes"): MIDDLEWARE.append("axes.middleware.AxesMiddleware") if has_module("django_tenants"): - MIDDLEWARE.insert( - 0, "django_tenants.middleware.main.TenantMainMiddleware" - ) # Must be first in the list + MIDDLEWARE.insert(0, "django_tenants.middleware.main.TenantMainMiddleware") MIDDLEWARE = tuple(MIDDLEWARE + portal_config_settings.pop("MIDDLEWARE", [])) diff --git a/tethys_tenants/migrations/0001_initial.py b/tethys_tenants/migrations/0001_initial.py index 8f0975fcb..92deb37bf 100644 --- a/tethys_tenants/migrations/0001_initial.py +++ b/tethys_tenants/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.7 on 2025-10-31 22:25 +# Generated by Django 5.2.7 on 2025-11-03 21:19 import django.db.models.deletion import django_tenants.postgresql_backend.base @@ -37,4 +37,18 @@ class Migration(migrations.Migration): 'abstract': False, }, ), + migrations.CreateModel( + name='App', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('app_package', models.CharField(max_length=200)), + ('enabled', models.BooleanField(default=True)), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tenant_apps', to='tethys_tenants.tenant')), + ], + options={ + 'verbose_name': 'Tenant App', + 'verbose_name_plural': 'Tenant Apps', + 'unique_together': {('tenant', 'app_package')}, + }, + ), ] diff --git a/tethys_tenants/models.py b/tethys_tenants/models.py index bae439de6..9eceedbd3 100644 --- a/tethys_tenants/models.py +++ b/tethys_tenants/models.py @@ -5,13 +5,37 @@ DomainMixin = optional_import("DomainMixin", from_module="django_tenants.models") if has_module(TenantMixin): + class Tenant(TenantMixin): name = models.CharField(max_length=100) created_on = models.DateField(auto_now_add=True) - # default true, schema will be automatically created and synced when it is saved + # Schema will be automatically created and synced on save when True auto_create_schema = True + if has_module(DomainMixin): + class Domain(DomainMixin): pass + + +if has_module("django_tenants"): + + class App(models.Model): + """Model to associate apps with specific tenants""" + + tenant = models.ForeignKey( + Tenant, on_delete=models.CASCADE, related_name="tenant_apps" + ) + app_package = models.CharField(max_length=200) + enabled = models.BooleanField(default=True) + + class Meta: + app_label = "tethys_tenants" # The Django app this belongs to + verbose_name = "Tenant App" + verbose_name_plural = "Tenant Apps" + unique_together = ("tenant", "app_package") + + def __str__(self): + return f"{self.tenant.schema_name}: {self.app_package}" diff --git a/tethys_tenants/tenant_models/__init__.py b/tethys_tenants/tenant_models/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tethys_tenants/tenant_models/migrations/0001_initial.py b/tethys_tenants/tenant_models/migrations/0001_initial.py deleted file mode 100644 index 9173aea03..000000000 --- a/tethys_tenants/tenant_models/migrations/0001_initial.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 5.2.7 on 2025-10-31 22:25 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('tethys_tenants', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='App', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('app_package', models.CharField(max_length=200)), - ('enabled', models.BooleanField(default=True)), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tenant_apps', to='tethys_tenants.tenant')), - ], - options={ - 'verbose_name': 'Tenant App', - 'verbose_name_plural': 'Tenant Apps', - 'unique_together': {('tenant', 'app_package')}, - }, - ), - ] diff --git a/tethys_tenants/tenant_models/migrations/__init__.py b/tethys_tenants/tenant_models/migrations/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tethys_tenants/tenant_models/models.py b/tethys_tenants/tenant_models/models.py deleted file mode 100644 index 15a40735c..000000000 --- a/tethys_tenants/tenant_models/models.py +++ /dev/null @@ -1,20 +0,0 @@ -from django.db import models -from tethys_tenants.models import Tenant -from tethys_portal.optional_dependencies import has_module - - -if has_module("django_tenants"): - class App(models.Model): - """Model to associate apps with specific tenants""" - tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name='tenant_apps') - app_package = models.CharField(max_length=200) # e.g., "tethysapp.my_app" - enabled = models.BooleanField(default=True) - - class Meta: - app_label = 'tenant_models' # This tells Django which app this belongs to - verbose_name = "Tenant App" - verbose_name_plural = "Tenant Apps" - unique_together = ('tenant', 'app_package') - - def __str__(self): - return f"{self.tenant.schema_name}: {self.app_package}" From 8336a6c3f4a943bae16697a74c8dc429e76155ac Mon Sep 17 00:00:00 2001 From: Gio <43544549+romer8@users.noreply.github.com> Date: Mon, 28 Oct 2024 08:24:59 -0600 Subject: [PATCH 04/35] harverster or middleware won't work for tenant-aware apps --- .../test_tethys_apps/test_harvester.py | 16 ++++-- tethys_apps/harvester.py | 52 +------------------ tethys_portal/settings.py | 2 +- tethys_tenants/migrations/0001_initial.py | 16 +----- tethys_tenants/models.py | 21 -------- tethys_tenants/tenant_models/__init__.py | 0 .../tenant_models/migrations/0001_initial.py | 30 +++++++++++ .../tenant_models/migrations/__init__.py | 0 tethys_tenants/tenant_models/models.py | 24 +++++++++ 9 files changed, 71 insertions(+), 90 deletions(-) create mode 100644 tethys_tenants/tenant_models/__init__.py create mode 100644 tethys_tenants/tenant_models/migrations/0001_initial.py create mode 100644 tethys_tenants/tenant_models/migrations/__init__.py create mode 100644 tethys_tenants/tenant_models/models.py diff --git a/tests/unit_tests/test_tethys_apps/test_harvester.py b/tests/unit_tests/test_tethys_apps/test_harvester.py index bbbfec21e..edb5ddc76 100644 --- a/tests/unit_tests/test_tethys_apps/test_harvester.py +++ b/tests/unit_tests/test_tethys_apps/test_harvester.py @@ -297,12 +297,16 @@ def test_harvest_app_instances_programming_error( :return: """ list_apps = {"test_app": "tethysapp.test_app"} + exceptions = [ProgrammingError, SqliteProgrammingError] - mock_permissions.side_effect = ProgrammingError + for exception in exceptions: + with self.subTest(exception=exception): + mock_permissions.side_effect = exception - shv = SingletonHarvester() - shv._harvest_app_instances(list_apps) + shv = SingletonHarvester() + shv._harvest_app_instances(list_apps) +<<<<<<< HEAD mock_logwarning.assert_called_with( "Unable to register app permissions. django_content_type table does not exist" ) @@ -338,6 +342,12 @@ def test_harvest_app_instances_sqlite_programming_error( mock_permissions.assert_called() self.assertIn("Tethys Apps Loaded:", mock_stdout.getvalue()) self.assertIn("test_app", mock_stdout.getvalue()) +======= + mock_logwarning.assert_called() + mock_permissions.assert_called() + self.assertIn("Tethys Apps Loaded:", mock_stdout.getvalue()) + self.assertIn("test_app", mock_stdout.getvalue()) +>>>>>>> da1d12a9 (Check for Sqlite ProgramingError Exception (#1106)) @mock.patch("sys.stdout", new_callable=io.StringIO) @mock.patch("tethys_apps.harvester.tethys_log.warning") diff --git a/tethys_apps/harvester.py b/tethys_apps/harvester.py index 0d68e2a60..60d67645b 100644 --- a/tethys_apps/harvester.py +++ b/tethys_apps/harvester.py @@ -16,9 +16,6 @@ from django.core.exceptions import ObjectDoesNotExist from tethys_apps.base import TethysAppBase, TethysExtensionBase from tethys_apps.base.testing.environment import is_testing_environment -from tethys_portal.optional_dependencies import has_module - - tethys_log = logging.getLogger("tethys." + __name__) @@ -64,13 +61,9 @@ def harvest_extensions(self): except Exception: """DO NOTHING""" - def harvest_apps(self, tenant_schema=None): + def harvest_apps(self): """ - Searches the apps package for apps, optionally filtered by schema. - - Args: - tenant_schema (str): Only load apps for this tenant schema - + Searches the apps package for apps """ # Notify user harvesting is taking place @@ -84,53 +77,12 @@ def harvest_apps(self, tenant_schema=None): for _, modname, ispkg in pkgutil.iter_modules(tethysapp.__path__): if ispkg: tethys_apps[modname] = "tethysapp.{}".format(modname) - - # Filter apps by tenant if django-tenants is available and tenant is specified - if has_module("django_tenants") and tenant_schema: - tethys_apps = self._filter_apps_by_tenant(tethys_apps, tenant_schema) # Harvest App Instances self._harvest_app_instances(tethys_apps) except Exception: """DO NOTHING""" - - def _filter_apps_by_tenant(self, available_apps, tenant_schema): - """ - Filter apps based on tenant schema. - - Args: - available_apps (dict): Dictionary of available app packages - tenant_schema (str): Tenant schema name - - Returns: - dict: Filtered apps for the tenant - """ - try: - from tethys_tenants.models import Tenant - from tethys_tenants.models import App as TenantApp - - # Get the current tenant - tenant = Tenant.objects.get(schema_name=tenant_schema) - - # Get enabled apps for this tenant - tenant_apps = TenantApp.objects.filter( - client=tenant, - enabled=True - ).values_list('app_package', flat=True) - - # Filter available apps to only include tenant-specific apps - filtered_apps = {} - for app_name, app_package in available_apps.items(): - if app_package in tenant_apps: - filtered_apps[app_name] = app_package - - return filtered_apps - - except Exception as e: - tethys_log.warning(f"Error filtering apps by tenant: {e}") - # Return all apps if filtering fails - return available_apps def get_url_patterns(self, url_namespaces=None): """ diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index df46126cc..3f16b7e05 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -289,7 +289,7 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - "tethys_tenants", + "tethys_tenants.tenant_models", ], ) diff --git a/tethys_tenants/migrations/0001_initial.py b/tethys_tenants/migrations/0001_initial.py index 92deb37bf..752a31107 100644 --- a/tethys_tenants/migrations/0001_initial.py +++ b/tethys_tenants/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.7 on 2025-11-03 21:19 +# Generated by Django 5.2.7 on 2025-11-03 22:30 import django.db.models.deletion import django_tenants.postgresql_backend.base @@ -37,18 +37,4 @@ class Migration(migrations.Migration): 'abstract': False, }, ), - migrations.CreateModel( - name='App', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('app_package', models.CharField(max_length=200)), - ('enabled', models.BooleanField(default=True)), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tenant_apps', to='tethys_tenants.tenant')), - ], - options={ - 'verbose_name': 'Tenant App', - 'verbose_name_plural': 'Tenant Apps', - 'unique_together': {('tenant', 'app_package')}, - }, - ), ] diff --git a/tethys_tenants/models.py b/tethys_tenants/models.py index 9eceedbd3..e63b2cffb 100644 --- a/tethys_tenants/models.py +++ b/tethys_tenants/models.py @@ -18,24 +18,3 @@ class Tenant(TenantMixin): class Domain(DomainMixin): pass - - -if has_module("django_tenants"): - - class App(models.Model): - """Model to associate apps with specific tenants""" - - tenant = models.ForeignKey( - Tenant, on_delete=models.CASCADE, related_name="tenant_apps" - ) - app_package = models.CharField(max_length=200) - enabled = models.BooleanField(default=True) - - class Meta: - app_label = "tethys_tenants" # The Django app this belongs to - verbose_name = "Tenant App" - verbose_name_plural = "Tenant Apps" - unique_together = ("tenant", "app_package") - - def __str__(self): - return f"{self.tenant.schema_name}: {self.app_package}" diff --git a/tethys_tenants/tenant_models/__init__.py b/tethys_tenants/tenant_models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tethys_tenants/tenant_models/migrations/0001_initial.py b/tethys_tenants/tenant_models/migrations/0001_initial.py new file mode 100644 index 000000000..5ee1c76a5 --- /dev/null +++ b/tethys_tenants/tenant_models/migrations/0001_initial.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.7 on 2025-11-03 22:30 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('tethys_tenants', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='App', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('app_package', models.CharField(max_length=200)), + ('enabled', models.BooleanField(default=True)), + ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tenant_apps', to='tethys_tenants.tenant')), + ], + options={ + 'verbose_name': 'Tenant App', + 'verbose_name_plural': 'Tenant Apps', + 'unique_together': {('tenant', 'app_package')}, + }, + ), + ] diff --git a/tethys_tenants/tenant_models/migrations/__init__.py b/tethys_tenants/tenant_models/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tethys_tenants/tenant_models/models.py b/tethys_tenants/tenant_models/models.py new file mode 100644 index 000000000..3969c17fa --- /dev/null +++ b/tethys_tenants/tenant_models/models.py @@ -0,0 +1,24 @@ +from django.db import models +from tethys_portal.optional_dependencies import has_module +from tethys_tenants.models import Tenant + + +if has_module("django_tenants"): + + class App(models.Model): + """Model to associate apps with specific tenants""" + + tenant = models.ForeignKey( + Tenant, on_delete=models.CASCADE, related_name="tenant_apps" + ) + app_package = models.CharField(max_length=200) + enabled = models.BooleanField(default=True) + + class Meta: + app_label = "tenant_models" # The Django app this belongs to + verbose_name = "Tenant App" + verbose_name_plural = "Tenant Apps" + unique_together = ("tenant", "app_package") + + def __str__(self): + return f"{self.tenant.schema_name}: {self.app_package}" From 5eee441873cb77453c0319bc074fbeeaafaa465b Mon Sep 17 00:00:00 2001 From: Michael Souffront <17328135+msouff@users.noreply.github.com> Date: Fri, 7 Nov 2025 14:02:39 -0700 Subject: [PATCH 05/35] more simplifications to the core tethys_tenants --- environment.yml | 1 + tethys_portal/settings.py | 73 +++++++++++-------- tethys_tenants/admin.py | 68 +++++++++++++++++ tethys_tenants/apps.py | 15 ++++ tethys_tenants/models.py | 9 +++ tethys_tenants/tenant_models/__init__.py | 0 .../tenant_models/migrations/0001_initial.py | 30 -------- .../tenant_models/migrations/__init__.py | 0 tethys_tenants/tenant_models/models.py | 24 ------ 9 files changed, 137 insertions(+), 83 deletions(-) create mode 100644 tethys_tenants/admin.py create mode 100644 tethys_tenants/apps.py delete mode 100644 tethys_tenants/tenant_models/__init__.py delete mode 100644 tethys_tenants/tenant_models/migrations/0001_initial.py delete mode 100644 tethys_tenants/tenant_models/migrations/__init__.py delete mode 100644 tethys_tenants/tenant_models/models.py diff --git a/environment.yml b/environment.yml index bad938829..9cd7ddc66 100644 --- a/environment.yml +++ b/environment.yml @@ -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 diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index 3f16b7e05..bcab6cef7 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -272,33 +272,9 @@ default_installed_apps, ) -# Django Tenants settings -if has_module("django_tenants"): - DATABASES["default"]["ENGINE"] = "django_tenants.postgresql_backend" - DATABASE_ROUTERS = ("django_tenants.routers.TenantSyncRouter",) - - TENANT_MODEL = "tethys_tenants.Tenant" - TENANT_DOMAIN_MODEL = "tethys_tenants.Domain" - - TENANT_APPS = portal_config_settings.pop( - "TENANT_APPS_OVERRIDE", - [ - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", - "django.contrib.staticfiles", - "tethys_tenants.tenant_models", - ], - ) - - SHARED_APPS = INSTALLED_APPS - TENANT_APPS = tuple(TENANT_APPS + portal_config_settings.pop("TENANT_APPS", [])) - - INSTALLED_APPS = tuple( - list(SHARED_APPS) + [app for app in TENANT_APPS if app not in SHARED_APPS] - ) +INSTALLED_APPS = tuple( + INSTALLED_APPS + portal_config_settings.pop("INSTALLED_APPS", []) +) MIDDLEWARE = portal_config_settings.pop( "MIDDLEWARE_OVERRIDE", @@ -328,8 +304,6 @@ ) # TODO: Templates need to be upgraded if has_module("axes"): MIDDLEWARE.append("axes.middleware.AxesMiddleware") -if has_module("django_tenants"): - MIDDLEWARE.insert(0, "django_tenants.middleware.main.TenantMainMiddleware") MIDDLEWARE = tuple(MIDDLEWARE + portal_config_settings.pop("MIDDLEWARE", [])) @@ -387,6 +361,47 @@ ROOT_URLCONF = "tethys_portal.urls" +# Django Tenants settings +if has_module("django_tenants"): + TENANTS_CONFIG = portal_config_settings.pop("TENANTS", {}) + + DATABASES["default"]["ENGINE"] = "django_tenants.postgresql_backend" + DATABASE_ROUTERS = ("django_tenants.routers.TenantSyncRouter",) + + 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) + + # ROOT_URLCONF = "tethys_tenants.urls" + # PUBLIC_SCHEMA_URLCONF = "tethys_portal.urls" + + SHOW_PUBLIC_IF_NO_TENANT_FOUND = TENANTS_CONFIG.pop( + "SHOW_PUBLIC_IF_NO_TENANT_FOUND", + False, + ) + # Internationalization LANGUAGE_CODE = "en-us" diff --git a/tethys_tenants/admin.py b/tethys_tenants/admin.py new file mode 100644 index 000000000..3bdf57b72 --- /dev/null +++ b/tethys_tenants/admin.py @@ -0,0 +1,68 @@ +""" +******************************************************************************** +* 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) diff --git a/tethys_tenants/apps.py b/tethys_tenants/apps.py new file mode 100644 index 000000000..8b2206061 --- /dev/null +++ b/tethys_tenants/apps.py @@ -0,0 +1,15 @@ +""" +******************************************************************************** +* Name: apps.py +* Author: Michael Souffront +* Created On: 2025 +* License: BSD 2-Clause +******************************************************************************** +""" + +from django.apps import AppConfig + + +class TethysTenantsConfig(AppConfig): + name = "tethys_tenants" + verbose_name = "Tethys Tenants" diff --git a/tethys_tenants/models.py b/tethys_tenants/models.py index e63b2cffb..422562a8f 100644 --- a/tethys_tenants/models.py +++ b/tethys_tenants/models.py @@ -1,3 +1,12 @@ +""" +******************************************************************************** +* Name: admin.py +* Author: Michael Souffront +* Created On: 2025 +* License: BSD 2-Clause +******************************************************************************** +""" + from django.db import models from tethys_portal.optional_dependencies import optional_import, has_module diff --git a/tethys_tenants/tenant_models/__init__.py b/tethys_tenants/tenant_models/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tethys_tenants/tenant_models/migrations/0001_initial.py b/tethys_tenants/tenant_models/migrations/0001_initial.py deleted file mode 100644 index 5ee1c76a5..000000000 --- a/tethys_tenants/tenant_models/migrations/0001_initial.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 5.2.7 on 2025-11-03 22:30 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('tethys_tenants', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='App', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('app_package', models.CharField(max_length=200)), - ('enabled', models.BooleanField(default=True)), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tenant_apps', to='tethys_tenants.tenant')), - ], - options={ - 'verbose_name': 'Tenant App', - 'verbose_name_plural': 'Tenant Apps', - 'unique_together': {('tenant', 'app_package')}, - }, - ), - ] diff --git a/tethys_tenants/tenant_models/migrations/__init__.py b/tethys_tenants/tenant_models/migrations/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tethys_tenants/tenant_models/models.py b/tethys_tenants/tenant_models/models.py deleted file mode 100644 index 3969c17fa..000000000 --- a/tethys_tenants/tenant_models/models.py +++ /dev/null @@ -1,24 +0,0 @@ -from django.db import models -from tethys_portal.optional_dependencies import has_module -from tethys_tenants.models import Tenant - - -if has_module("django_tenants"): - - class App(models.Model): - """Model to associate apps with specific tenants""" - - tenant = models.ForeignKey( - Tenant, on_delete=models.CASCADE, related_name="tenant_apps" - ) - app_package = models.CharField(max_length=200) - enabled = models.BooleanField(default=True) - - class Meta: - app_label = "tenant_models" # The Django app this belongs to - verbose_name = "Tenant App" - verbose_name_plural = "Tenant Apps" - unique_together = ("tenant", "app_package") - - def __str__(self): - return f"{self.tenant.schema_name}: {self.app_package}" From 03f8244d63da0fbb14066537540206facc4d5f6a Mon Sep 17 00:00:00 2001 From: Michael Souffront <17328135+msouff@users.noreply.github.com> Date: Fri, 7 Nov 2025 15:43:08 -0700 Subject: [PATCH 06/35] fixing broken tests --- tests/unit_tests/test_tethys_apps/test_harvester.py | 7 ------- tethys_portal/settings.py | 3 --- 2 files changed, 10 deletions(-) diff --git a/tests/unit_tests/test_tethys_apps/test_harvester.py b/tests/unit_tests/test_tethys_apps/test_harvester.py index edb5ddc76..055810a38 100644 --- a/tests/unit_tests/test_tethys_apps/test_harvester.py +++ b/tests/unit_tests/test_tethys_apps/test_harvester.py @@ -306,7 +306,6 @@ def test_harvest_app_instances_programming_error( shv = SingletonHarvester() shv._harvest_app_instances(list_apps) -<<<<<<< HEAD mock_logwarning.assert_called_with( "Unable to register app permissions. django_content_type table does not exist" ) @@ -342,12 +341,6 @@ def test_harvest_app_instances_sqlite_programming_error( mock_permissions.assert_called() self.assertIn("Tethys Apps Loaded:", mock_stdout.getvalue()) self.assertIn("test_app", mock_stdout.getvalue()) -======= - mock_logwarning.assert_called() - mock_permissions.assert_called() - self.assertIn("Tethys Apps Loaded:", mock_stdout.getvalue()) - self.assertIn("test_app", mock_stdout.getvalue()) ->>>>>>> da1d12a9 (Check for Sqlite ProgramingError Exception (#1106)) @mock.patch("sys.stdout", new_callable=io.StringIO) @mock.patch("tethys_apps.harvester.tethys_log.warning") diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index bcab6cef7..f3ca199bb 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -394,9 +394,6 @@ MIDDLEWARE.insert(0, "django_tenants.middleware.main.TenantMainMiddleware") MIDDLEWARE = tuple(MIDDLEWARE) - # ROOT_URLCONF = "tethys_tenants.urls" - # PUBLIC_SCHEMA_URLCONF = "tethys_portal.urls" - SHOW_PUBLIC_IF_NO_TENANT_FOUND = TENANTS_CONFIG.pop( "SHOW_PUBLIC_IF_NO_TENANT_FOUND", False, From 0fe616ca0c74ac7f0fc0be98a5b0751f7a016bf8 Mon Sep 17 00:00:00 2001 From: Michael Souffront <17328135+msouff@users.noreply.github.com> Date: Fri, 7 Nov 2025 15:44:59 -0700 Subject: [PATCH 07/35] revert test_harvester.py --- tests/unit_tests/test_tethys_apps/test_harvester.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/unit_tests/test_tethys_apps/test_harvester.py b/tests/unit_tests/test_tethys_apps/test_harvester.py index 055810a38..bbbfec21e 100644 --- a/tests/unit_tests/test_tethys_apps/test_harvester.py +++ b/tests/unit_tests/test_tethys_apps/test_harvester.py @@ -297,14 +297,11 @@ def test_harvest_app_instances_programming_error( :return: """ list_apps = {"test_app": "tethysapp.test_app"} - exceptions = [ProgrammingError, SqliteProgrammingError] - for exception in exceptions: - with self.subTest(exception=exception): - mock_permissions.side_effect = exception + mock_permissions.side_effect = ProgrammingError - shv = SingletonHarvester() - shv._harvest_app_instances(list_apps) + shv = SingletonHarvester() + shv._harvest_app_instances(list_apps) mock_logwarning.assert_called_with( "Unable to register app permissions. django_content_type table does not exist" From 8267196a827f511b83226da839d3bf17e3f67b30 Mon Sep 17 00:00:00 2001 From: Michael Souffront <17328135+msouff@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:37:57 -0700 Subject: [PATCH 08/35] tests --- .../test_tethys_portal/test_settings.py | 22 +++++++++++++++++++ tethys_portal/settings.py | 9 ++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/test_tethys_portal/test_settings.py b/tests/unit_tests/test_tethys_portal/test_settings.py index 21bd2ae68..e7e835d0d 100644 --- a/tests/unit_tests/test_tethys_portal/test_settings.py +++ b/tests/unit_tests/test_tethys_portal/test_settings.py @@ -217,6 +217,28 @@ def test_db_config_postgres(self, _): "PORT": 5436, }, ) + + @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( diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index f3ca199bb..1fa54bb2b 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -362,10 +362,15 @@ ROOT_URLCONF = "tethys_portal.urls" # Django Tenants settings -if has_module("django_tenants"): +if has_module("django_tenants") and "TENANTS" in portal_config_settings: TENANTS_CONFIG = portal_config_settings.pop("TENANTS", {}) - DATABASES["default"]["ENGINE"] = "django_tenants.postgresql_backend" + # 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_MODEL = "tethys_tenants.Tenant" From b05d84a13361d4e3e7f7b6bf02a61af9003c9b81 Mon Sep 17 00:00:00 2001 From: Michael Souffront <17328135+msouff@users.noreply.github.com> Date: Mon, 10 Nov 2025 19:13:03 -0700 Subject: [PATCH 09/35] context_processor.request is already in there --- tethys_portal/settings.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index 1fa54bb2b..448f541d5 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -370,9 +370,14 @@ DATABASES["default"]["ENGINE"] = TENANTS_CONFIG.pop( "DATABASE_ENGINE", "django.db.backends.sqlite3" ) + + if TENANTS_CONFIG.pop("POSTGIS_ENABLED", False): + ORIGINAL_BACKEND = "django.contrib.gis.db.backends.postgis" 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" @@ -448,12 +453,6 @@ CONTEXT_PROCESSORS + portal_config_settings.pop("CONTEXT_PROCESSORS", []) ) -if has_module("django_tenants"): - CONTEXT_PROCESSORS = ( - "django.template.context_processors.request", - *CONTEXT_PROCESSORS, - ) - # Templates ADDITIONAL_TEMPLATE_DIRS = [ From 3944006875e213af23d8c30b217ce3e26bc2cb20 Mon Sep 17 00:00:00 2001 From: Michael Souffront <17328135+msouff@users.noreply.github.com> Date: Mon, 10 Nov 2025 20:49:48 -0700 Subject: [PATCH 10/35] removed tenants postgis backend. Not needed --- tethys_portal/settings.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index 448f541d5..a12696bdd 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -370,9 +370,6 @@ DATABASES["default"]["ENGINE"] = TENANTS_CONFIG.pop( "DATABASE_ENGINE", "django.db.backends.sqlite3" ) - - if TENANTS_CONFIG.pop("POSTGIS_ENABLED", False): - ORIGINAL_BACKEND = "django.contrib.gis.db.backends.postgis" DATABASE_ROUTERS = ("django_tenants.routers.TenantSyncRouter",) From 2b9745920e1d7679cc7efdeec39914a6104a7cd9 Mon Sep 17 00:00:00 2001 From: Michael Souffront <17328135+msouff@users.noreply.github.com> Date: Tue, 11 Nov 2025 17:27:17 -0700 Subject: [PATCH 11/35] tests --- .../test_tethys_tenants/__init__.py | 0 .../test_tethys_tenants/test_admin.py | 113 ++++++++++++++++++ .../test_tethys_tenants/test_apps.py | 24 ++++ .../test_tethys_tenants/test_models.py | 23 ++++ tethys_tenants/apps.py | 8 +- tethys_tenants/models.py | 3 +- 6 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 tests/unit_tests/test_tethys_tenants/__init__.py create mode 100644 tests/unit_tests/test_tethys_tenants/test_admin.py create mode 100644 tests/unit_tests/test_tethys_tenants/test_apps.py create mode 100644 tests/unit_tests/test_tethys_tenants/test_models.py diff --git a/tests/unit_tests/test_tethys_tenants/__init__.py b/tests/unit_tests/test_tethys_tenants/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit_tests/test_tethys_tenants/test_admin.py b/tests/unit_tests/test_tethys_tenants/test_admin.py new file mode 100644 index 000000000..86e6a1367 --- /dev/null +++ b/tests/unit_tests/test_tethys_tenants/test_admin.py @@ -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() diff --git a/tests/unit_tests/test_tethys_tenants/test_apps.py b/tests/unit_tests/test_tethys_tenants/test_apps.py new file mode 100644 index 000000000..42daf1cb6 --- /dev/null +++ b/tests/unit_tests/test_tethys_tenants/test_apps.py @@ -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() diff --git a/tests/unit_tests/test_tethys_tenants/test_models.py b/tests/unit_tests/test_tethys_tenants/test_models.py new file mode 100644 index 000000000..fd5034570 --- /dev/null +++ b/tests/unit_tests/test_tethys_tenants/test_models.py @@ -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() diff --git a/tethys_tenants/apps.py b/tethys_tenants/apps.py index 8b2206061..71ea86d9b 100644 --- a/tethys_tenants/apps.py +++ b/tethys_tenants/apps.py @@ -8,8 +8,10 @@ """ from django.apps import AppConfig +from tethys_portal.optional_dependencies import has_module -class TethysTenantsConfig(AppConfig): - name = "tethys_tenants" - verbose_name = "Tethys Tenants" +if has_module("django_tenants"): + class TethysTenantsConfig(AppConfig): + name = "tethys_tenants" + verbose_name = "Tethys Tenants" diff --git a/tethys_tenants/models.py b/tethys_tenants/models.py index 422562a8f..71a2a0ae8 100644 --- a/tethys_tenants/models.py +++ b/tethys_tenants/models.py @@ -13,8 +13,8 @@ TenantMixin = optional_import("TenantMixin", from_module="django_tenants.models") DomainMixin = optional_import("DomainMixin", from_module="django_tenants.models") -if has_module(TenantMixin): +if has_module(TenantMixin): class Tenant(TenantMixin): name = models.CharField(max_length=100) created_on = models.DateField(auto_now_add=True) @@ -24,6 +24,5 @@ class Tenant(TenantMixin): if has_module(DomainMixin): - class Domain(DomainMixin): pass From baee361f3318eb6eea11655eac83fa9d050678b7 Mon Sep 17 00:00:00 2001 From: Michael Souffront <17328135+msouff@users.noreply.github.com> Date: Wed, 12 Nov 2025 11:33:15 -0700 Subject: [PATCH 12/35] linting --- .../test_tethys_portal/test_settings.py | 6 +- tethys_tenants/admin.py | 16 ++--- tethys_tenants/apps.py | 1 + tethys_tenants/migrations/0001_initial.py | 63 ++++++++++++++----- tethys_tenants/models.py | 2 + 5 files changed, 65 insertions(+), 23 deletions(-) diff --git a/tests/unit_tests/test_tethys_portal/test_settings.py b/tests/unit_tests/test_tethys_portal/test_settings.py index e7e835d0d..c678981ba 100644 --- a/tests/unit_tests/test_tethys_portal/test_settings.py +++ b/tests/unit_tests/test_tethys_portal/test_settings.py @@ -217,12 +217,14 @@ def test_db_config_postgres(self, _): "PORT": 5436, }, ) - + @mock.patch( "tethys_portal.settings.yaml.safe_load", return_value={ "settings": { - "DATABASES": {"default": {"ENGINE": "django_tenants.postgresql_backend"}} + "DATABASES": { + "default": {"ENGINE": "django_tenants.postgresql_backend"} + } } }, ) diff --git a/tethys_tenants/admin.py b/tethys_tenants/admin.py index 3bdf57b72..7816d2fdc 100644 --- a/tethys_tenants/admin.py +++ b/tethys_tenants/admin.py @@ -15,17 +15,20 @@ 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' - + 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) @@ -40,14 +43,13 @@ 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): + 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): + 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",) @@ -60,9 +62,9 @@ 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): + 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): + def add_view(self, request, form_url="", extra_context=None): return super().add_view(request, form_url, extra_context) diff --git a/tethys_tenants/apps.py b/tethys_tenants/apps.py index 71ea86d9b..b4dc0695f 100644 --- a/tethys_tenants/apps.py +++ b/tethys_tenants/apps.py @@ -12,6 +12,7 @@ if has_module("django_tenants"): + class TethysTenantsConfig(AppConfig): name = "tethys_tenants" verbose_name = "Tethys Tenants" diff --git a/tethys_tenants/migrations/0001_initial.py b/tethys_tenants/migrations/0001_initial.py index 752a31107..6603517f9 100644 --- a/tethys_tenants/migrations/0001_initial.py +++ b/tethys_tenants/migrations/0001_initial.py @@ -9,32 +9,67 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='Tenant', + name="Tenant", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('schema_name', models.CharField(db_index=True, max_length=63, unique=True, validators=[django_tenants.postgresql_backend.base._check_schema_name])), - ('name', models.CharField(max_length=100)), - ('created_on', models.DateField(auto_now_add=True)), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "schema_name", + models.CharField( + db_index=True, + max_length=63, + unique=True, + validators=[ + django_tenants.postgresql_backend.base._check_schema_name + ], + ), + ), + ("name", models.CharField(max_length=100)), + ("created_on", models.DateField(auto_now_add=True)), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='Domain', + name="Domain", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('domain', models.CharField(db_index=True, max_length=253, unique=True)), - ('is_primary', models.BooleanField(db_index=True, default=True)), - ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='domains', to='tethys_tenants.tenant')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "domain", + models.CharField(db_index=True, max_length=253, unique=True), + ), + ("is_primary", models.BooleanField(db_index=True, default=True)), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="domains", + to="tethys_tenants.tenant", + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), ] diff --git a/tethys_tenants/models.py b/tethys_tenants/models.py index 71a2a0ae8..78f253fc9 100644 --- a/tethys_tenants/models.py +++ b/tethys_tenants/models.py @@ -15,6 +15,7 @@ if has_module(TenantMixin): + class Tenant(TenantMixin): name = models.CharField(max_length=100) created_on = models.DateField(auto_now_add=True) @@ -24,5 +25,6 @@ class Tenant(TenantMixin): if has_module(DomainMixin): + class Domain(DomainMixin): pass From ccc1f676a7c601473f8a63e93ca99fabf3067a68 Mon Sep 17 00:00:00 2001 From: Michael Souffront <17328135+msouff@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:26:36 -0700 Subject: [PATCH 13/35] apply tenant migration only if has_module django tenants; more test updates --- .../test_base/test_handoff.py | 2 + .../test_tethys_portal/test_settings.py | 6 +- .../test_tethys_portal/test_views/test_api.py | 10 ++ tethys_platform | 0 tethys_portal/settings.py | 7 +- tethys_tenants/admin.py | 12 +- tethys_tenants/migrations/0001_initial.py | 125 +++++++++--------- tethys_tenants/models.py | 10 +- 8 files changed, 98 insertions(+), 74 deletions(-) create mode 100644 tethys_platform diff --git a/tests/unit_tests/test_tethys_apps/test_base/test_handoff.py b/tests/unit_tests/test_tethys_apps/test_base/test_handoff.py index 1a246b1e1..fbbd874d0 100644 --- a/tests/unit_tests/test_tethys_apps/test_base/test_handoff.py +++ b/tests/unit_tests/test_tethys_apps/test_base/test_handoff.py @@ -303,6 +303,7 @@ 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=""') @@ -310,6 +311,7 @@ def test_test_app_handoff(self): 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=""') diff --git a/tests/unit_tests/test_tethys_portal/test_settings.py b/tests/unit_tests/test_tethys_portal/test_settings.py index c678981ba..bb1b3f6a4 100644 --- a/tests/unit_tests/test_tethys_portal/test_settings.py +++ b/tests/unit_tests/test_tethys_portal/test_settings.py @@ -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 @@ -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={ @@ -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"], diff --git a/tests/unit_tests/test_tethys_portal/test_views/test_api.py b/tests/unit_tests/test_tethys_portal/test_views/test_api.py index 15ec6b706..5750c5ed4 100644 --- a/tests/unit_tests/test_tethys_portal/test_views/test_api.py +++ b/tests/unit_tests/test_tethys_portal/test_views/test_api.py @@ -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) @@ -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) @@ -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) @@ -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() @@ -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() @@ -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() @@ -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"})) diff --git a/tethys_platform b/tethys_platform new file mode 100644 index 000000000..e69de29bb diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index a12696bdd..1eac6f5e2 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -246,7 +246,6 @@ ] for module in [ - "django_tenants", "analytical", "axes", "captcha", @@ -362,13 +361,13 @@ ROOT_URLCONF = "tethys_portal.urls" # Django Tenants settings -if has_module("django_tenants") and "TENANTS" in portal_config_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.db.backends.sqlite3" + "DATABASE_ENGINE", "django_tenants.postgresql_backend" ) DATABASE_ROUTERS = ("django_tenants.routers.TenantSyncRouter",) @@ -390,7 +389,7 @@ ], ) - SHARED_APPS = INSTALLED_APPS + SHARED_APPS = ("django_tenants",) + INSTALLED_APPS TENANT_APPS = tuple(TENANT_APPS + TENANTS_CONFIG.pop("TENANT_APPS", [])) INSTALLED_APPS = tuple( diff --git a/tethys_tenants/admin.py b/tethys_tenants/admin.py index 7816d2fdc..71e2a8390 100644 --- a/tethys_tenants/admin.py +++ b/tethys_tenants/admin.py @@ -11,14 +11,22 @@ 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"): + from tethys_tenants.models import Tenant, Domain + def is_public_schema(request): """Check if current request is from public schema""" - return getattr(request.tenant, "schema_name", None) == "public" + is_public_schema = False + if ( + not hasattr(request, "tenant") + or request.tenant is None + or request.tenant.schema_name == "public" + ): + is_public_schema = True + return is_public_schema def public_schema_only(view_func): """Decorator to ensure view is only accessible from public schema""" diff --git a/tethys_tenants/migrations/0001_initial.py b/tethys_tenants/migrations/0001_initial.py index 6603517f9..fc98cb801 100644 --- a/tethys_tenants/migrations/0001_initial.py +++ b/tethys_tenants/migrations/0001_initial.py @@ -1,9 +1,11 @@ # Generated by Django 5.2.7 on 2025-11-03 22:30 +# Applied only if django_tenants is installed import django.db.models.deletion -import django_tenants.postgresql_backend.base from django.db import migrations, models +from tethys_portal.optional_dependencies import has_module + class Migration(migrations.Migration): @@ -11,65 +13,70 @@ class Migration(migrations.Migration): dependencies = [] - operations = [ - migrations.CreateModel( - name="Tenant", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", + if has_module("django_tenants"): + import django_tenants.postgresql_backend.base + + operations = [ + migrations.CreateModel( + name="Tenant", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "schema_name", + models.CharField( + db_index=True, + max_length=63, + unique=True, + validators=[ + django_tenants.postgresql_backend.base._check_schema_name + ], + ), ), - ), - ( - "schema_name", - models.CharField( - db_index=True, - max_length=63, - unique=True, - validators=[ - django_tenants.postgresql_backend.base._check_schema_name - ], + ("name", models.CharField(max_length=100)), + ("created_on", models.DateField(auto_now_add=True)), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="Domain", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), ), - ), - ("name", models.CharField(max_length=100)), - ("created_on", models.DateField(auto_now_add=True)), - ], - options={ - "abstract": False, - }, - ), - migrations.CreateModel( - name="Domain", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", + ( + "domain", + models.CharField(db_index=True, max_length=253, unique=True), ), - ), - ( - "domain", - models.CharField(db_index=True, max_length=253, unique=True), - ), - ("is_primary", models.BooleanField(db_index=True, default=True)), - ( - "tenant", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="domains", - to="tethys_tenants.tenant", + ("is_primary", models.BooleanField(db_index=True, default=True)), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="domains", + to="tethys_tenants.tenant", + ), ), - ), - ], - options={ - "abstract": False, - }, - ), - ] + ], + options={ + "abstract": False, + }, + ), + ] + else: + operations = [] diff --git a/tethys_tenants/models.py b/tethys_tenants/models.py index 78f253fc9..9458cc3bb 100644 --- a/tethys_tenants/models.py +++ b/tethys_tenants/models.py @@ -8,13 +8,12 @@ """ from django.db import models -from tethys_portal.optional_dependencies import optional_import, has_module +from tethys_portal.optional_dependencies import has_module -TenantMixin = optional_import("TenantMixin", from_module="django_tenants.models") -DomainMixin = optional_import("DomainMixin", from_module="django_tenants.models") +if has_module("django_tenants"): -if has_module(TenantMixin): + from django_tenants.models import TenantMixin, DomainMixin class Tenant(TenantMixin): name = models.CharField(max_length=100) @@ -23,8 +22,5 @@ class Tenant(TenantMixin): # Schema will be automatically created and synced on save when True auto_create_schema = True - -if has_module(DomainMixin): - class Domain(DomainMixin): pass From 0d622326af544f1c40a50a248795520d1f4310b9 Mon Sep 17 00:00:00 2001 From: Michael Souffront <17328135+msouff@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:36:21 -0700 Subject: [PATCH 14/35] moved tethys_tenants app to django tenants settings --- tethys_portal/settings.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index 1eac6f5e2..775e95e97 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -240,7 +240,6 @@ "tethys_layouts", "tethys_sdk", "tethys_services", - "tethys_tenants", "tethys_quotas", "guardian", ] @@ -389,7 +388,7 @@ ], ) - SHARED_APPS = ("django_tenants",) + INSTALLED_APPS + SHARED_APPS = ("django_tenants", "tethys_tenants") + INSTALLED_APPS TENANT_APPS = tuple(TENANT_APPS + TENANTS_CONFIG.pop("TENANT_APPS", [])) INSTALLED_APPS = tuple( From a4794377ed5bcaaed4d7d4b1fea4f4d120f48df9 Mon Sep 17 00:00:00 2001 From: Michael Souffront <17328135+msouff@users.noreply.github.com> Date: Fri, 14 Nov 2025 16:42:41 -0700 Subject: [PATCH 15/35] working on documentation --- .../production/manual/configuration.rst | 1 + .../advanced/images/tethys_tenants_admin.png | 3 + .../configuration/advanced/multi_tenancy.rst | 130 ++++++++++++++++++ tethys_portal/settings.py | 17 ++- 4 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 docs/installation/production/manual/configuration/advanced/images/tethys_tenants_admin.png create mode 100644 docs/installation/production/manual/configuration/advanced/multi_tenancy.rst diff --git a/docs/installation/production/manual/configuration.rst b/docs/installation/production/manual/configuration.rst index 998ad1b7d..c1881ad9b 100644 --- a/docs/installation/production/manual/configuration.rst +++ b/docs/installation/production/manual/configuration.rst @@ -40,5 +40,6 @@ These guides describe additional configuration that you can perform to add more configuration/advanced/self_hosted_js_deps configuration/advanced/social_auth configuration/advanced/multi_factor_auth + configuration/advanced/multi_tenancy configuration/advanced/webanalytics configuration/advanced/django_channels_layer diff --git a/docs/installation/production/manual/configuration/advanced/images/tethys_tenants_admin.png b/docs/installation/production/manual/configuration/advanced/images/tethys_tenants_admin.png new file mode 100644 index 000000000..93d4e774a --- /dev/null +++ b/docs/installation/production/manual/configuration/advanced/images/tethys_tenants_admin.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5b35db7ca224ca2d81c6201ac514ffc7a00dce6d4cf3a2852843c51c4f17ea62 +size 4244 diff --git a/docs/installation/production/manual/configuration/advanced/multi_tenancy.rst b/docs/installation/production/manual/configuration/advanced/multi_tenancy.rst new file mode 100644 index 000000000..4945971fe --- /dev/null +++ b/docs/installation/production/manual/configuration/advanced/multi_tenancy.rst @@ -0,0 +1,130 @@ +.. _multi_tenancy_config: + +************************ +Multi Tenancy (Optional) +************************ + +**Last Updated:** December 2025 + +.. important:: + + This capability requires the ``django-tenants`` third-party library to be installed. Starting with Tethys 5.0 or if you are using ``micro-tethys-platform``, you will need to install ``django-tenants`` using conda or pip as follows: + + .. code-block:: bash + + # conda: conda-forge channel strongly recommended + conda install -c conda-forge django-tenants + + # pip + pip install django-tenants + +.. warning:: + + Multi-tenancy requires PostgreSQL as the database backend. SQLite is not supported. + +Tethys Portal supports only one tenant per portal by default. Multi-tenancy allows you to run multiple tenants (isolated instances of Tethys Portal) within a single deployment. This is useful for organizations or providers that want to provide separate environments for different groups or customers. In addition, multi-tenancy enables the ability to separate and customize the look and resources of the Tethys Portal based on each tenant. This functionality extends to the app level. + + +Enable Multi-Tenancy +==================== + +Use the following instructions to setup multi-tenancy for your Tethys Portal deployment. See the `Django-tenants Documentation `_ for more information. + +Configuration +------------- + +Enable multi-tenancy by adding a ``TENANTS`` section to your :file:`portal_config.yml` file and override the database engine to use the ``django-tenants`` backend: + +.. code-block:: yaml + + settings: + TENANTS: + DATABASE_ENGINE: django_tenants.postgresql_backend + +You can customize the multi-tenancy behavior with additional settings. : + +.. code-block:: yaml + + settings: + TENANTS: + DATABASE_ENGINE: django_tenants.postgresql_backend + TENANT_APPS: + - "tethys_apps" + - "tethys_config" + TENANT_LIMIT_SET_CALLS: false + TENANT_COLOR_ADMIN_APPS: true + SHOW_PUBLIC_IF_NO_TENANT_FOUND: false + +Configuration Options +===================== + +**DATABASE_ENGINE** + Required. Must be set to ``django_tenants.postgresql_backend`` to enable schema-based multi-tenancy. The default behavior when django-tenants and the TENANTS setting is in :file:`portal_config.yml` is to override the database engine; however, we recommend explicitly setting it in :file:`portal_config.yml` to avoid confusion. + +**TENANT_APPS** + List of Django apps that should be isolated per tenant. These apps will have their database tables created in each tenant's schema. + +**TENANT_LIMIT_SET_CALLS** + Boolean (default: ``false``). When ``true``, limits database SET calls for performance optimization. + +**TENANT_COLOR_ADMIN_APPS** + Boolean (default: ``true``). When ``true``, colors tenant-enabled sections dark green in the site admin. + +**SHOW_PUBLIC_IF_NO_TENANT_FOUND** + Boolean (default: ``false``). When ``true``, shows the public schema when no tenant is found instead of returning a 404 error. + +Working with Tenants +==================== + +**Run migrations**: + +If the Tethys database is been create for the first time, new tenant tables are created as part by detecting and applying migrations in the following way. + + .. code-block:: bash + + tethys manage makemigrations + tethys manage migrate_schemas + +If existing django-apps become tenant aware (are moved to the `TENANT_APPS` list), django will not recognize that new migrations need to be applied. Add the `--fake-initial` flag to the `migrate_schemas` so the these tables are create on the tenant schemas. + + .. code-block:: bash + + tethys manage makemigrations + tethys manage migrate_schemas --fake-initial + +After updating your :file:`portal_config.yml` file: + +**Create tenant**: + + .. code-block:: bash + + tethys manage create_tenant + +See the `Django-tenants Documentation `_ for more details on using the `create_tenant` command or run ``tethys manage create_tenant --django-help`` on the active tethys environment terminal. + +Even though it already exists, the default public schema must be added to the Tenants table using the `create_tenant` command. See the example below: + + .. code-block:: bash + + tethys manage create_tenant --schema_name public --name Public --domain-domain localhost + +**Create tenant superuser**: + +Each tenant requires its own portal admin account. Create a superuser for a specific tenant by running the following command: + + .. code-block:: bash + + tethys manage create_tenant_superuser + +New tenants and tenant domains can be added via the Tethys Portal Admin interface once multi-tenancy is enabled. The Tethys Tenants admin block is only visible to superusers of the public schema. + +.. figure:: ./images/tethys_tenants_admin.png + :width: 800px + +Management +---------- + +Django-tenants includes two very useful commands to help manage database schemas. + +- `tenant_command `_: Runs any django manage command on an individual schema +- `all_tenants_command `_: Runs any django manage command on all schemas \ No newline at end of file diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index 775e95e97..918b53c2c 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -360,7 +360,7 @@ ROOT_URLCONF = "tethys_portal.urls" # Django Tenants settings -if has_module("django_tenants"): +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 @@ -373,6 +373,16 @@ TENANT_LIMIT_SET_CALLS = TENANTS_CONFIG.pop("TENANT_LIMIT_SET_CALLS", False) + TENANT_COLOR_ADMIN_APPS = TENANTS_CONFIG.pop( + "TENANT_COLOR_ADMIN_APPS", + True, + ) + + SHOW_PUBLIC_IF_NO_TENANT_FOUND = TENANTS_CONFIG.pop( + "SHOW_PUBLIC_IF_NO_TENANT_FOUND", + False, + ) + TENANT_MODEL = "tethys_tenants.Tenant" TENANT_DOMAIN_MODEL = "tethys_tenants.Domain" @@ -399,11 +409,6 @@ 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" From fb02b92280bc0dd50edcb9d28ced7eadc37dfb1d Mon Sep 17 00:00:00 2001 From: Michael Souffront <17328135+msouff@users.noreply.github.com> Date: Tue, 18 Nov 2025 16:28:46 -0700 Subject: [PATCH 16/35] documentation --- docs/installation/production.rst | 18 +++++++++++++++++- .../production/manual/configuration.rst | 6 ++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/installation/production.rst b/docs/installation/production.rst index 13ca21150..f0b63dec5 100644 --- a/docs/installation/production.rst +++ b/docs/installation/production.rst @@ -4,7 +4,7 @@ Production Installation Guide ***************************** -**Last Updated:** September 2024 +**Last Updated:** December 2025 A **production installation**, sometimes called **deployment**, is an installation of Tethys Platform that is configured to for being hosted on a live server. This guide provides an explanation of the difference between Production and Development installations and provides several methods for installing Tethys Platform in production. @@ -51,6 +51,22 @@ This method involves using Docker to package and automate the deployment of a Te production/docker +Advanced and Optional Capabilities +================================== + +The following sections provide information on configuring advanced and optional capabilities for your production Tethys Platform installation. + +* :doc:`Configure HTTPS `: For setting up a secured connection for your portal. +* :doc:`Customize Portal Theme `: Customize the Tethys Portal to reflect your organization's branding. +* :doc:`Forgotten Password Recovery `: Set up email capabilities to allow users to recover forgotten passwords. +* :doc:`Lockout `: Prevent brute-force login attempts +* :doc:`Self Hosted Dependency Mode `: Configure Tethys Portal to host JavaScript dependencies locally. +* :doc:`Single Sign On `: Set up social authentication and single sign-on with providers including Google, Facebook, or LinkedIn. +* :doc:`Multi Factor Authentication `: Enable and enforce multi-factor authentication through apps such as LastPass Authenticator or Google Authenticator. +* :doc:`Multi Tenancy `: Enable multiple tenants with a single portal deployment, and customize resources based on tenant. +* :doc:`Web Analytics `: Track how users interact with the Tethys portal and its applications using web-based analytical services. +* :doc:`Django Channels Layer `: For production installations that use the WebSockets and/or Bokeh Server functionality that comes with Tethys Platform. + References ========== diff --git a/docs/installation/production/manual/configuration.rst b/docs/installation/production/manual/configuration.rst index c1881ad9b..64a2c3675 100644 --- a/docs/installation/production/manual/configuration.rst +++ b/docs/installation/production/manual/configuration.rst @@ -35,6 +35,12 @@ These guides describe additional configuration that you can perform to add more configuration/advanced/https_config configuration/advanced/customize + +**Optional Configuration** + +.. toctree:: + :maxdepth: 1 + configuration/advanced/email_config configuration/advanced/lockout configuration/advanced/self_hosted_js_deps From a3e1b9ab3ff6614e264a1ef660e43b85ac14b531 Mon Sep 17 00:00:00 2001 From: Michael Souffront <17328135+msouff@users.noreply.github.com> Date: Wed, 19 Nov 2025 16:21:15 -0700 Subject: [PATCH 17/35] fixed tests; more documentation --- .../configuration/advanced/multi_tenancy.rst | 17 ++++++--- docs/supplementary/optional_features.rst | 8 +++++ tethys_portal/settings.py | 36 ++++++++++++------- 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/docs/installation/production/manual/configuration/advanced/multi_tenancy.rst b/docs/installation/production/manual/configuration/advanced/multi_tenancy.rst index 4945971fe..b90ff4ece 100644 --- a/docs/installation/production/manual/configuration/advanced/multi_tenancy.rst +++ b/docs/installation/production/manual/configuration/advanced/multi_tenancy.rst @@ -78,19 +78,19 @@ Working with Tenants **Run migrations**: -If the Tethys database is been create for the first time, new tenant tables are created as part by detecting and applying migrations in the following way. +If the Tethys database is being created for the first time, new tenant tables are created as part of it by detecting and applying migrations in the following way. .. code-block:: bash tethys manage makemigrations tethys manage migrate_schemas -If existing django-apps become tenant aware (are moved to the `TENANT_APPS` list), django will not recognize that new migrations need to be applied. Add the `--fake-initial` flag to the `migrate_schemas` so the these tables are create on the tenant schemas. +If existing django-apps become tenant aware (are moved to the `TENANT_APPS` list) later on, django will not recognize that new migrations need to be applied by default. Use the ``migrate`` command to first unapply the migrations at the tenant level and then reapply them properly using the ``zero`` parameter and the ``--tenant``` flag in the following way. .. code-block:: bash - tethys manage makemigrations - tethys manage migrate_schemas --fake-initial + tethys manage migrate zero --fake --tenant + tethys manage migrate --tenant After updating your :file:`portal_config.yml` file: @@ -127,4 +127,11 @@ Management Django-tenants includes two very useful commands to help manage database schemas. - `tenant_command `_: Runs any django manage command on an individual schema -- `all_tenants_command `_: Runs any django manage command on all schemas \ No newline at end of file +- `all_tenants_command `_: Runs any django manage command on all schemas + +For example, to show the applied migrations for a specific tenant schema or for all tenant schemas, use the following commands: + + .. code-block:: bash + + tethys manage tenant_command showmigrations --schema + tethys manage all_tenants_command showmigrations diff --git a/docs/supplementary/optional_features.rst b/docs/supplementary/optional_features.rst index bd251c40b..41677beff 100644 --- a/docs/supplementary/optional_features.rst +++ b/docs/supplementary/optional_features.rst @@ -72,6 +72,14 @@ Allows users to enable multi-factor authentication for their Tethys Portal accou - ``arrow`` - ``isodate`` +Multi Tenancy +------------- + +Enable multiple tenants with a single portal deployment and customize resources based on tenant. + +**dependencies** + - ``django-tenants`` + Single Sign On with Social Accounts ----------------------------------- diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index 918b53c2c..90091ea77 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -264,7 +264,6 @@ if has_module(module): default_installed_apps.append(module) - INSTALLED_APPS = portal_config_settings.pop( "INSTALLED_APPS_OVERRIDE", default_installed_apps, @@ -360,7 +359,7 @@ ROOT_URLCONF = "tethys_portal.urls" # Django Tenants settings -if has_module("django_tenants") and "TENANTS" in portal_config_settings: +if has_module("django_tenants"): TENANTS_CONFIG = portal_config_settings.pop("TENANTS", {}) # Tethys Tenants requires "django_tenants.postgresql_backend" as the database engine @@ -386,17 +385,28 @@ 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", - ], - ) + default_tenant_apps = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + ] + + for module in [ + "axes", + "captcha", + "mfa", + "oauth2_provider", + "rest_framework.authtoken", + "social_django", + "termsandconditions", + ]: + if has_module(module): + default_tenant_apps.append(module) + + TENANT_APPS = TENANTS_CONFIG.pop("TENANT_APPS_OVERRIDE", default_tenant_apps) SHARED_APPS = ("django_tenants", "tethys_tenants") + INSTALLED_APPS TENANT_APPS = tuple(TENANT_APPS + TENANTS_CONFIG.pop("TENANT_APPS", [])) From 54e87d6d65f93002fbc1814a5cbdc33ada9a0555 Mon Sep 17 00:00:00 2001 From: jakeymac Date: Thu, 18 Dec 2025 10:08:39 -0700 Subject: [PATCH 18/35] Updated migration to only be detected if django_tenants is installed --- tethys_tenants/migrations/0001_initial.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/tethys_tenants/migrations/0001_initial.py b/tethys_tenants/migrations/0001_initial.py index fc98cb801..a430240a7 100644 --- a/tethys_tenants/migrations/0001_initial.py +++ b/tethys_tenants/migrations/0001_initial.py @@ -6,16 +6,13 @@ from tethys_portal.optional_dependencies import has_module +if has_module("django_tenants"): + import django_tenants.postgresql_backend.base + class Migration(migrations.Migration): -class Migration(migrations.Migration): - - initial = True - - dependencies = [] - - if has_module("django_tenants"): - import django_tenants.postgresql_backend.base + initial = True + dependencies = [] operations = [ migrations.CreateModel( name="Tenant", @@ -78,5 +75,3 @@ class Migration(migrations.Migration): }, ), ] - else: - operations = [] From 7ab9d65e335f71af7a4e7ef090c1ab39329c8771 Mon Sep 17 00:00:00 2001 From: jakeymac Date: Thu, 18 Dec 2025 10:09:15 -0700 Subject: [PATCH 19/35] Updated whats new page in docs --- docs/whats_new.rst | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/whats_new.rst b/docs/whats_new.rst index d33844d02..66038f703 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -47,7 +47,13 @@ Async Support for the Jobs Table Gizmo See: :ref:`jobs-table` -For a full list of changes in version 4.3 refer to ``_ +Multi-Tenancy Support +..................... + +* Tethys Platform now supports multi-tenancy using the third-party `django-tenants` library. This allows you to run multiple isolated configurations of Tethys Portal within a single portal. +See: :ref:`Multi Tenancy ` + +For a full list of changes in version 4.4 refer to ``_ Prior Release Notes =================== From 36bc74da6e1969f13c58a5e00117ecba1603a79c Mon Sep 17 00:00:00 2001 From: jakeymac Date: Thu, 18 Dec 2025 10:45:39 -0700 Subject: [PATCH 20/35] Added warning in settings to make sure users are using django_tenants.postgresql_backend as their database engine if using django_tenants --- tethys_portal/settings.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index 90091ea77..9e1644a9f 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -36,6 +36,7 @@ from tethys_apps.utilities import relative_to_tethys_home from tethys_utils import deprecation_warning from tethys_cli.gen_commands import generate_secret_key +from tethys_cli.cli_colors import write_warning from tethys_portal.optional_dependencies import optional_import, has_module # optional imports @@ -360,13 +361,17 @@ # Django Tenants settings if has_module("django_tenants"): - TENANTS_CONFIG = portal_config_settings.pop("TENANTS", {}) + TENANTS_CONFIG = portal_config_settings.pop("TENANTS_CONFIG", {}) # 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" - ) + if DATABASES["default"]["ENGINE"] != "django_tenants.postgresql_backend": + print("") + write_warning("The database engine for the default database must be set to " + "'django_tenants.postgresql_backend' to use multi-tenancy features. " + "Please update your portal_config.yml file accordingly." \ + "You can use the following command to do so:\n\n" \ + "tethys settings --set DATABASES.default.ENGINE django_tenants.postgresql_backend\n") DATABASE_ROUTERS = ("django_tenants.routers.TenantSyncRouter",) From 0275ad388b167c9ab1e67b9b3f88a71ad671ae2a Mon Sep 17 00:00:00 2001 From: jakeymac Date: Thu, 18 Dec 2025 10:46:56 -0700 Subject: [PATCH 21/35] Updated order of sections in production doc page --- docs/installation/production.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/installation/production.rst b/docs/installation/production.rst index f0b63dec5..0653f04a8 100644 --- a/docs/installation/production.rst +++ b/docs/installation/production.rst @@ -58,14 +58,14 @@ The following sections provide information on configuring advanced and optional * :doc:`Configure HTTPS `: For setting up a secured connection for your portal. * :doc:`Customize Portal Theme `: Customize the Tethys Portal to reflect your organization's branding. +* :doc:`Django Channels Layer `: For production installations that use the WebSockets and/or Bokeh Server functionality that comes with Tethys Platform. * :doc:`Forgotten Password Recovery `: Set up email capabilities to allow users to recover forgotten passwords. * :doc:`Lockout `: Prevent brute-force login attempts -* :doc:`Self Hosted Dependency Mode `: Configure Tethys Portal to host JavaScript dependencies locally. -* :doc:`Single Sign On `: Set up social authentication and single sign-on with providers including Google, Facebook, or LinkedIn. * :doc:`Multi Factor Authentication `: Enable and enforce multi-factor authentication through apps such as LastPass Authenticator or Google Authenticator. * :doc:`Multi Tenancy `: Enable multiple tenants with a single portal deployment, and customize resources based on tenant. +* :doc:`Self Hosted Dependency Mode `: Configure Tethys Portal to host JavaScript dependencies locally. +* :doc:`Single Sign On `: Set up social authentication and single sign-on with providers including Google, Facebook, or LinkedIn. * :doc:`Web Analytics `: Track how users interact with the Tethys portal and its applications using web-based analytical services. -* :doc:`Django Channels Layer `: For production installations that use the WebSockets and/or Bokeh Server functionality that comes with Tethys Platform. References ========== From 0d372f6dd447d1c36b822a18fd55f3d857512986 Mon Sep 17 00:00:00 2001 From: jakeymac Date: Thu, 18 Dec 2025 10:57:22 -0700 Subject: [PATCH 22/35] Updates to documentation --- docs/installation/production/manual/configuration.rst | 2 ++ .../manual/configuration/advanced/multi_tenancy.rst | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/installation/production/manual/configuration.rst b/docs/installation/production/manual/configuration.rst index 64a2c3675..a98e97171 100644 --- a/docs/installation/production/manual/configuration.rst +++ b/docs/installation/production/manual/configuration.rst @@ -30,6 +30,8 @@ Advanced Configuration These guides describe additional configuration that you can perform to add more capabilities to your Tethys Portal. +**Recommended Configuration** + .. toctree:: :maxdepth: 1 diff --git a/docs/installation/production/manual/configuration/advanced/multi_tenancy.rst b/docs/installation/production/manual/configuration/advanced/multi_tenancy.rst index b90ff4ece..7aefdda6f 100644 --- a/docs/installation/production/manual/configuration/advanced/multi_tenancy.rst +++ b/docs/installation/production/manual/configuration/advanced/multi_tenancy.rst @@ -18,7 +18,7 @@ Multi Tenancy (Optional) # pip pip install django-tenants -.. warning:: +.. important:: Multi-tenancy requires PostgreSQL as the database backend. SQLite is not supported. @@ -33,20 +33,20 @@ Use the following instructions to setup multi-tenancy for your Tethys Portal dep Configuration ------------- -Enable multi-tenancy by adding a ``TENANTS`` section to your :file:`portal_config.yml` file and override the database engine to use the ``django-tenants`` backend: +Enable multi-tenancy by adding a ``TENANTS_CONFIG`` section to your :file:`portal_config.yml` file and override the database engine to use the ``django-tenants`` backend: .. code-block:: yaml settings: - TENANTS: + TENANTS_CONFIG: DATABASE_ENGINE: django_tenants.postgresql_backend -You can customize the multi-tenancy behavior with additional settings. : +You can customize the multi-tenancy behavior with additional settings: .. code-block:: yaml settings: - TENANTS: + TENANTS_CONFIG: DATABASE_ENGINE: django_tenants.postgresql_backend TENANT_APPS: - "tethys_apps" From 176258a45890e4074932427f6b41173747be7488 Mon Sep 17 00:00:00 2001 From: jakeymac Date: Thu, 18 Dec 2025 13:27:25 -0700 Subject: [PATCH 23/35] Fixed documentation --- .../manual/configuration/advanced/multi_tenancy.rst | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/docs/installation/production/manual/configuration/advanced/multi_tenancy.rst b/docs/installation/production/manual/configuration/advanced/multi_tenancy.rst index 7aefdda6f..b7ec7c936 100644 --- a/docs/installation/production/manual/configuration/advanced/multi_tenancy.rst +++ b/docs/installation/production/manual/configuration/advanced/multi_tenancy.rst @@ -33,21 +33,21 @@ Use the following instructions to setup multi-tenancy for your Tethys Portal dep Configuration ------------- -Enable multi-tenancy by adding a ``TENANTS_CONFIG`` section to your :file:`portal_config.yml` file and override the database engine to use the ``django-tenants`` backend: +Enable multi-tenancy by overriding the database engine to use the ``django-tenants`` backend: .. code-block:: yaml settings: - TENANTS_CONFIG: - DATABASE_ENGINE: django_tenants.postgresql_backend + DATABASES: + default: + ENGINE: django_tenants.postgresql_backend -You can customize the multi-tenancy behavior with additional settings: +You can customize the multi-tenancy behavior with the following settings: .. code-block:: yaml settings: TENANTS_CONFIG: - DATABASE_ENGINE: django_tenants.postgresql_backend TENANT_APPS: - "tethys_apps" - "tethys_config" @@ -58,9 +58,6 @@ You can customize the multi-tenancy behavior with additional settings: Configuration Options ===================== -**DATABASE_ENGINE** - Required. Must be set to ``django_tenants.postgresql_backend`` to enable schema-based multi-tenancy. The default behavior when django-tenants and the TENANTS setting is in :file:`portal_config.yml` is to override the database engine; however, we recommend explicitly setting it in :file:`portal_config.yml` to avoid confusion. - **TENANT_APPS** List of Django apps that should be isolated per tenant. These apps will have their database tables created in each tenant's schema. From f7884380355358eca79e0b33e8183db02354fdb3 Mon Sep 17 00:00:00 2001 From: jakeymac Date: Thu, 18 Dec 2025 16:36:55 -0700 Subject: [PATCH 24/35] Updated tethys installation script to use django_tenants postgresql backend as default db engine --- scripts/install_tethys.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/install_tethys.sh b/scripts/install_tethys.sh index c3463e86c..e9f03eb6d 100644 --- a/scripts/install_tethys.sh +++ b/scripts/install_tethys.sh @@ -386,7 +386,7 @@ then --set DATABASES.default.PASSWORD ${TETHYS_DB_PASSWORD} \ --set DATABASES.default.PORT ${TETHYS_DB_PORT} \ --set DATABASES.default.DIR ${TETHYS_DB_DIR} \ - --set DATABASES.default.ENGINE django.db.backends.postgresql + --set DATABASES.default.ENGINE django_tenants.postgresql_backend cat ${TETHYS_HOME}/portal_config.yml fi From c88c4516a4c682c7c32b879633866797e284e47c Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Fri, 19 Dec 2025 00:27:08 -0700 Subject: [PATCH 25/35] Formatting fixes --- tethys_portal/settings.py | 12 +++++++----- tethys_tenants/migrations/0001_initial.py | 1 + 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index f109e7bbb..55ce218a1 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -368,11 +368,13 @@ # Set up in portal_config.yml if DATABASES["default"]["ENGINE"] != "django_tenants.postgresql_backend": print("") - write_warning("The database engine for the default database must be set to " - "'django_tenants.postgresql_backend' to use multi-tenancy features. " - "Please update your portal_config.yml file accordingly." \ - "You can use the following command to do so:\n\n" \ - "tethys settings --set DATABASES.default.ENGINE django_tenants.postgresql_backend\n") + write_warning( + "The database engine for the default database must be set to " + "'django_tenants.postgresql_backend' to use multi-tenancy features. " + "Please update your portal_config.yml file accordingly." + "You can use the following command to do so:\n\n" + "tethys settings --set DATABASES.default.ENGINE django_tenants.postgresql_backend\n" + ) DATABASE_ROUTERS = ("django_tenants.routers.TenantSyncRouter",) diff --git a/tethys_tenants/migrations/0001_initial.py b/tethys_tenants/migrations/0001_initial.py index a430240a7..3d9b6c73f 100644 --- a/tethys_tenants/migrations/0001_initial.py +++ b/tethys_tenants/migrations/0001_initial.py @@ -8,6 +8,7 @@ if has_module("django_tenants"): import django_tenants.postgresql_backend.base + class Migration(migrations.Migration): initial = True From ee310b09943e936cf79a2fa655e689422b63e5e4 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Fri, 19 Dec 2025 02:16:04 -0700 Subject: [PATCH 26/35] Fixed tests --- tests/unit_tests/test_tethys_portal/test_views/test_api.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/unit_tests/test_tethys_portal/test_views/test_api.py b/tests/unit_tests/test_tethys_portal/test_views/test_api.py index c353be135..9c21d1b6d 100644 --- a/tests/unit_tests/test_tethys_portal/test_views/test_api.py +++ b/tests/unit_tests/test_tethys_portal/test_views/test_api.py @@ -36,7 +36,7 @@ def test_get_csrf_not_authenticated(self): response = self.client.get(reverse("api:get_csrf")) self.assertEqual(response.status_code, 401) - @override_settings(ENABLE_OPEN_PORTAL=True) + @override_settings(ENABLE_OPEN_PORTAL=True, SHOW_PUBLIC_IF_NO_TENANT_FOUND=True) def test_get_csrf_not_authenticated_but_open_portal(self): """Test get_csrf API endpoint not authenticated.""" self.client.force_login(self.user) @@ -60,7 +60,7 @@ def test_get_session_not_authenticated(self): response = self.client.get(reverse("api:get_session")) self.assertEqual(response.status_code, 401) - @override_settings(ENABLE_OPEN_PORTAL=True) + @override_settings(ENABLE_OPEN_PORTAL=True, SHOW_PUBLIC_IF_NO_TENANT_FOUND=True) def test_get_session_not_authenticated_but_open_portal(self): """Test get_session API endpoint not authenticated.""" response = self.client.get(reverse("api:get_session")) @@ -89,7 +89,7 @@ def test_get_whoami_not_authenticated(self): response = self.client.get(reverse("api:get_whoami")) self.assertEqual(response.status_code, 401) - @override_settings(ENABLE_OPEN_PORTAL=True) + @override_settings(ENABLE_OPEN_PORTAL=True, SHOW_PUBLIC_IF_NO_TENANT_FOUND=True) def test_get_whoami_not_authenticated_but_open_portal(self): """Test get_whoami API endpoint not authenticated.""" response = self.client.get(reverse("api:get_whoami")) @@ -116,6 +116,7 @@ def test_get_whoami_authenticated(self): self.assertEqual("foo", json["username"]) self.assertTrue(json["isAuthenticated"]) + @override_settings(SHOW_PUBLIC_IF_NO_TENANT_FOUND=True) def test_get_whoami_authenticated_gravatar_exception(self): """Test get_whoami API endpoint when gravatar fails.""" from unittest.mock import patch From 32718becf2b1ee921ee61082aae9a71856953179 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Fri, 26 Dec 2025 10:36:45 -0700 Subject: [PATCH 27/35] Updated configurations to display warnings to user concening tethys tenant settings --- tethys_cli/manage_commands.py | 39 +++++++++++++++++++++++++++++++++++ tethys_portal/settings.py | 16 +++----------- tethys_tenants/apps.py | 3 +++ tethys_tenants/checks.py | 23 +++++++++++++++++++++ 4 files changed, 68 insertions(+), 13 deletions(-) create mode 100644 tethys_tenants/checks.py diff --git a/tethys_cli/manage_commands.py b/tethys_cli/manage_commands.py index 363b1581c..01d796cf0 100644 --- a/tethys_cli/manage_commands.py +++ b/tethys_cli/manage_commands.py @@ -10,15 +10,30 @@ import sys from django.core.management import get_commands +from django.conf import settings from tethys_cli.cli_helpers import get_manage_path, run_process +from tethys_cli.cli_colors import write_warning from tethys_utils import deprecation_warning, DOCS_BASE_URL + + MANAGE_START = "start" MANAGE_COLLECTSTATIC = "collectstatic" MANAGE_COLLECTWORKSPACES = "collectworkspaces" MANAGE_COLLECT = "collectall" MANAGE_GET_PATH = "path" +MANAGE_TENANTS = [ + "migrate_schemas", + "tenant_command", + "all_tenants_command", + "create_tenant_superuser", + "create_tenant", + "delete_tenant", + "clone_tenant", + "rename_schema", + "create_missing_schemas" +] def add_manage_parser(subparsers): @@ -182,6 +197,30 @@ def manage_command(args, unknown_args=None): elif args.command == MANAGE_GET_PATH: print(manage_path) + elif args.command in MANAGE_TENANTS: + DATABASES = getattr(settings, "DATABASES", {}) + if not getattr(settings, "TENANTS_ENABLED", False): + write_warning( + "Multi-tenancy features are not enabled. To enable multi-tenancy, set 'TENANTS_CONFIG.ENABLED" + "to true in your portal_config.yml file. " \ + "You can use the following command to do so:\n\n" + "tethys settings --set TENANTS_CONFIG.ENABLED true\n\n" \ + "For more information, see the documentation at " + f"{DOCS_BASE_URL}tethys_portal/multi_tenancy.html" + ) + sys.exit(1) + elif DATABASES["default"]["ENGINE"] != "django_tenants.postgresql_backend": + write_warning( + "The database engine for the default database must be set to " + "'django_tenants.postgresql_backend' to use multi-tenancy features.\n" + "Please update your portal_config.yml file accordingly." + "You can use the following command to do so:\n\n" + "tethys settings --set DATABASES.default.ENGINE django_tenants.postgresql_backend\n" + ) + sys.exit(1) + else: + primary_process = [sys.executable, manage_path, args.command, *unknown_args] + else: if args.django_help: primary_process = [sys.executable, manage_path, args.command, "--help"] diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index 55ce218a1..865b20a54 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -361,21 +361,11 @@ ROOT_URLCONF = "tethys_portal.urls" # Django Tenants settings -if has_module("django_tenants"): - TENANTS_CONFIG = portal_config_settings.pop("TENANTS_CONFIG", {}) - +TENANTS_CONFIG = portal_config_settings.pop("TENANTS_CONFIG", {}) +TENANTS_ENABLED = TENANTS_CONFIG.pop("ENABLED", False) +if has_module("django_tenants") and TENANTS_ENABLED: # Tethys Tenants requires "django_tenants.postgresql_backend" as the database engine # Set up in portal_config.yml - if DATABASES["default"]["ENGINE"] != "django_tenants.postgresql_backend": - print("") - write_warning( - "The database engine for the default database must be set to " - "'django_tenants.postgresql_backend' to use multi-tenancy features. " - "Please update your portal_config.yml file accordingly." - "You can use the following command to do so:\n\n" - "tethys settings --set DATABASES.default.ENGINE django_tenants.postgresql_backend\n" - ) - DATABASE_ROUTERS = ("django_tenants.routers.TenantSyncRouter",) TENANT_LIMIT_SET_CALLS = TENANTS_CONFIG.pop("TENANT_LIMIT_SET_CALLS", False) diff --git a/tethys_tenants/apps.py b/tethys_tenants/apps.py index b4dc0695f..dd4ab920c 100644 --- a/tethys_tenants/apps.py +++ b/tethys_tenants/apps.py @@ -16,3 +16,6 @@ class TethysTenantsConfig(AppConfig): name = "tethys_tenants" verbose_name = "Tethys Tenants" + + def ready(self): + import tethys_tenants.checks diff --git a/tethys_tenants/checks.py b/tethys_tenants/checks.py new file mode 100644 index 000000000..3fb35e43c --- /dev/null +++ b/tethys_tenants/checks.py @@ -0,0 +1,23 @@ +from django.conf import settings +from django.core.checks import Warning, register + + +@register() +def tenant_engine_check(app_configs, **kwargs): + if getattr(settings, "TENANTS_ENABLED", False): + engine = settings.DATABASES["default"]["ENGINE"] + + if engine != "django_tenants.postgresql_backend": + return [ + Warning( + "Tethys Tenants is enabled, but the default database engine " + "is not 'django_tenants.postgresql_backend'.\n" + "This can result in errors involving the database. " + "Please update your portal_config.yml file.\n" + "You can use the following command to do so:\n\n" + "tethys settings --set DATABASES.default.ENGINE django_tenants.postgresql_backend\n", + id="tethys.tenants", + ) + ] + + return [] From 8c3c5c5f4b7b2edafc557cb63ef506bd61c2a10e Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Fri, 26 Dec 2025 10:38:14 -0700 Subject: [PATCH 28/35] Formatting --- tethys_cli/manage_commands.py | 23 +++++++++++------------ tethys_tenants/apps.py | 2 +- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/tethys_cli/manage_commands.py b/tethys_cli/manage_commands.py index 01d796cf0..adf0b0314 100644 --- a/tethys_cli/manage_commands.py +++ b/tethys_cli/manage_commands.py @@ -17,22 +17,21 @@ from tethys_utils import deprecation_warning, DOCS_BASE_URL - MANAGE_START = "start" MANAGE_COLLECTSTATIC = "collectstatic" MANAGE_COLLECTWORKSPACES = "collectworkspaces" MANAGE_COLLECT = "collectall" MANAGE_GET_PATH = "path" MANAGE_TENANTS = [ - "migrate_schemas", - "tenant_command", - "all_tenants_command", - "create_tenant_superuser", - "create_tenant", - "delete_tenant", - "clone_tenant", - "rename_schema", - "create_missing_schemas" + "migrate_schemas", + "tenant_command", + "all_tenants_command", + "create_tenant_superuser", + "create_tenant", + "delete_tenant", + "clone_tenant", + "rename_schema", + "create_missing_schemas", ] @@ -202,9 +201,9 @@ def manage_command(args, unknown_args=None): if not getattr(settings, "TENANTS_ENABLED", False): write_warning( "Multi-tenancy features are not enabled. To enable multi-tenancy, set 'TENANTS_CONFIG.ENABLED" - "to true in your portal_config.yml file. " \ + "to true in your portal_config.yml file. " "You can use the following command to do so:\n\n" - "tethys settings --set TENANTS_CONFIG.ENABLED true\n\n" \ + "tethys settings --set TENANTS_CONFIG.ENABLED true\n\n" "For more information, see the documentation at " f"{DOCS_BASE_URL}tethys_portal/multi_tenancy.html" ) diff --git a/tethys_tenants/apps.py b/tethys_tenants/apps.py index dd4ab920c..e58180f62 100644 --- a/tethys_tenants/apps.py +++ b/tethys_tenants/apps.py @@ -18,4 +18,4 @@ class TethysTenantsConfig(AppConfig): verbose_name = "Tethys Tenants" def ready(self): - import tethys_tenants.checks + import tethys_tenants.checks # noqa: F401 From a71e231479061e9eb5bcdc3321101e081121602b Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Fri, 26 Dec 2025 11:27:14 -0700 Subject: [PATCH 29/35] Updated documentation for tenants configurations --- .../configuration/advanced/multi_tenancy.rst | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/docs/installation/production/manual/configuration/advanced/multi_tenancy.rst b/docs/installation/production/manual/configuration/advanced/multi_tenancy.rst index b7ec7c936..94699f22c 100644 --- a/docs/installation/production/manual/configuration/advanced/multi_tenancy.rst +++ b/docs/installation/production/manual/configuration/advanced/multi_tenancy.rst @@ -33,21 +33,36 @@ Use the following instructions to setup multi-tenancy for your Tethys Portal dep Configuration ------------- -Enable multi-tenancy by overriding the database engine to use the ``django-tenants`` backend: +Enable multi-tenancy by making the following changes to your settings: + +Begin by enabling Tethys Tenants: + +.. code-block:: yaml + :emphasize-lines: 3-4 + + settings: + ... + TENANTS_CONFIG: + ENABLED: true + +Next, override the database engine to use the ``django-tenants`` backend: .. code-block:: yaml + :emphasize-lines: 4 settings: - DATABASES: - default: - ENGINE: django_tenants.postgresql_backend + DATABASES: + default: + ENGINE: django_tenants.postgresql_backend You can customize the multi-tenancy behavior with the following settings: .. code-block:: yaml + :emphasize-lines: 4-9 settings: TENANTS_CONFIG: + ENABLED: true TENANT_APPS: - "tethys_apps" - "tethys_config" From 91f6c7cdab99f67c51eccb96b7e65c36f0739aa2 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Fri, 26 Dec 2025 19:27:09 -0700 Subject: [PATCH 30/35] flake fix --- tethys_portal/settings.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index 861397340..2c0057a10 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -36,7 +36,6 @@ from tethys_apps.utilities import relative_to_tethys_home from tethys_utils import deprecation_warning from tethys_cli.gen_commands import generate_secret_key -from tethys_cli.cli_colors import write_warning from tethys_portal.optional_dependencies import optional_import, has_module # optional imports From 09af76adf87402447e7e0986c03dcc70d1c73203 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Sat, 27 Dec 2025 05:06:23 -0700 Subject: [PATCH 31/35] Converted tests to pytest --- .../test_tethys_tenants/test_admin.py | 234 ++++++++++-------- .../test_tethys_tenants/test_apps.py | 28 ++- .../test_tethys_tenants/test_models.py | 30 ++- 3 files changed, 164 insertions(+), 128 deletions(-) diff --git a/tests/unit_tests/test_tethys_tenants/test_admin.py b/tests/unit_tests/test_tethys_tenants/test_admin.py index 86e6a1367..d1057e040 100644 --- a/tests/unit_tests/test_tethys_tenants/test_admin.py +++ b/tests/unit_tests/test_tethys_tenants/test_admin.py @@ -7,107 +7,137 @@ from tethys_tenants import models from tethys_tenants import admin as tethys_tenants_admin +import pytest -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() +@pytest.fixture +def setup_test(): + factory = RequestFactory() + site = AdminSite() + + # Create object to hold instance properties + class InstanceProperties: + pass + + props = InstanceProperties() + props.factory = factory + props.site = site + yield props + pass + + +def test_is_public_schema_function(): + # Mock request with public tenant + public_request = mock.MagicMock() + public_request.tenant.schema_name = "public" + assert tethys_tenants_admin.is_public_schema(public_request) + + # Mock request with non-public tenant + tenant_request = mock.MagicMock() + tenant_request.tenant.schema_name = "tenant1" + assert not tethys_tenants_admin.is_public_schema(tenant_request) + + + +def test_public_schema_only_decorator(): + @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) + assert result == "success" + + # Test with tenant schema - should raise Http404 + tenant_request = mock.MagicMock() + tenant_request.tenant.schema_name = "tenant1" + with pytest.raises(Http404): + dummy_view(None, tenant_request) + + + +def test_domain_admin_registration(): + registry = admin.site._registry + assert models.Domain in registry + assert isinstance(registry[models.Domain], tethys_tenants_admin.DomainAdmin) + + + +def test_tenant_admin_registration(): + registry = admin.site._registry + assert models.Tenant in registry + assert isinstance(registry[models.Tenant], tethys_tenants_admin.TenantAdmin) + + + +def test_tenant_admin_configuration(setup_test): + admin_instance = tethys_tenants_admin.TenantAdmin(models.Tenant, setup_test.site) + assert admin_instance.list_display == ("name",) + + + +def test_domain_admin_has_module_permission(setup_test): + admin_instance = tethys_tenants_admin.DomainAdmin(models.Domain, setup_test.site) + + # Test with public schema + public_request = mock.MagicMock() + public_request.tenant.schema_name = "public" + assert admin_instance.has_module_permission(public_request) + + # Test with tenant schema + tenant_request = mock.MagicMock() + tenant_request.tenant.schema_name = "tenant1" + assert not admin_instance.has_module_permission(tenant_request) + + + +def test_tenant_admin_has_module_permission(setup_test): + admin_instance = tethys_tenants_admin.TenantAdmin(models.Tenant, setup_test.site) + + # Test with public schema + public_request = mock.MagicMock() + public_request.tenant.schema_name = "public" + assert admin_instance.has_module_permission(public_request) + + # Test with tenant schema + tenant_request = mock.MagicMock() + tenant_request.tenant.schema_name = "tenant1" + assert not admin_instance.has_module_permission(tenant_request) + + + +def test_domain_admin_changelist_view_public_schema(setup_test): + admin_instance = tethys_tenants_admin.DomainAdmin(models.Domain, setup_test.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) + assert result == "success" + mock_super.assert_called_once_with(public_request, None) + + + +def test_domain_admin_changelist_view_tenant_schema(setup_test): + admin_instance = tethys_tenants_admin.DomainAdmin(models.Domain, setup_test.site) + + tenant_request = mock.MagicMock() + tenant_request.tenant.schema_name = "tenant1" + + with pytest.raises(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(mock_has_module, ): + importlib.reload(tethys_tenants_admin) + + # Verify has_module was called + mock_has_module.assert_called() \ No newline at end of file diff --git a/tests/unit_tests/test_tethys_tenants/test_apps.py b/tests/unit_tests/test_tethys_tenants/test_apps.py index 42daf1cb6..f805ce982 100644 --- a/tests/unit_tests/test_tethys_tenants/test_apps.py +++ b/tests/unit_tests/test_tethys_tenants/test_apps.py @@ -4,21 +4,23 @@ from django.apps import apps from tethys_tenants import apps as tenant_apps +import pytest -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 +def test_tethys_tenants_config(): + 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)) + assert "tethys_tenants" == name + assert "Tethys Tenants" == verbose_name + assert 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() + +@mock.patch("tethys_portal.optional_dependencies.has_module", return_value=False) +def test_tethys_tenants_config_unavailable(mock_hm): + importlib.reload(tenant_apps) + + # Verify has_module was called + mock_hm.assert_called() \ No newline at end of file diff --git a/tests/unit_tests/test_tethys_tenants/test_models.py b/tests/unit_tests/test_tethys_tenants/test_models.py index fd5034570..1800f1c60 100644 --- a/tests/unit_tests/test_tethys_tenants/test_models.py +++ b/tests/unit_tests/test_tethys_tenants/test_models.py @@ -3,21 +3,25 @@ from django.test import TestCase from tethys_tenants import models +import pytest -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_tenant_model_exists(): + assert hasattr(models.Tenant, "name") + assert hasattr(models.Tenant, "created_on") + assert hasattr(models.Tenant, "auto_create_schema") + assert 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() +def test_domain_model_exists(): + # Domain inherits from DomainMixin, so just checking it exists + assert models.Domain is not None + + + +@mock.patch("tethys_portal.optional_dependencies.has_module", return_value=False) +def test_models_not_imported_when_django_tenants_unavailable(mock_hm): + importlib.reload(models) + + mock_hm.assert_called() \ No newline at end of file From 1c97e0cb25c918730c4dd76057033436847f24af Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Sat, 27 Dec 2025 05:18:19 -0700 Subject: [PATCH 32/35] formatting fixes --- .../test_tethys_tenants/test_admin.py | 21 ++++++------------- .../test_tethys_tenants/test_apps.py | 5 +---- .../test_tethys_tenants/test_models.py | 6 +----- 3 files changed, 8 insertions(+), 24 deletions(-) diff --git a/tests/unit_tests/test_tethys_tenants/test_admin.py b/tests/unit_tests/test_tethys_tenants/test_admin.py index d1057e040..a691552b8 100644 --- a/tests/unit_tests/test_tethys_tenants/test_admin.py +++ b/tests/unit_tests/test_tethys_tenants/test_admin.py @@ -1,6 +1,6 @@ import importlib from unittest import mock -from django.test import TestCase, RequestFactory +from django.test import RequestFactory from django.contrib import admin from django.http import Http404 from django.contrib.admin.sites import AdminSite @@ -38,7 +38,6 @@ def test_is_public_schema_function(): assert not tethys_tenants_admin.is_public_schema(tenant_request) - def test_public_schema_only_decorator(): @tethys_tenants_admin.public_schema_only def dummy_view(self, request): @@ -57,27 +56,23 @@ def dummy_view(self, request): dummy_view(None, tenant_request) - def test_domain_admin_registration(): registry = admin.site._registry assert models.Domain in registry assert isinstance(registry[models.Domain], tethys_tenants_admin.DomainAdmin) - def test_tenant_admin_registration(): registry = admin.site._registry assert models.Tenant in registry assert isinstance(registry[models.Tenant], tethys_tenants_admin.TenantAdmin) - def test_tenant_admin_configuration(setup_test): admin_instance = tethys_tenants_admin.TenantAdmin(models.Tenant, setup_test.site) assert admin_instance.list_display == ("name",) - def test_domain_admin_has_module_permission(setup_test): admin_instance = tethys_tenants_admin.DomainAdmin(models.Domain, setup_test.site) @@ -92,7 +87,6 @@ def test_domain_admin_has_module_permission(setup_test): assert not admin_instance.has_module_permission(tenant_request) - def test_tenant_admin_has_module_permission(setup_test): admin_instance = tethys_tenants_admin.TenantAdmin(models.Tenant, setup_test.site) @@ -107,23 +101,19 @@ def test_tenant_admin_has_module_permission(setup_test): assert not admin_instance.has_module_permission(tenant_request) - def test_domain_admin_changelist_view_public_schema(setup_test): admin_instance = tethys_tenants_admin.DomainAdmin(models.Domain, setup_test.site) public_request = mock.MagicMock() public_request.tenant.schema_name = "public" - with mock.patch( - "django.contrib.admin.ModelAdmin.changelist_view" - ) as mock_super: + with mock.patch("django.contrib.admin.ModelAdmin.changelist_view") as mock_super: mock_super.return_value = "success" result = admin_instance.changelist_view(public_request) assert result == "success" mock_super.assert_called_once_with(public_request, None) - def test_domain_admin_changelist_view_tenant_schema(setup_test): admin_instance = tethys_tenants_admin.DomainAdmin(models.Domain, setup_test.site) @@ -134,10 +124,11 @@ def test_domain_admin_changelist_view_tenant_schema(setup_test): 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(mock_has_module, ): +def test_admin_graceful_handling_without_django_tenants( + mock_has_module, +): importlib.reload(tethys_tenants_admin) # Verify has_module was called - mock_has_module.assert_called() \ No newline at end of file + mock_has_module.assert_called() diff --git a/tests/unit_tests/test_tethys_tenants/test_apps.py b/tests/unit_tests/test_tethys_tenants/test_apps.py index f805ce982..528814f84 100644 --- a/tests/unit_tests/test_tethys_tenants/test_apps.py +++ b/tests/unit_tests/test_tethys_tenants/test_apps.py @@ -1,10 +1,8 @@ import importlib from unittest import mock -from django.test import TestCase from django.apps import apps from tethys_tenants import apps as tenant_apps -import pytest def test_tethys_tenants_config(): @@ -17,10 +15,9 @@ def test_tethys_tenants_config(): assert isinstance(app_config, tenant_apps.TethysTenantsConfig) - @mock.patch("tethys_portal.optional_dependencies.has_module", return_value=False) def test_tethys_tenants_config_unavailable(mock_hm): importlib.reload(tenant_apps) # Verify has_module was called - mock_hm.assert_called() \ No newline at end of file + mock_hm.assert_called() diff --git a/tests/unit_tests/test_tethys_tenants/test_models.py b/tests/unit_tests/test_tethys_tenants/test_models.py index 1800f1c60..19cb41895 100644 --- a/tests/unit_tests/test_tethys_tenants/test_models.py +++ b/tests/unit_tests/test_tethys_tenants/test_models.py @@ -1,9 +1,7 @@ import importlib from unittest import mock -from django.test import TestCase from tethys_tenants import models -import pytest def test_tenant_model_exists(): @@ -13,15 +11,13 @@ def test_tenant_model_exists(): assert models.Tenant.auto_create_schema - def test_domain_model_exists(): # Domain inherits from DomainMixin, so just checking it exists assert models.Domain is not None - @mock.patch("tethys_portal.optional_dependencies.has_module", return_value=False) def test_models_not_imported_when_django_tenants_unavailable(mock_hm): importlib.reload(models) - mock_hm.assert_called() \ No newline at end of file + mock_hm.assert_called() From 21ac0b6de31e23951a1b015c94bd0a32da52774b Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Sat, 27 Dec 2025 05:32:56 -0700 Subject: [PATCH 33/35] Updated default portal config in installation script to enable tenants --- scripts/install_tethys.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/install_tethys.sh b/scripts/install_tethys.sh index e9f03eb6d..2a4caa20e 100644 --- a/scripts/install_tethys.sh +++ b/scripts/install_tethys.sh @@ -386,7 +386,8 @@ then --set DATABASES.default.PASSWORD ${TETHYS_DB_PASSWORD} \ --set DATABASES.default.PORT ${TETHYS_DB_PORT} \ --set DATABASES.default.DIR ${TETHYS_DB_DIR} \ - --set DATABASES.default.ENGINE django_tenants.postgresql_backend + --set DATABASES.default.ENGINE django_tenants.postgresql_backend \ + --set TETHYS_TENANTS.enabled true cat ${TETHYS_HOME}/portal_config.yml fi From ef3ddf1db6adeb717526c060f0eb18c5285700c4 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Sat, 27 Dec 2025 05:38:48 -0700 Subject: [PATCH 34/35] Fixed config in install script --- scripts/install_tethys.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/install_tethys.sh b/scripts/install_tethys.sh index 2a4caa20e..13ea7371c 100644 --- a/scripts/install_tethys.sh +++ b/scripts/install_tethys.sh @@ -387,7 +387,7 @@ then --set DATABASES.default.PORT ${TETHYS_DB_PORT} \ --set DATABASES.default.DIR ${TETHYS_DB_DIR} \ --set DATABASES.default.ENGINE django_tenants.postgresql_backend \ - --set TETHYS_TENANTS.enabled true + --set TETHYS_TENANTS.ENABLED true cat ${TETHYS_HOME}/portal_config.yml fi From 2cca9e7eaeec995855a6f1f537286a98abbd5799 Mon Sep 17 00:00:00 2001 From: Jacob Johnson Date: Sat, 27 Dec 2025 05:44:29 -0700 Subject: [PATCH 35/35] One more portal_config fix in install script --- scripts/install_tethys.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/install_tethys.sh b/scripts/install_tethys.sh index 13ea7371c..acbe26357 100644 --- a/scripts/install_tethys.sh +++ b/scripts/install_tethys.sh @@ -387,7 +387,7 @@ then --set DATABASES.default.PORT ${TETHYS_DB_PORT} \ --set DATABASES.default.DIR ${TETHYS_DB_DIR} \ --set DATABASES.default.ENGINE django_tenants.postgresql_backend \ - --set TETHYS_TENANTS.ENABLED true + --set TENANTS_CONFIG.ENABLED true cat ${TETHYS_HOME}/portal_config.yml fi