cardo-python-utils 0.5.dev30__tar.gz → 0.5.dev31__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/MANIFEST.in +1 -0
  2. {cardo_python_utils-0.5.dev30/cardo_python_utils.egg-info → cardo_python_utils-0.5.dev31}/PKG-INFO +1 -1
  3. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31/cardo_python_utils.egg-info}/PKG-INFO +1 -1
  4. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/cardo_python_utils.egg-info/SOURCES.txt +22 -1
  5. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/pyproject.toml +1 -1
  6. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/admin/user_group.py +1 -1
  7. cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/README.md +62 -0
  8. cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/__init__.py +1 -0
  9. cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/apps.py +7 -0
  10. cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/celery/__init__.py +4 -0
  11. cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/celery/tenant_aware_task.py +31 -0
  12. cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/db/__init__.py +0 -0
  13. cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/db/routers.py +11 -0
  14. cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/db/transaction.py +26 -0
  15. cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/db/utils.py +66 -0
  16. cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/management/__init__.py +0 -0
  17. cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/management/commands/__init__.py +0 -0
  18. cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/management/commands/migrateall.py +25 -0
  19. cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/management/commands/tenant_aware_command.py +74 -0
  20. cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/middleware/__init__.py +6 -0
  21. cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/middleware/tenant_http_middleware.py +120 -0
  22. cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/redis/__init__.py +4 -0
  23. cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/redis/key_function.py +5 -0
  24. cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/settings.py +17 -0
  25. cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/storage/__init__.py +6 -0
  26. cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/storage/tenant_aware_storage.py +86 -0
  27. cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/tenant_context.py +89 -0
  28. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/LICENSE +0 -0
  29. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/README.rst +0 -0
  30. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/cardo_python_utils.egg-info/dependency_links.txt +0 -0
  31. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/cardo_python_utils.egg-info/requires.txt +0 -0
  32. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/cardo_python_utils.egg-info/top_level.txt +0 -0
  33. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/__init__.py +0 -0
  34. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/choices.py +0 -0
  35. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/data_structures.py +0 -0
  36. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/db.py +0 -0
  37. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/django_utils.py +0 -0
  38. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/esma_choices.py +0 -0
  39. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/exceptions.py +0 -0
  40. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/imports.py +0 -0
  41. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/__init__.py +0 -0
  42. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/admin/__init__.py +0 -0
  43. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/admin/auth.py +0 -0
  44. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/admin/user_groups_changelist.html +0 -0
  45. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/api/__init__.py +0 -0
  46. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/api/drf.py +0 -0
  47. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/api/ninja.py +0 -0
  48. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/api/utils.py +0 -0
  49. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/models/__init__.py +0 -0
  50. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/models/user_group.py +0 -0
  51. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/service.py +0 -0
  52. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/tests/__init__.py +0 -0
  53. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/tests/conftest.py +0 -0
  54. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/utils.py +0 -0
  55. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/math.py +0 -0
  56. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/text.py +0 -0
  57. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/time.py +0 -0
  58. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/python_utils/types_hinting.py +0 -0
  59. {cardo_python_utils-0.5.dev30 → cardo_python_utils-0.5.dev31}/setup.cfg +0 -0
@@ -1,4 +1,5 @@
1
1
  include LICENSE
2
2
  include README.rst
3
3
  include python_utils/keycloak/django/admin/user_groups_changelist.html
4
+ include python_utils/multi_tenancy/README.md
4
5
  recursive-exclude tests *
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cardo-python-utils
3
- Version: 0.5.dev30
3
+ Version: 0.5.dev31
4
4
  Summary: Python library enhanced with a wide range of functions for different scenarios.
5
5
  Author-email: CardoAI <hello@cardoai.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cardo-python-utils
3
- Version: 0.5.dev30
3
+ Version: 0.5.dev31
4
4
  Summary: Python library enhanced with a wide range of functions for different scenarios.
5
5
  Author-email: CardoAI <hello@cardoai.com>
6
6
  License: MIT
@@ -33,4 +33,25 @@ python_utils/keycloak/django/api/utils.py
33
33
  python_utils/keycloak/django/models/__init__.py
34
34
  python_utils/keycloak/django/models/user_group.py
35
35
  python_utils/keycloak/django/tests/__init__.py
36
- python_utils/keycloak/django/tests/conftest.py
36
+ python_utils/keycloak/django/tests/conftest.py
37
+ python_utils/multi_tenancy/README.md
38
+ python_utils/multi_tenancy/__init__.py
39
+ python_utils/multi_tenancy/apps.py
40
+ python_utils/multi_tenancy/settings.py
41
+ python_utils/multi_tenancy/tenant_context.py
42
+ python_utils/multi_tenancy/celery/__init__.py
43
+ python_utils/multi_tenancy/celery/tenant_aware_task.py
44
+ python_utils/multi_tenancy/db/__init__.py
45
+ python_utils/multi_tenancy/db/routers.py
46
+ python_utils/multi_tenancy/db/transaction.py
47
+ python_utils/multi_tenancy/db/utils.py
48
+ python_utils/multi_tenancy/management/__init__.py
49
+ python_utils/multi_tenancy/management/commands/__init__.py
50
+ python_utils/multi_tenancy/management/commands/migrateall.py
51
+ python_utils/multi_tenancy/management/commands/tenant_aware_command.py
52
+ python_utils/multi_tenancy/middleware/__init__.py
53
+ python_utils/multi_tenancy/middleware/tenant_http_middleware.py
54
+ python_utils/multi_tenancy/redis/__init__.py
55
+ python_utils/multi_tenancy/redis/key_function.py
56
+ python_utils/multi_tenancy/storage/__init__.py
57
+ python_utils/multi_tenancy/storage/tenant_aware_storage.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cardo-python-utils"
7
- version = "0.5.dev30"
7
+ version = "0.5.dev31"
8
8
  description = "Python library enhanced with a wide range of functions for different scenarios."
9
9
  readme = "README.rst"
10
10
  requires-python = ">=3.8"
@@ -134,7 +134,7 @@ class UserGroupAdminBase(admin.ModelAdmin, metaclass=UserGroupAdminMetaclass):
134
134
  ]
135
135
 
136
136
  @staticmethod
137
- def sync_groups_with_keycloak(request): # noqa
137
+ def sync_groups_with_keycloak(request):
138
138
  """Syncs user groups with Keycloak"""
139
139
 
140
140
  try:
@@ -0,0 +1,62 @@
1
+ This package adds multi-tenancy support to a Django application, with a separate database for each tenant.
2
+ Several components are provided to facilitate this, including: database routing, middleware, celery tasks etc.
3
+
4
+ It is heavily inspired by the implementation of multi-tenancy in the Mercury app by Klement Omeri.
5
+
6
+ # Usage
7
+
8
+ To use this package, add the following to your Django settings.py file:
9
+
10
+ ```python3
11
+ INSTALLED_APPS = [
12
+ ...
13
+ "python_utils.multi_tenancy",
14
+ ...
15
+ ]
16
+
17
+ MIDDLEWARE = [
18
+ "python_utils.multi_tenancy.middleware.TenantAwareHttpMiddleware",
19
+ ...
20
+ ]
21
+
22
+ # Include the database configuration for each tenant in the DATABASES setting.
23
+ # You can use the get_database_configs() function from python_utils.multi_tenancy.db.utils as a helper.
24
+ DATABASES = {
25
+ 'tenant1': { ... },
26
+ 'tenant2': { ... },
27
+ ...
28
+ }
29
+
30
+ # If you want to override the database alias to use for local development (when DEBUG is True).
31
+ # By default, the first database defined in DATABASES is used.
32
+ DEVELOPMENT_TENANT = "development"
33
+
34
+ # This is required to use the tenant context when routing database queries
35
+ DATABASE_ROUTERS = [
36
+ "python_utils.multi_tenancy.db.routers.TenantAwareRouter"
37
+ ]
38
+
39
+ # If using celery, set the task class to TenantAwareTask:
40
+ CELERY_TASK_CLS = "python_utils.multi_tenancy.celery.TenantAwareTask"
41
+
42
+ # If using Redis caching, configure the cache backend as follows:
43
+ CACHES = {
44
+ "default": {
45
+ "BACKEND": "django_redis.cache.RedisCache",
46
+ "LOCATION": REDIS_LOCATION,
47
+ "KEY_FUNCTION": "python_utils.multi_tenancy.redis.make_key",
48
+ **OPTIONS,
49
+ }
50
+ }
51
+
52
+ # If using Django Storages with S3, configure the storage backends as follows:
53
+ STORAGES = {
54
+ "default": {
55
+ "BACKEND": "python_utils.multi_tenancy.storage.TenantAwarePrivateS3Storage",
56
+ },
57
+ }
58
+
59
+ # If you want to exclude certain paths from tenant processing, use TENANT_AWARE_EXCLUDED_PATHS:
60
+ # They are considered as prefixes, so all paths starting with the given strings will be excluded.
61
+ TENANT_AWARE_EXCLUDED_PATHS = ("/some/path",)
62
+ ```
@@ -0,0 +1 @@
1
+ default_app_config = "python_utils.multi_tenancy.apps.MultiTenancyConfig"
@@ -0,0 +1,7 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class MultiTenancyConfig(AppConfig):
5
+ name = "python_utils.multi_tenancy"
6
+ label = "multi_tenancy"
7
+ verbose_name = "Multi-Tenancy"
@@ -0,0 +1,4 @@
1
+ from .tenant_aware_task import TenantAwareTask
2
+
3
+
4
+ __all__ = ["TenantAwareTask"]
@@ -0,0 +1,31 @@
1
+ from celery import Task
2
+
3
+ from ..settings import TENANT_KEY
4
+ from ..tenant_context import TenantContext
5
+
6
+
7
+ class TenantAwareTask(Task):
8
+ #: Enable argument checking.
9
+ #: You can set this to false if you don't want the signature to be
10
+ #: checked when calling the task.
11
+ #: Set to false because we are attaching a tenant to the task
12
+ #: and we don't want to check the signature.
13
+ #: Defaults to :attr:`app.strict_typing <@Celery.strict_typing>`.
14
+ typing = False
15
+
16
+ def __call__(self, *args, **kwargs):
17
+ """Override the __call__ method to set the tenant name in the thread namespace."""
18
+
19
+ # Celery backend_cleanup doesn't need a tenant and cannot be configured to pass the tenant kwarg
20
+ # because it is dynamically generated at @connect_on_app_finalize by celery itself
21
+ # ref: celery/app/builtins.py def add_backend_cleanup_task
22
+ if self.name != "celery.backend_cleanup":
23
+ tenant = kwargs.pop(TENANT_KEY)
24
+ TenantContext.set(tenant)
25
+
26
+ return self.run(*args, **kwargs)
27
+
28
+ def after_return(self, status, retval, task_id, args, kwargs, einfo):
29
+ """Clear the tenant from the thread namespace after the task has returned."""
30
+ TenantContext.clear()
31
+ super().after_return(status, retval, task_id, args, kwargs, einfo)
@@ -0,0 +1,11 @@
1
+ from ..tenant_context import TenantContext
2
+
3
+
4
+ class TenantAwareRouter:
5
+ @staticmethod
6
+ def db_for_read(model, **hints):
7
+ return TenantContext.get()
8
+
9
+ @staticmethod
10
+ def db_for_write(model, **hints):
11
+ return TenantContext.get()
@@ -0,0 +1,26 @@
1
+ from django.db.transaction import Atomic
2
+
3
+ from ..tenant_context import TenantContext
4
+
5
+
6
+ class TenantAtomic(Atomic):
7
+ """
8
+ A transaction that can be used in a multi-tenant application.
9
+ This transaction is bound to the current tenant.
10
+ The default implementation is to use the default database.
11
+ We want to override this by using the tenant database alias instead.
12
+ """
13
+
14
+ def __enter__(self):
15
+ self.using = TenantContext.get()
16
+ super().__enter__()
17
+
18
+
19
+ def tenant_atomic(using=None, savepoint=True, durable=False):
20
+ # Bare decorator: @atomic -- although the first argument is called
21
+ # `using`, it's actually the function being decorated.
22
+ if callable(using):
23
+ return TenantAtomic(using=None, savepoint=savepoint, durable=durable)(using)
24
+ # Decorator: @atomic(...) or context manager: with atomic(...): ...
25
+ else:
26
+ return TenantAtomic(using, savepoint, durable)
@@ -0,0 +1,66 @@
1
+ from functools import reduce
2
+ import json
3
+ import os
4
+ from typing import NotRequired, TypedDict
5
+ from django.db import connections
6
+
7
+
8
+ class DatabaseConfigData(TypedDict):
9
+ host: str
10
+ name: str
11
+ user: str
12
+ password: str
13
+ port: NotRequired[int]
14
+
15
+
16
+ def get_connection(tenant: str = None):
17
+ """
18
+ Get the connection to the database with the given tenant or the default one.
19
+ Default value will be retrieved from TenantContext.get()
20
+ This uses the connections dict from django.db to get the required connection.
21
+
22
+ The default implementation of this function uses the 'default' alias
23
+ if not argument is given. We want to use the TenantContext class to
24
+ determine the correct alias.
25
+ Args:
26
+ tenant: Name of the tenant database alias
27
+
28
+ Returns: The connection to the database
29
+
30
+ """
31
+ from ..tenant_context import TenantContext
32
+
33
+ database_alias = tenant or TenantContext.get()
34
+ connection = connections[database_alias]
35
+
36
+ return connection
37
+
38
+
39
+ def get_database_configs() -> dict[str, DatabaseConfigData]:
40
+ """
41
+ The env variables prefixed with 'DATABASE_CONFIG' should be provided in the following format:
42
+ {
43
+ "tenant1": {
44
+ "host": "",
45
+ "name": "",
46
+ "user": "",
47
+ "password": "",
48
+ "port": 5432 / null
49
+ },
50
+ "tenant2": {
51
+ "host": "",
52
+ "name": "",
53
+ "user": "",
54
+ "password": ""
55
+ }
56
+ }
57
+ If multiple 'DATABASE_CONFIG'-prefixed variables are set, they will be merged into a single dictionary.
58
+ """
59
+ configs = [json.loads(v or "{}") for k, v in os.environ.items() if k.startswith("DATABASE_CONFIG")]
60
+ database_configs = reduce(lambda a, b: {**a, **b}, configs, {})
61
+ for tenant, db_config in database_configs.items():
62
+ for key in ["host", "name", "user", "password"]:
63
+ if key not in db_config:
64
+ raise ValueError(f"Missing required database config '{key}' for tenant {tenant}")
65
+
66
+ return database_configs
@@ -0,0 +1,25 @@
1
+ from django.core.management.commands.migrate import Command as BaseMigrateCommand
2
+ from django.db.models.signals import post_migrate, pre_migrate
3
+ from django.dispatch import receiver
4
+
5
+ from ...settings import TENANT_DATABASES
6
+ from ...tenant_context import TenantContext
7
+
8
+
9
+ @receiver(pre_migrate)
10
+ def set_tenant_pre_migrate(sender, **kwargs):
11
+ database_alias = kwargs.get("using")
12
+ TenantContext.set(database_alias)
13
+
14
+
15
+ @receiver(post_migrate)
16
+ def clear_tenant_post_migrate(sender, **kwargs):
17
+ TenantContext.clear()
18
+
19
+
20
+ class Command(BaseMigrateCommand):
21
+ def handle(self, *args, **options):
22
+ for tenant in TENANT_DATABASES:
23
+ self.stdout.write(f"Migrating tenant: {tenant}")
24
+ options["database"] = tenant
25
+ super().handle(*args, **options)
@@ -0,0 +1,74 @@
1
+ from abc import abstractmethod
2
+ from django.core.management import BaseCommand
3
+
4
+ from ...settings import TENANT_DATABASES
5
+ from ...tenant_context import TenantContext
6
+
7
+
8
+ class TenantAwareCommand(BaseCommand):
9
+ """
10
+ Base class for all tenant aware commands.
11
+ The usage is like this:
12
+
13
+ class MyCommand(TenantAwareCommand):
14
+ def add_arguments(self, parser):
15
+ super().add_arguments(parser)
16
+ # add your own arguments here
17
+
18
+ def execute_command(self, *args, **options):
19
+ # logic to execute for each tenant
20
+ """
21
+
22
+ def add_arguments(self, parser):
23
+ """
24
+ Add the tenant argument to the command. The tenant argument is
25
+ required in a multi tenant environment.
26
+ The tenant value can either be a single tenant name or 'all' to run
27
+ the command for all tenants.
28
+ """
29
+ parser.add_argument(
30
+ "--tenant",
31
+ action="store",
32
+ dest="tenant",
33
+ help="Specify the tenant to run the command for, either a single tenant name or 'all'.",
34
+ required=True,
35
+ type=str,
36
+ )
37
+
38
+ def handle(self, *args, **options):
39
+ """
40
+ Get the tenant name from the command line arguments and set it as
41
+ the current tenant using TenantContext.set().
42
+ """
43
+ tenant = options["tenant"]
44
+ if tenant is None:
45
+ self.stdout.write(
46
+ self.style.ERROR(
47
+ "Please provide --tenant in order to run this command, either a single tenant name or 'all'!"
48
+ )
49
+ )
50
+ exit()
51
+
52
+ if tenant.lower() == "all":
53
+ tenants_to_use = TENANT_DATABASES
54
+ else:
55
+ if tenant not in TENANT_DATABASES:
56
+ self.stdout.write(self.style.ERROR(f"Tenant '{tenant}' not found in DATABASES settings."))
57
+ exit()
58
+
59
+ tenants_to_use = [tenant]
60
+
61
+ for tenant in tenants_to_use:
62
+ self.stdout.write(f"Executing command for tenant: {tenant}")
63
+ options["database"] = tenant
64
+ with TenantContext(tenant):
65
+ self.execute_command(*args, **options)
66
+
67
+ @abstractmethod
68
+ def execute_command(self, *args, **options):
69
+ """
70
+ Abstract method to be implemented by the child class.
71
+ This method will be called by the handle() method after
72
+ setting the tenant context.
73
+ """
74
+ pass
@@ -0,0 +1,6 @@
1
+ from .tenant_http_middleware import TenantAwareHttpMiddleware
2
+
3
+
4
+ __all__ = [
5
+ "TenantAwareHttpMiddleware",
6
+ ]
@@ -0,0 +1,120 @@
1
+ import logging
2
+ from typing import Callable, Optional
3
+
4
+ from django.conf import settings
5
+ from django.core.handlers.wsgi import WSGIRequest
6
+ from django.http import HttpResponse
7
+
8
+ from ..settings import DEVELOPMENT_TENANT, TENANT_AWARE_EXCLUDED_PATHS, TENANT_DATABASES
9
+ from ..tenant_context import TenantContext
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class TenantAwareHttpMiddleware:
15
+ """
16
+ Middleware that sets the thread local variable `tenant`.
17
+ This middleware must be placed before any other middleware that
18
+ executes queries on the database.
19
+ In this way, the database alias is set before any other middleware is
20
+ called and it is removed after all of them are called.
21
+
22
+ Tenant detection:
23
+ - Production (DEBUG=False): Subdomain pattern <app>.<tenant>.domain.com
24
+ - Development (DEBUG=True): Uses DEVELOPMENT_TENANT setting directly
25
+
26
+ The subdomain pattern expects: <app>.<tenant>.<domain>
27
+ where <app> can be any value (e.g., app, portal, dashboard)
28
+ and <tenant> can contain alphanumeric characters and hyphens.
29
+ The -internal suffix is stripped from tenant names.
30
+ """
31
+
32
+ def __init__(self, get_response: Callable):
33
+ """
34
+ Middleware initialization. Only called once per Django application initialization.
35
+ Args:
36
+ get_response: Callable to get the response of the view.
37
+ """
38
+ self.get_response = get_response
39
+
40
+ def __call__(self, request: WSGIRequest) -> HttpResponse:
41
+ """
42
+ Called by Django for each http request to process it and return a response.
43
+ Everything that should be done before the view is called is done before
44
+ the get_response method is called and everything that should be done
45
+ after the view is called is done after the get_response method is called.
46
+ Args:
47
+ request: Django request object.
48
+
49
+ Returns: HttpResponse object.
50
+ """
51
+ if self._is_excluded_path(request.path):
52
+ return self.get_response(request)
53
+
54
+ if TenantContext.is_set():
55
+ raise Exception("Tenant context already set")
56
+
57
+ # In DEBUG mode, use DEVELOPMENT_TENANT directly
58
+ # In production, extract tenant from subdomain
59
+ if settings.DEBUG:
60
+ tenant = DEVELOPMENT_TENANT
61
+ logger.debug(f"Using development tenant: {tenant}")
62
+ else:
63
+ tenant = self._get_tenant_from_subdomain(request)
64
+ if tenant is None:
65
+ raise Exception(f"Could not determine tenant from subdomain. Host: {request.get_host()}")
66
+
67
+ # Validate tenant exists in configured databases
68
+ if not self._is_valid_tenant(tenant):
69
+ raise Exception(f"Unknown tenant: {tenant}")
70
+
71
+ # Call the next middleware in the chain until the response is returned.
72
+ # After that, the database alias is removed from the thread local variable.
73
+ with TenantContext(tenant):
74
+ response = self.get_response(request)
75
+
76
+ return response
77
+
78
+ def _is_excluded_path(self, path: str) -> bool:
79
+ """
80
+ Check if the path should be excluded from tenant handling.
81
+ """
82
+ for excluded_path in TENANT_AWARE_EXCLUDED_PATHS:
83
+ if path.startswith(excluded_path):
84
+ return True
85
+
86
+ return False
87
+
88
+ def _get_tenant_from_subdomain(self, request: WSGIRequest) -> Optional[str]:
89
+ """
90
+ Extract tenant from subdomain.
91
+
92
+ Expected formats:
93
+ - <app>.tenant.domain.com -> tenant
94
+ - <app>.tenant-internal.domain.com -> tenant (strips -internal suffix)
95
+ """
96
+ host = request.get_host().split(":")[0] # Remove port if present
97
+ parts = host.split(".")
98
+
99
+ # Need at least 3 parts: <app>.<tenant>.<domain>
100
+ if len(parts) >= 3:
101
+ tenant = self._normalize_tenant(parts[1])
102
+ logger.debug(f"Tenant '{tenant}' extracted from subdomain: {host}")
103
+ return tenant
104
+
105
+ return None
106
+
107
+ @staticmethod
108
+ def _normalize_tenant(tenant: str) -> str:
109
+ # Remove -internal suffix if present
110
+ if tenant.endswith("-internal"):
111
+ return tenant[: -len("-internal")]
112
+ return tenant
113
+
114
+ @staticmethod
115
+ def _is_valid_tenant(tenant: str) -> bool:
116
+ """
117
+ Validate that the tenant exists in configured databases.
118
+ Skip 'default' as it's typically a placeholder.
119
+ """
120
+ return tenant in TENANT_DATABASES
@@ -0,0 +1,4 @@
1
+ from .key_function import make_key
2
+
3
+
4
+ __all__ = ["make_key"]
@@ -0,0 +1,5 @@
1
+ from ..tenant_context import TenantContext
2
+
3
+
4
+ def make_key(key, key_prefix, version):
5
+ return ":".join([TenantContext.get(), key_prefix, str(version), key])
@@ -0,0 +1,17 @@
1
+ from django.conf import settings
2
+
3
+ TENANT_DATABASES = set(settings.DATABASES.keys()) - {"default"}
4
+ TENANT_KEY = "tenant"
5
+
6
+ TENANT_AWARE_EXCLUDED_PATHS = getattr(settings, "TENANT_AWARE_EXCLUDED_PATHS", ())
7
+ TENANT_AWARE_EXCLUDED_PATHS = (
8
+ *TENANT_AWARE_EXCLUDED_PATHS,
9
+ "/health",
10
+ "/healthz",
11
+ "/static",
12
+ "/staticfiles",
13
+ "/media",
14
+ "/mediafiles",
15
+ )
16
+
17
+ DEVELOPMENT_TENANT = getattr(settings, "DEVELOPMENT_TENANT", list(TENANT_DATABASES)[0])
@@ -0,0 +1,6 @@
1
+ from .tenant_aware_storage import TenantAwareS3Storage, TenantAwarePrivateS3Storage
2
+
3
+ __all__ = [
4
+ "TenantAwareS3Storage",
5
+ "TenantAwarePrivateS3Storage",
6
+ ]
@@ -0,0 +1,86 @@
1
+ import json
2
+ import os
3
+ from typing import Optional
4
+
5
+ from django.conf import settings
6
+ from storages.backends.s3boto3 import S3Boto3Storage
7
+
8
+ from ..tenant_context import TenantContext
9
+
10
+
11
+ def get_tenant_bucket_names() -> dict:
12
+ """
13
+ Retrieve the tenant bucket names from the environment variable.
14
+ The environment variable AWS_STORAGE_TENANT_BUCKET_NAMES should be a JSON
15
+ dictionary where each key is the tenant name and the value is the bucket name.
16
+
17
+ Returns:
18
+ A dictionary mapping tenant names to their S3 bucket names.
19
+ """
20
+ bucket_names_raw = os.getenv("AWS_STORAGE_TENANT_BUCKET_NAMES", "{}")
21
+ try:
22
+ return json.loads(bucket_names_raw)
23
+ except json.JSONDecodeError:
24
+ return {}
25
+
26
+
27
+ def get_bucket_name_for_tenant(tenant: Optional[str] = None) -> str:
28
+ """
29
+ Get the S3 bucket name for a specific tenant.
30
+
31
+ Args:
32
+ tenant: The tenant name. If None, uses the current tenant from context.
33
+
34
+ Returns:
35
+ The S3 bucket name for the tenant.
36
+
37
+ Raises:
38
+ ValueError: If no bucket is configured for the tenant.
39
+ """
40
+ if tenant is None:
41
+ tenant = TenantContext.get()
42
+
43
+ tenant_buckets = get_tenant_bucket_names()
44
+
45
+ if tenant not in tenant_buckets:
46
+ raise ValueError(
47
+ f"No S3 bucket configured for tenant '{tenant}'. "
48
+ f"Please set AWS_STORAGE_TENANT_BUCKET_NAMES environment variable."
49
+ )
50
+
51
+ return tenant_buckets[tenant]
52
+
53
+
54
+ class TenantAwareS3Storage(S3Boto3Storage):
55
+ """
56
+ A tenant-aware S3 storage backend that dynamically selects the bucket
57
+ based on the current tenant context.
58
+ """
59
+
60
+ def __init__(self, *args, **kwargs):
61
+ # Don't set bucket_name here; it will be resolved dynamically
62
+ super().__init__(*args, **kwargs)
63
+
64
+ @property
65
+ def bucket_name(self):
66
+ """Dynamically resolve the bucket name based on current tenant."""
67
+ try:
68
+ return get_bucket_name_for_tenant()
69
+ except (RuntimeError, ValueError):
70
+ # Fallback to default bucket if tenant context is not set
71
+ return getattr(settings, "AWS_STORAGE_BUCKET_NAME", None)
72
+
73
+ @bucket_name.setter
74
+ def bucket_name(self, value):
75
+ # Allow setting bucket_name but it will be overridden by the property getter
76
+ self._bucket_name_override = value
77
+
78
+
79
+ class TenantAwarePrivateS3Storage(TenantAwareS3Storage):
80
+ """
81
+ Tenant-aware private media storage with file overwrite disabled.
82
+ """
83
+
84
+ location = "private"
85
+ file_overwrite = False
86
+ custom_domain = False
@@ -0,0 +1,89 @@
1
+ import logging
2
+ import sys
3
+ from contextlib import ContextDecorator
4
+ from threading import local
5
+ from typing import Optional
6
+
7
+ from .settings import TENANT_DATABASES
8
+
9
+ logger = logging.getLogger(__name__)
10
+ thread_namespace = local()
11
+
12
+
13
+ class TenantContext(ContextDecorator):
14
+ """
15
+ Context manager that sets the current tenant.
16
+ This class can be used in several ways:
17
+ 1. As a context manager:
18
+ with TenantContext('tenant'):
19
+ # do something
20
+
21
+ 2. As a decorator:
22
+ @TenantContext('tenant')
23
+ def some_function():
24
+ # do something
25
+
26
+ 3. Directly using static methods:
27
+ TenantContext.set('tenant')
28
+ # do something
29
+ TenantContext.clear()
30
+ """
31
+
32
+ field_name = "tenant"
33
+
34
+ def __init__(self, tenant: str):
35
+ self.tenant = tenant
36
+
37
+ def __enter__(self):
38
+ TenantContext.set(self.tenant)
39
+
40
+ def __exit__(self, exc_type, exc_val, exc_tb):
41
+ TenantContext.clear()
42
+
43
+ @staticmethod
44
+ def is_set() -> bool:
45
+ return hasattr(thread_namespace, TenantContext.field_name)
46
+
47
+ @staticmethod
48
+ def get() -> Optional[str]:
49
+ try:
50
+ return getattr(thread_namespace, TenantContext.field_name)
51
+ except AttributeError as e:
52
+ raise RuntimeError("Tenant context is not set.") from e
53
+
54
+ @staticmethod
55
+ def set(tenant):
56
+ if TenantContext.is_set():
57
+ # If the tenant is already set, we do not allow to set it
58
+ # again unless it is the same value.
59
+ # This is to prevent the tenant to be set to a different
60
+ # value in the same thread.
61
+ # A request-response cycle is required to set the tenant
62
+ # only once and operate on the same tenant in its lifecycle.
63
+ # The same applies to a single task, it is not allowed to set the
64
+ # tenant in the same task to a different value.
65
+ if TenantContext.get() != tenant:
66
+ logger.error("ERROR: TENANT CONTEXT ALREADY SET")
67
+ logger.error(f"Current tenant: {TenantContext.get()}, new tenant: {tenant}")
68
+ return sys.exit(1)
69
+
70
+ else:
71
+ # Normally in a request-response cycle, or a single task, the
72
+ # tenant context is set only once. But there is an exception
73
+ # for the migrate command, which sets the tenant context
74
+ # multiple times to the same value.
75
+ logger.info("Tenant context already set to %s", tenant)
76
+ return
77
+
78
+ if tenant not in TENANT_DATABASES:
79
+ logger.error(f"Tenant '{tenant}' not found in DATABASES settings.")
80
+ return sys.exit(1)
81
+
82
+ setattr(thread_namespace, TenantContext.field_name, tenant)
83
+ logger.info(f"Tenant context set to {tenant}")
84
+
85
+ @staticmethod
86
+ def clear():
87
+ if TenantContext.is_set():
88
+ delattr(thread_namespace, TenantContext.field_name)
89
+ logger.info("Tenant context cleared")