diff --git a/docs/installation/production.rst b/docs/installation/production.rst index 13ca21150..0653f04a8 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:`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:`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. + References ========== diff --git a/docs/installation/production/manual/configuration.rst b/docs/installation/production/manual/configuration.rst index f58d0ad4d..b7743d302 100644 --- a/docs/installation/production/manual/configuration.rst +++ b/docs/installation/production/manual/configuration.rst @@ -30,16 +30,25 @@ Advanced Configuration These guides describe additional configuration that you can perform to add more capabilities to your Tethys Portal. +**Recommended Configuration** + .. toctree:: :maxdepth: 1 configuration/advanced/https_config configuration/advanced/cookie_consent configuration/advanced/customize + +**Optional Configuration** + +.. toctree:: + :maxdepth: 1 + configuration/advanced/email_config configuration/advanced/lockout 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..94699f22c --- /dev/null +++ b/docs/installation/production/manual/configuration/advanced/multi_tenancy.rst @@ -0,0 +1,149 @@ +.. _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 + +.. important:: + + 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 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 + +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" + TENANT_LIMIT_SET_CALLS: false + TENANT_COLOR_ADMIN_APPS: true + SHOW_PUBLIC_IF_NO_TENANT_FOUND: false + +Configuration Options +===================== + +**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 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) 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 migrate zero --fake --tenant + tethys manage migrate --tenant + +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 + +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 8799a5369..b068d369f 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/docs/whats_new.rst b/docs/whats_new.rst index 116a1c624..ebfc796dd 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -63,6 +63,13 @@ Recipes See: :ref:`recipes` +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 ``_ Automated PyPI Package Uploads ------------------------------ diff --git a/environment.yml b/environment.yml index 5697a38c7..5c3517b9c 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/scripts/install_tethys.sh b/scripts/install_tethys.sh index c3463e86c..acbe26357 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.db.backends.postgresql + --set DATABASES.default.ENGINE django_tenants.postgresql_backend \ + --set TENANTS_CONFIG.ENABLED true cat ${TETHYS_HOME}/portal_config.yml fi diff --git a/tests/unit_tests/test_tethys_portal/test_settings.py b/tests/unit_tests/test_tethys_portal/test_settings.py index 21bd2ae68..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"], @@ -218,6 +220,30 @@ def test_db_config_postgres(self, _): }, ) + @mock.patch( + "tethys_portal.settings.yaml.safe_load", + return_value={ + "settings": { + "DATABASES": { + "default": {"ENGINE": "django_tenants.postgresql_backend"} + } + } + }, + ) + def test_db_config_tenants_postgres(self, _): + reload(settings) + self.assertDictEqual( + settings.DATABASES["default"], + { + "ENGINE": "django_tenants.postgresql_backend", + "NAME": "tethys_platform", + "USER": "tethys_default", + "PASSWORD": "pass", + "HOST": "localhost", + "PORT": 5436, + }, + ) + # TODO remove compatibility code tests with Tethys5.0 (or 4.2?) @mock.patch( "tethys_portal.settings.yaml.safe_load", 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 c7b585916..077cf6508 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 @@ -32,6 +32,7 @@ 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 and check deprecation warning.""" with warnings.catch_warnings(record=True) as w: @@ -47,7 +48,7 @@ def test_get_csrf_not_authenticated(self): ) ) - @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 and check deprecation warning.""" self.client.force_login(self.user) @@ -66,6 +67,7 @@ def test_get_csrf_not_authenticated_but_open_portal(self): ) ) + @override_settings(SHOW_PUBLIC_IF_NO_TENANT_FOUND=True) def test_get_csrf_authenticated(self): """Test get_csrf API endpoint authenticated and check deprecation warning.""" self.client.force_login(self.user) @@ -84,6 +86,7 @@ def test_get_csrf_authenticated(self): ) ) + @override_settings(SHOW_PUBLIC_IF_NO_TENANT_FOUND=True) def test_get_session_not_authenticated(self): """Test get_session API endpoint not authenticated and check deprecation warning.""" with warnings.catch_warnings(record=True) as w: @@ -99,7 +102,7 @@ def test_get_session_not_authenticated(self): ) ) - @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 and check deprecation warning.""" with warnings.catch_warnings(record=True) as w: @@ -119,6 +122,7 @@ def test_get_session_not_authenticated_but_open_portal(self): ) ) + @override_settings(SHOW_PUBLIC_IF_NO_TENANT_FOUND=True) def test_get_session_authenticated(self): """Test get_session API endpoint authenticated and check deprecation warning.""" self.client.force_login(self.user) @@ -139,6 +143,7 @@ def test_get_session_authenticated(self): ) ) + @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")) @@ -149,6 +154,7 @@ def test_get_whoami_not_authenticated(self): {"username": "", "isAuthenticated": False, "isStaff": False}, json ) + @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) @@ -166,6 +172,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 @@ -193,6 +200,7 @@ def test_get_whoami_authenticated_gravatar_exception(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() @@ -231,6 +239,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() @@ -271,6 +280,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() @@ -318,6 +328,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/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..a691552b8 --- /dev/null +++ b/tests/unit_tests/test_tethys_tenants/test_admin.py @@ -0,0 +1,134 @@ +import importlib +from unittest import mock +from django.test import 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 +import pytest + + +@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() 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..528814f84 --- /dev/null +++ b/tests/unit_tests/test_tethys_tenants/test_apps.py @@ -0,0 +1,23 @@ +import importlib +from unittest import mock +from django.apps import apps + +from tethys_tenants import apps as tenant_apps + + +def test_tethys_tenants_config(): + app_config = apps.get_app_config("tethys_tenants") + name = app_config.name + verbose_name = app_config.verbose_name + + 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(mock_hm): + importlib.reload(tenant_apps) + + # Verify has_module was called + 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 new file mode 100644 index 000000000..19cb41895 --- /dev/null +++ b/tests/unit_tests/test_tethys_tenants/test_models.py @@ -0,0 +1,23 @@ +import importlib +from unittest import mock + +from tethys_tenants import models + + +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(): + # 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() diff --git a/tethys_cli/manage_commands.py b/tethys_cli/manage_commands.py index 363b1581c..adf0b0314 100644 --- a/tethys_cli/manage_commands.py +++ b/tethys_cli/manage_commands.py @@ -10,15 +10,29 @@ 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 +196,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_platform b/tethys_platform new file mode 100644 index 000000000..e69de29bb diff --git a/tethys_portal/settings.py b/tethys_portal/settings.py index f20df7b5c..2c0057a10 100644 --- a/tethys_portal/settings.py +++ b/tethys_portal/settings.py @@ -265,7 +265,6 @@ if has_module(module): default_installed_apps.append(module) - INSTALLED_APPS = portal_config_settings.pop( "INSTALLED_APPS_OVERRIDE", default_installed_apps, @@ -365,6 +364,63 @@ ROOT_URLCONF = "tethys_portal.urls" +# Django Tenants settings +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 + DATABASE_ROUTERS = ("django_tenants.routers.TenantSyncRouter",) + + 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" + + 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", [])) + + 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) + # Internationalization LANGUAGE_CODE = "en-us" @@ -435,7 +491,6 @@ } ] - # Static files (CSS, JavaScript, Images) STATIC_URL = portal_config_settings.pop("STATIC_URL", "/static/") diff --git a/tethys_tenants/__init__.py b/tethys_tenants/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tethys_tenants/admin.py b/tethys_tenants/admin.py new file mode 100644 index 000000000..71e2a8390 --- /dev/null +++ b/tethys_tenants/admin.py @@ -0,0 +1,78 @@ +""" +******************************************************************************** +* 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 + + +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""" + 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""" + + @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..e58180f62 --- /dev/null +++ b/tethys_tenants/apps.py @@ -0,0 +1,21 @@ +""" +******************************************************************************** +* Name: apps.py +* Author: Michael Souffront +* Created On: 2025 +* License: BSD 2-Clause +******************************************************************************** +""" + +from django.apps import AppConfig +from tethys_portal.optional_dependencies import has_module + + +if has_module("django_tenants"): + + class TethysTenantsConfig(AppConfig): + name = "tethys_tenants" + verbose_name = "Tethys Tenants" + + def ready(self): + import tethys_tenants.checks # noqa: F401 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 [] diff --git a/tethys_tenants/migrations/0001_initial.py b/tethys_tenants/migrations/0001_initial.py new file mode 100644 index 000000000..3d9b6c73f --- /dev/null +++ b/tethys_tenants/migrations/0001_initial.py @@ -0,0 +1,78 @@ +# Generated by Django 5.2.7 on 2025-11-03 22:30 +# Applied only if django_tenants is installed + +import django.db.models.deletion +from django.db import migrations, models + +from tethys_portal.optional_dependencies import has_module + +if has_module("django_tenants"): + import django_tenants.postgresql_backend.base + + 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..9458cc3bb --- /dev/null +++ b/tethys_tenants/models.py @@ -0,0 +1,26 @@ +""" +******************************************************************************** +* Name: admin.py +* Author: Michael Souffront +* Created On: 2025 +* License: BSD 2-Clause +******************************************************************************** +""" + +from django.db import models +from tethys_portal.optional_dependencies import has_module + + +if has_module("django_tenants"): + + from django_tenants.models import TenantMixin, DomainMixin + + class Tenant(TenantMixin): + name = models.CharField(max_length=100) + created_on = models.DateField(auto_now_add=True) + + # Schema will be automatically created and synced on save when True + auto_create_schema = True + + class Domain(DomainMixin): + pass