cardo-python-utils 0.5.dev29__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.
- cardo_python_utils-0.5.dev31/MANIFEST.in +5 -0
- {cardo_python_utils-0.5.dev29/cardo_python_utils.egg-info → cardo_python_utils-0.5.dev31}/PKG-INFO +1 -1
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31/cardo_python_utils.egg-info}/PKG-INFO +1 -1
- cardo_python_utils-0.5.dev31/cardo_python_utils.egg-info/SOURCES.txt +57 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/pyproject.toml +1 -1
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/admin/user_group.py +1 -1
- cardo_python_utils-0.5.dev31/python_utils/keycloak/django/admin/user_groups_changelist.html +7 -0
- cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/README.md +62 -0
- cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/__init__.py +1 -0
- cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/apps.py +7 -0
- cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/celery/__init__.py +4 -0
- cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/celery/tenant_aware_task.py +31 -0
- cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/db/__init__.py +0 -0
- cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/db/routers.py +11 -0
- cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/db/transaction.py +26 -0
- cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/db/utils.py +66 -0
- cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/management/__init__.py +0 -0
- cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/management/commands/__init__.py +0 -0
- cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/management/commands/migrateall.py +25 -0
- cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/management/commands/tenant_aware_command.py +74 -0
- cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/middleware/__init__.py +6 -0
- cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/middleware/tenant_http_middleware.py +120 -0
- cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/redis/__init__.py +4 -0
- cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/redis/key_function.py +5 -0
- cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/settings.py +17 -0
- cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/storage/__init__.py +6 -0
- cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/storage/tenant_aware_storage.py +86 -0
- cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/tenant_context.py +89 -0
- cardo_python_utils-0.5.dev29/MANIFEST.in +0 -4
- cardo_python_utils-0.5.dev29/cardo_python_utils.egg-info/SOURCES.txt +0 -35
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/LICENSE +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/README.rst +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/cardo_python_utils.egg-info/dependency_links.txt +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/cardo_python_utils.egg-info/requires.txt +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/cardo_python_utils.egg-info/top_level.txt +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/__init__.py +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/choices.py +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/data_structures.py +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/db.py +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/django_utils.py +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/esma_choices.py +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/exceptions.py +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/imports.py +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/__init__.py +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/admin/__init__.py +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/admin/auth.py +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/api/__init__.py +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/api/drf.py +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/api/ninja.py +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/api/utils.py +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/models/__init__.py +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/models/user_group.py +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/service.py +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/tests/__init__.py +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/django/tests/conftest.py +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/utils.py +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/math.py +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/text.py +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/time.py +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/types_hinting.py +0 -0
- {cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/setup.cfg +0 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.rst
|
|
4
|
+
pyproject.toml
|
|
5
|
+
cardo_python_utils.egg-info/PKG-INFO
|
|
6
|
+
cardo_python_utils.egg-info/SOURCES.txt
|
|
7
|
+
cardo_python_utils.egg-info/dependency_links.txt
|
|
8
|
+
cardo_python_utils.egg-info/requires.txt
|
|
9
|
+
cardo_python_utils.egg-info/top_level.txt
|
|
10
|
+
python_utils/__init__.py
|
|
11
|
+
python_utils/choices.py
|
|
12
|
+
python_utils/data_structures.py
|
|
13
|
+
python_utils/db.py
|
|
14
|
+
python_utils/django_utils.py
|
|
15
|
+
python_utils/esma_choices.py
|
|
16
|
+
python_utils/exceptions.py
|
|
17
|
+
python_utils/imports.py
|
|
18
|
+
python_utils/math.py
|
|
19
|
+
python_utils/text.py
|
|
20
|
+
python_utils/time.py
|
|
21
|
+
python_utils/types_hinting.py
|
|
22
|
+
python_utils/keycloak/utils.py
|
|
23
|
+
python_utils/keycloak/django/__init__.py
|
|
24
|
+
python_utils/keycloak/django/service.py
|
|
25
|
+
python_utils/keycloak/django/admin/__init__.py
|
|
26
|
+
python_utils/keycloak/django/admin/auth.py
|
|
27
|
+
python_utils/keycloak/django/admin/user_group.py
|
|
28
|
+
python_utils/keycloak/django/admin/user_groups_changelist.html
|
|
29
|
+
python_utils/keycloak/django/api/__init__.py
|
|
30
|
+
python_utils/keycloak/django/api/drf.py
|
|
31
|
+
python_utils/keycloak/django/api/ninja.py
|
|
32
|
+
python_utils/keycloak/django/api/utils.py
|
|
33
|
+
python_utils/keycloak/django/models/__init__.py
|
|
34
|
+
python_utils/keycloak/django/models/user_group.py
|
|
35
|
+
python_utils/keycloak/django/tests/__init__.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.
|
|
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):
|
|
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,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)
|
|
File without changes
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
@@ -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)
|
cardo_python_utils-0.5.dev31/python_utils/multi_tenancy/management/commands/tenant_aware_command.py
ADDED
|
@@ -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,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,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,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")
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
LICENSE
|
|
2
|
-
MANIFEST.in
|
|
3
|
-
README.rst
|
|
4
|
-
pyproject.toml
|
|
5
|
-
cardo_python_utils.egg-info/PKG-INFO
|
|
6
|
-
cardo_python_utils.egg-info/SOURCES.txt
|
|
7
|
-
cardo_python_utils.egg-info/dependency_links.txt
|
|
8
|
-
cardo_python_utils.egg-info/requires.txt
|
|
9
|
-
cardo_python_utils.egg-info/top_level.txt
|
|
10
|
-
python_utils/__init__.py
|
|
11
|
-
python_utils/choices.py
|
|
12
|
-
python_utils/data_structures.py
|
|
13
|
-
python_utils/db.py
|
|
14
|
-
python_utils/django_utils.py
|
|
15
|
-
python_utils/esma_choices.py
|
|
16
|
-
python_utils/exceptions.py
|
|
17
|
-
python_utils/imports.py
|
|
18
|
-
python_utils/math.py
|
|
19
|
-
python_utils/text.py
|
|
20
|
-
python_utils/time.py
|
|
21
|
-
python_utils/types_hinting.py
|
|
22
|
-
python_utils/keycloak/utils.py
|
|
23
|
-
python_utils/keycloak/django/__init__.py
|
|
24
|
-
python_utils/keycloak/django/service.py
|
|
25
|
-
python_utils/keycloak/django/admin/__init__.py
|
|
26
|
-
python_utils/keycloak/django/admin/auth.py
|
|
27
|
-
python_utils/keycloak/django/admin/user_group.py
|
|
28
|
-
python_utils/keycloak/django/api/__init__.py
|
|
29
|
-
python_utils/keycloak/django/api/drf.py
|
|
30
|
-
python_utils/keycloak/django/api/ninja.py
|
|
31
|
-
python_utils/keycloak/django/api/utils.py
|
|
32
|
-
python_utils/keycloak/django/models/__init__.py
|
|
33
|
-
python_utils/keycloak/django/models/user_group.py
|
|
34
|
-
python_utils/keycloak/django/tests/__init__.py
|
|
35
|
-
python_utils/keycloak/django/tests/conftest.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/data_structures.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cardo_python_utils-0.5.dev29 → cardo_python_utils-0.5.dev31}/python_utils/keycloak/utils.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|