django-multitenant-middleware 0.1.0__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.
- django_multitenant_middleware-0.1.0/LICENSE +21 -0
- django_multitenant_middleware-0.1.0/MANIFEST.in +3 -0
- django_multitenant_middleware-0.1.0/PKG-INFO +174 -0
- django_multitenant_middleware-0.1.0/README.md +133 -0
- django_multitenant_middleware-0.1.0/django-multitenant-middleware/__init__.py +29 -0
- django_multitenant_middleware-0.1.0/django-multitenant-middleware/exceptions.py +4 -0
- django_multitenant_middleware-0.1.0/django-multitenant-middleware/http_middleware.py +93 -0
- django_multitenant_middleware-0.1.0/django-multitenant-middleware/resolvers.py +143 -0
- django_multitenant_middleware-0.1.0/django-multitenant-middleware/tenant_fetcher.py +29 -0
- django_multitenant_middleware-0.1.0/django-multitenant-middleware/ws_middleware.py +42 -0
- django_multitenant_middleware-0.1.0/django_multitenant_middleware.egg-info/PKG-INFO +174 -0
- django_multitenant_middleware-0.1.0/django_multitenant_middleware.egg-info/SOURCES.txt +16 -0
- django_multitenant_middleware-0.1.0/django_multitenant_middleware.egg-info/dependency_links.txt +1 -0
- django_multitenant_middleware-0.1.0/django_multitenant_middleware.egg-info/requires.txt +2 -0
- django_multitenant_middleware-0.1.0/django_multitenant_middleware.egg-info/top_level.txt +1 -0
- django_multitenant_middleware-0.1.0/pyproject.toml +35 -0
- django_multitenant_middleware-0.1.0/setup.cfg +4 -0
- django_multitenant_middleware-0.1.0/tests/test_middleware.py +8 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vipul Manohar Raut
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-multitenant-middleware
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: HTTP + WebSocket middleware for multi-tenant Django projects
|
|
5
|
+
Author-email: Vipul Manohar Raut <vipulraut38@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Vipul Manohar Raut
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
Project-URL: Homepage, https://github.com/LazyEpul/django-multitenant-middleware
|
|
28
|
+
Keywords: django,multi-tenant,middleware,channels,asgi
|
|
29
|
+
Classifier: Development Status :: 4 - Beta
|
|
30
|
+
Classifier: Framework :: Django
|
|
31
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
32
|
+
Classifier: Programming Language :: Python :: 3
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
34
|
+
Classifier: Operating System :: OS Independent
|
|
35
|
+
Requires-Python: >=3.9
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
License-File: LICENSE
|
|
38
|
+
Requires-Dist: Django>=4.2
|
|
39
|
+
Requires-Dist: django-tenants>=3.4
|
|
40
|
+
Dynamic: license-file
|
|
41
|
+
|
|
42
|
+
# django-multitenant-middleware
|
|
43
|
+
|
|
44
|
+
`django-multitenant-middleware` is a Django package that provides multi-tenant support for both **HTTP** and **WebSocket (Channels)** requests. It resolves tenants dynamically based on subdomains, hostnames, or custom headers.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install django-multitenant-middleware
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Settings Configuration (HTTP)
|
|
57
|
+
|
|
58
|
+
1. Define your tenant model in `settings.py`:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
CHANNELS_MULTITENANT_TENANT_MODEL = "tenants.Client"
|
|
62
|
+
|
|
63
|
+
# Optional: Custom resolver class and args
|
|
64
|
+
CHANNELS_MULTITENANT_RESOLVER_CLASS = None # defaults to FlexibleSubdomainTenantResolver
|
|
65
|
+
CHANNELS_MULTITENANT_RESOLVER_ARGS = {"base_domain": BASE_DOMAIN}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
2. Add the middleware to your Django `MIDDLEWARE` list:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
MIDDLEWARE = [
|
|
72
|
+
# Other middleware...
|
|
73
|
+
"channels_multitenant.http_middleware.TenantHTTPMiddleware",
|
|
74
|
+
]
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
> The middleware automatically sets `request.tenant` and `request.tenant_context`.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## WebSocket Integration (ASGI)
|
|
82
|
+
|
|
83
|
+
1. In `asgi.py`:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
import os
|
|
87
|
+
import django
|
|
88
|
+
from channels.auth import AuthMiddlewareStack
|
|
89
|
+
from channels.routing import ProtocolTypeRouter, URLRouter
|
|
90
|
+
from django.core.asgi import get_asgi_application
|
|
91
|
+
|
|
92
|
+
from channels_multitenant.ws_middleware import TenantWebSocketMiddleware
|
|
93
|
+
from channels_multitenant.resolvers import FlexibleSubdomainTenantResolver
|
|
94
|
+
from tenants.models import Client
|
|
95
|
+
from core.websocket.routing import websocket_urlpatterns
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
2. Setup ASGI:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings")
|
|
102
|
+
django.setup()
|
|
103
|
+
django_asgi_app = get_asgi_application()
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
3. Configure tenant resolver:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
resolver = FlexibleSubdomainTenantResolver(
|
|
110
|
+
base_domain="app.example.com", # your base domain
|
|
111
|
+
separators=("-", ".")
|
|
112
|
+
)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
4. Wrap WebSocket routes:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
application = ProtocolTypeRouter({
|
|
119
|
+
"http": django_asgi_app,
|
|
120
|
+
"websocket": TenantWebSocketMiddleware(
|
|
121
|
+
AuthMiddlewareStack(
|
|
122
|
+
URLRouter(websocket_urlpatterns)
|
|
123
|
+
),
|
|
124
|
+
tenant_model=Client, # your tenant model
|
|
125
|
+
resolver=resolver,
|
|
126
|
+
),
|
|
127
|
+
})
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Tenant Resolvers
|
|
133
|
+
|
|
134
|
+
* **FlexibleSubdomainTenantResolver**: Extracts tenant from subdomain prefixes
|
|
135
|
+
Example: `tenant1-app.example.com → tenant1`
|
|
136
|
+
|
|
137
|
+
* **HeaderTenantResolver**: Extracts tenant from a custom HTTP header
|
|
138
|
+
Example: `X-Tenant-ID: tenant1 → tenant1`
|
|
139
|
+
|
|
140
|
+
* **Custom Resolver**: Subclass `BaseTenantResolver` to implement your logic
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from channels_multitenant.resolvers import BaseTenantResolver
|
|
144
|
+
|
|
145
|
+
class MyCustomResolver(BaseTenantResolver):
|
|
146
|
+
def resolve(self, scope) -> str | None:
|
|
147
|
+
# Implement your custom logic
|
|
148
|
+
return "my_tenant"
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## TenantFetcher (Optional Helper)
|
|
154
|
+
|
|
155
|
+
`TenantFetcher` provides async caching for WebSocket tenants:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
from channels_multitenant.tenant_fetcher import TenantFetcher
|
|
159
|
+
from tenants.models import Client
|
|
160
|
+
|
|
161
|
+
fetcher = TenantFetcher(Client)
|
|
162
|
+
tenant = await fetcher.get_tenant("tenant1")
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
* Caches tenants (default 5 minutes)
|
|
166
|
+
* Async-ready for Channels
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Notes
|
|
171
|
+
|
|
172
|
+
* Ensure `CHANNELS_MULTITENANT_TENANT_MODEL` points to your tenant model.
|
|
173
|
+
* Missing or invalid settings will raise `ImproperlyConfigured`.
|
|
174
|
+
* Compatible with Django 4.x+ and Channels 3.x+.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# django-multitenant-middleware
|
|
2
|
+
|
|
3
|
+
`django-multitenant-middleware` is a Django package that provides multi-tenant support for both **HTTP** and **WebSocket (Channels)** requests. It resolves tenants dynamically based on subdomains, hostnames, or custom headers.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install django-multitenant-middleware
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Settings Configuration (HTTP)
|
|
16
|
+
|
|
17
|
+
1. Define your tenant model in `settings.py`:
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
CHANNELS_MULTITENANT_TENANT_MODEL = "tenants.Client"
|
|
21
|
+
|
|
22
|
+
# Optional: Custom resolver class and args
|
|
23
|
+
CHANNELS_MULTITENANT_RESOLVER_CLASS = None # defaults to FlexibleSubdomainTenantResolver
|
|
24
|
+
CHANNELS_MULTITENANT_RESOLVER_ARGS = {"base_domain": BASE_DOMAIN}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
2. Add the middleware to your Django `MIDDLEWARE` list:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
MIDDLEWARE = [
|
|
31
|
+
# Other middleware...
|
|
32
|
+
"channels_multitenant.http_middleware.TenantHTTPMiddleware",
|
|
33
|
+
]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
> The middleware automatically sets `request.tenant` and `request.tenant_context`.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## WebSocket Integration (ASGI)
|
|
41
|
+
|
|
42
|
+
1. In `asgi.py`:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import os
|
|
46
|
+
import django
|
|
47
|
+
from channels.auth import AuthMiddlewareStack
|
|
48
|
+
from channels.routing import ProtocolTypeRouter, URLRouter
|
|
49
|
+
from django.core.asgi import get_asgi_application
|
|
50
|
+
|
|
51
|
+
from channels_multitenant.ws_middleware import TenantWebSocketMiddleware
|
|
52
|
+
from channels_multitenant.resolvers import FlexibleSubdomainTenantResolver
|
|
53
|
+
from tenants.models import Client
|
|
54
|
+
from core.websocket.routing import websocket_urlpatterns
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
2. Setup ASGI:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings")
|
|
61
|
+
django.setup()
|
|
62
|
+
django_asgi_app = get_asgi_application()
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
3. Configure tenant resolver:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
resolver = FlexibleSubdomainTenantResolver(
|
|
69
|
+
base_domain="app.example.com", # your base domain
|
|
70
|
+
separators=("-", ".")
|
|
71
|
+
)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
4. Wrap WebSocket routes:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
application = ProtocolTypeRouter({
|
|
78
|
+
"http": django_asgi_app,
|
|
79
|
+
"websocket": TenantWebSocketMiddleware(
|
|
80
|
+
AuthMiddlewareStack(
|
|
81
|
+
URLRouter(websocket_urlpatterns)
|
|
82
|
+
),
|
|
83
|
+
tenant_model=Client, # your tenant model
|
|
84
|
+
resolver=resolver,
|
|
85
|
+
),
|
|
86
|
+
})
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Tenant Resolvers
|
|
92
|
+
|
|
93
|
+
* **FlexibleSubdomainTenantResolver**: Extracts tenant from subdomain prefixes
|
|
94
|
+
Example: `tenant1-app.example.com → tenant1`
|
|
95
|
+
|
|
96
|
+
* **HeaderTenantResolver**: Extracts tenant from a custom HTTP header
|
|
97
|
+
Example: `X-Tenant-ID: tenant1 → tenant1`
|
|
98
|
+
|
|
99
|
+
* **Custom Resolver**: Subclass `BaseTenantResolver` to implement your logic
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from channels_multitenant.resolvers import BaseTenantResolver
|
|
103
|
+
|
|
104
|
+
class MyCustomResolver(BaseTenantResolver):
|
|
105
|
+
def resolve(self, scope) -> str | None:
|
|
106
|
+
# Implement your custom logic
|
|
107
|
+
return "my_tenant"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## TenantFetcher (Optional Helper)
|
|
113
|
+
|
|
114
|
+
`TenantFetcher` provides async caching for WebSocket tenants:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from channels_multitenant.tenant_fetcher import TenantFetcher
|
|
118
|
+
from tenants.models import Client
|
|
119
|
+
|
|
120
|
+
fetcher = TenantFetcher(Client)
|
|
121
|
+
tenant = await fetcher.get_tenant("tenant1")
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
* Caches tenants (default 5 minutes)
|
|
125
|
+
* Async-ready for Channels
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Notes
|
|
130
|
+
|
|
131
|
+
* Ensure `CHANNELS_MULTITENANT_TENANT_MODEL` points to your tenant model.
|
|
132
|
+
* Missing or invalid settings will raise `ImproperlyConfigured`.
|
|
133
|
+
* Compatible with Django 4.x+ and Channels 3.x+.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from channels_multitenant.http_middleware import TenantHTTPMiddleware
|
|
2
|
+
from channels_multitenant.resolvers import (
|
|
3
|
+
FlexibleSubdomainTenantResolver,
|
|
4
|
+
HeaderTenantResolver,
|
|
5
|
+
)
|
|
6
|
+
from channels_multitenant.ws_middleware import TenantWebSocketMiddleware
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"TenantWebSocketMiddleware",
|
|
10
|
+
"FlexibleSubdomainTenantResolver",
|
|
11
|
+
"HeaderTenantResolver",
|
|
12
|
+
"TenantHTTPMiddleware",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_http_middleware(
|
|
19
|
+
get_response, tenant_model=None, resolver=None, tenant_setter=None
|
|
20
|
+
):
|
|
21
|
+
"""
|
|
22
|
+
Easy integration: call this from settings.py
|
|
23
|
+
"""
|
|
24
|
+
return TenantHTTPMiddleware(
|
|
25
|
+
get_response=get_response,
|
|
26
|
+
tenant_model=tenant_model,
|
|
27
|
+
resolver=resolver,
|
|
28
|
+
tenant_setter=tenant_setter,
|
|
29
|
+
)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from django.apps import apps
|
|
2
|
+
from django.conf import settings
|
|
3
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
4
|
+
from django.utils.deprecation import MiddlewareMixin
|
|
5
|
+
from django_tenants.utils import get_public_schema_name, tenant_context
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TenantHTTPMiddleware(MiddlewareMixin):
|
|
9
|
+
"""
|
|
10
|
+
Middleware for resolving tenant in standard Django HTTP requests.
|
|
11
|
+
Lazily sets tenant or public tenant.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, get_response=None):
|
|
15
|
+
super().__init__(get_response)
|
|
16
|
+
self.get_response = get_response
|
|
17
|
+
self._tenant_model = None
|
|
18
|
+
self._resolver = None
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def tenant_model(self):
|
|
22
|
+
if self._tenant_model is None:
|
|
23
|
+
# Try to get the model path from settings
|
|
24
|
+
model_path = getattr(settings, "CHANNELS_MULTITENANT_TENANT_MODEL", None)
|
|
25
|
+
if model_path is None:
|
|
26
|
+
raise ImproperlyConfigured(
|
|
27
|
+
"You must define CHANNELS_MULTITENANT_TENANT_MODEL in your Django settings!"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# If model_path is a string like "app_label.ModelName"
|
|
31
|
+
if isinstance(model_path, str):
|
|
32
|
+
try:
|
|
33
|
+
app_label, model_name = model_path.split(".")
|
|
34
|
+
self._tenant_model = apps.get_model(app_label, model_name)
|
|
35
|
+
except (ValueError, LookupError) as e:
|
|
36
|
+
raise ImproperlyConfigured(
|
|
37
|
+
f"Invalid CHANNELS_MULTITENANT_TENANT_MODEL setting: {model_path}. Error: {e}"
|
|
38
|
+
)
|
|
39
|
+
else:
|
|
40
|
+
# If someone passed the actual model class instead of string
|
|
41
|
+
self._tenant_model = model_path
|
|
42
|
+
|
|
43
|
+
return self._tenant_model
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def resolver(self):
|
|
47
|
+
if self._resolver is None:
|
|
48
|
+
resolver_class = getattr(
|
|
49
|
+
settings, "CHANNELS_MULTITENANT_RESOLVER_CLASS", None
|
|
50
|
+
)
|
|
51
|
+
resolver_args = getattr(settings, "CHANNELS_MULTITENANT_RESOLVER_ARGS", {})
|
|
52
|
+
if resolver_class is None:
|
|
53
|
+
from .resolvers import FlexibleSubdomainTenantResolver
|
|
54
|
+
|
|
55
|
+
resolver_class = FlexibleSubdomainTenantResolver
|
|
56
|
+
resolver_args.setdefault(
|
|
57
|
+
"base_domain", getattr(settings, "BASE_DOMAIN", "example.com")
|
|
58
|
+
)
|
|
59
|
+
self._resolver = resolver_class(**resolver_args)
|
|
60
|
+
return self._resolver
|
|
61
|
+
|
|
62
|
+
def process_request(self, request):
|
|
63
|
+
host = request.get_host().split(":")[0]
|
|
64
|
+
base_domain = getattr(settings, "BASE_DOMAIN", "example.com")
|
|
65
|
+
|
|
66
|
+
tenant_name = self.resolver.resolve(host) if self.resolver else None
|
|
67
|
+
tenant = None
|
|
68
|
+
|
|
69
|
+
if tenant_name:
|
|
70
|
+
try:
|
|
71
|
+
tenant = self.tenant_model.objects.get(schema_name=tenant_name)
|
|
72
|
+
except self.tenant_model.DoesNotExist:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
# If host is exactly the base domain, use public tenant object
|
|
76
|
+
if host == base_domain:
|
|
77
|
+
tenant = self.tenant_model.objects.get(schema_name=get_public_schema_name())
|
|
78
|
+
|
|
79
|
+
# Fallback to public schema
|
|
80
|
+
if tenant:
|
|
81
|
+
request.tenant = tenant
|
|
82
|
+
request.tenant_context = tenant_context(tenant)
|
|
83
|
+
request.tenant_context.__enter__()
|
|
84
|
+
else:
|
|
85
|
+
# Should rarely happen, but just in case
|
|
86
|
+
request.tenant = None
|
|
87
|
+
request.tenant_context = tenant_context(get_public_schema_name())
|
|
88
|
+
request.tenant_context.__enter__()
|
|
89
|
+
|
|
90
|
+
def process_response(self, request, response):
|
|
91
|
+
if hasattr(request, "tenant_context"):
|
|
92
|
+
request.tenant_context.__exit__(None, None, None)
|
|
93
|
+
return response
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
class BaseTenantResolver:
|
|
2
|
+
"""
|
|
3
|
+
Base class for tenant resolvers.
|
|
4
|
+
|
|
5
|
+
A tenant resolver extracts a tenant identifier from an incoming request.
|
|
6
|
+
The identifier can be derived from different sources such as the host
|
|
7
|
+
header, specific request headers, or query parameters.
|
|
8
|
+
|
|
9
|
+
Subclasses must implement the `resolve()` method.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def resolve(self, scope) -> str | None:
|
|
13
|
+
"""
|
|
14
|
+
Extract tenant identifier from the ASGI scope.
|
|
15
|
+
|
|
16
|
+
Parameters
|
|
17
|
+
----------
|
|
18
|
+
scope : dict
|
|
19
|
+
ASGI scope containing request metadata, including headers.
|
|
20
|
+
|
|
21
|
+
Returns
|
|
22
|
+
-------
|
|
23
|
+
str | None
|
|
24
|
+
Resolved tenant identifier, or None if the tenant
|
|
25
|
+
cannot be determined.
|
|
26
|
+
"""
|
|
27
|
+
raise NotImplementedError("Subclasses must implement `resolve()`.")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FlexibleSubdomainTenantResolver(BaseTenantResolver):
|
|
31
|
+
"""
|
|
32
|
+
Resolve tenant from host supporting multiple separator formats.
|
|
33
|
+
|
|
34
|
+
This resolver extracts the tenant identifier from the beginning
|
|
35
|
+
of the host name while supporting multiple separator styles.
|
|
36
|
+
|
|
37
|
+
Supported host formats
|
|
38
|
+
---------------------
|
|
39
|
+
Dash separated:
|
|
40
|
+
tenant1-app.example.com → tenant1
|
|
41
|
+
|
|
42
|
+
Dot separated:
|
|
43
|
+
tenant1.app.example.com → tenant1
|
|
44
|
+
|
|
45
|
+
Base domain only:
|
|
46
|
+
app.example.com → None
|
|
47
|
+
|
|
48
|
+
Notes
|
|
49
|
+
-----
|
|
50
|
+
- Any port in the host will be automatically removed.
|
|
51
|
+
- The host must end with the configured `base_domain`.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, base_domain: str, separators: tuple[str, ...] = ("-", ".")):
|
|
55
|
+
self.base_domain = base_domain.lower()
|
|
56
|
+
self.separators = separators
|
|
57
|
+
|
|
58
|
+
def resolve(self, scope) -> str | None:
|
|
59
|
+
"""
|
|
60
|
+
Extract tenant identifier from the host header.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
scope : dict or str
|
|
65
|
+
ASGI scope containing request headers or a host string.
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
-------
|
|
69
|
+
str | None
|
|
70
|
+
Tenant identifier if found, otherwise None.
|
|
71
|
+
"""
|
|
72
|
+
# Support plain host string for backward compatibility
|
|
73
|
+
if isinstance(scope, str):
|
|
74
|
+
host = scope.split(":")[0].lower()
|
|
75
|
+
elif isinstance(scope, dict):
|
|
76
|
+
headers = dict(scope.get("headers", []))
|
|
77
|
+
host_header = headers.get(b"host")
|
|
78
|
+
if not host_header:
|
|
79
|
+
return None
|
|
80
|
+
host = host_header.decode().split(":")[0].lower()
|
|
81
|
+
else:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
if not host.endswith(self.base_domain):
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
prefix = host[: -len(self.base_domain)].rstrip(".")
|
|
88
|
+
if not prefix:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
for sep in self.separators:
|
|
92
|
+
if sep in prefix:
|
|
93
|
+
return prefix.split(sep)[0]
|
|
94
|
+
|
|
95
|
+
return prefix
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class HeaderTenantResolver(BaseTenantResolver):
|
|
99
|
+
"""
|
|
100
|
+
Resolve tenant using a request header.
|
|
101
|
+
|
|
102
|
+
This resolver extracts the tenant identifier from a specific
|
|
103
|
+
HTTP header in the request.
|
|
104
|
+
|
|
105
|
+
Example
|
|
106
|
+
-------
|
|
107
|
+
Request header:
|
|
108
|
+
X-Tenant-ID: tenant1
|
|
109
|
+
|
|
110
|
+
Result:
|
|
111
|
+
tenant1
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(self, header_name: str = "x-tenant-id"):
|
|
115
|
+
self.header_name = header_name.lower().encode()
|
|
116
|
+
|
|
117
|
+
def resolve(self, scope) -> str | None:
|
|
118
|
+
"""
|
|
119
|
+
Extract tenant identifier from request headers.
|
|
120
|
+
|
|
121
|
+
Parameters
|
|
122
|
+
----------
|
|
123
|
+
scope : dict or list of tuples
|
|
124
|
+
ASGI scope containing request headers or a list of headers.
|
|
125
|
+
|
|
126
|
+
Returns
|
|
127
|
+
-------
|
|
128
|
+
str | None
|
|
129
|
+
Tenant identifier if header is present, otherwise None.
|
|
130
|
+
"""
|
|
131
|
+
headers_dict = {}
|
|
132
|
+
|
|
133
|
+
if isinstance(scope, dict):
|
|
134
|
+
headers_dict = dict(scope.get("headers", []))
|
|
135
|
+
elif isinstance(scope, (list, tuple)):
|
|
136
|
+
headers_dict = dict(scope)
|
|
137
|
+
else:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
tenant_header = headers_dict.get(self.header_name)
|
|
141
|
+
if not tenant_header:
|
|
142
|
+
return None
|
|
143
|
+
return tenant_header.decode().strip()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from channels.db import database_sync_to_async
|
|
2
|
+
from django.core.cache import cache
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TenantFetcher:
|
|
6
|
+
"""
|
|
7
|
+
Async helper to fetch tenant objects with caching for WebSocket requests.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, tenant_model, cache_timeout=300):
|
|
11
|
+
self.tenant_model = tenant_model
|
|
12
|
+
self.cache_timeout = cache_timeout
|
|
13
|
+
|
|
14
|
+
@database_sync_to_async
|
|
15
|
+
def get_tenant(self, tenant_name):
|
|
16
|
+
"""
|
|
17
|
+
Get tenant by schema name, using cache to reduce DB hits.
|
|
18
|
+
"""
|
|
19
|
+
cache_key = f"ws_tenant_{tenant_name}"
|
|
20
|
+
tenant = cache.get(cache_key)
|
|
21
|
+
if tenant:
|
|
22
|
+
return tenant
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
tenant = self.tenant_model.objects.get(schema_name=tenant_name)
|
|
26
|
+
cache.set(cache_key, tenant, self.cache_timeout)
|
|
27
|
+
return tenant
|
|
28
|
+
except self.tenant_model.DoesNotExist:
|
|
29
|
+
return None
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from channels.db import database_sync_to_async
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TenantWebSocketMiddleware:
|
|
5
|
+
def __init__(
|
|
6
|
+
self,
|
|
7
|
+
app,
|
|
8
|
+
tenant_model,
|
|
9
|
+
resolver,
|
|
10
|
+
tenant_setter=None,
|
|
11
|
+
):
|
|
12
|
+
self.app = app
|
|
13
|
+
self.tenant_model = tenant_model
|
|
14
|
+
self.resolver = resolver
|
|
15
|
+
self.tenant_setter = tenant_setter
|
|
16
|
+
|
|
17
|
+
async def __call__(self, scope, receive, send):
|
|
18
|
+
|
|
19
|
+
headers = dict(scope["headers"])
|
|
20
|
+
host = headers.get(b"host", b"").decode()
|
|
21
|
+
|
|
22
|
+
tenant_name = self.resolver.resolve(host)
|
|
23
|
+
|
|
24
|
+
tenant = None
|
|
25
|
+
|
|
26
|
+
if tenant_name:
|
|
27
|
+
tenant = await self.get_tenant(tenant_name)
|
|
28
|
+
|
|
29
|
+
if tenant:
|
|
30
|
+
scope["tenant"] = tenant
|
|
31
|
+
|
|
32
|
+
if self.tenant_setter:
|
|
33
|
+
await self.tenant_setter(tenant)
|
|
34
|
+
|
|
35
|
+
return await self.app(scope, receive, send)
|
|
36
|
+
|
|
37
|
+
@database_sync_to_async
|
|
38
|
+
def get_tenant(self, tenant_name):
|
|
39
|
+
try:
|
|
40
|
+
return self.tenant_model.objects.get(schema_name=tenant_name)
|
|
41
|
+
except self.tenant_model.DoesNotExist:
|
|
42
|
+
return None
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-multitenant-middleware
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: HTTP + WebSocket middleware for multi-tenant Django projects
|
|
5
|
+
Author-email: Vipul Manohar Raut <vipulraut38@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Vipul Manohar Raut
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
Project-URL: Homepage, https://github.com/LazyEpul/django-multitenant-middleware
|
|
28
|
+
Keywords: django,multi-tenant,middleware,channels,asgi
|
|
29
|
+
Classifier: Development Status :: 4 - Beta
|
|
30
|
+
Classifier: Framework :: Django
|
|
31
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
32
|
+
Classifier: Programming Language :: Python :: 3
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
34
|
+
Classifier: Operating System :: OS Independent
|
|
35
|
+
Requires-Python: >=3.9
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
License-File: LICENSE
|
|
38
|
+
Requires-Dist: Django>=4.2
|
|
39
|
+
Requires-Dist: django-tenants>=3.4
|
|
40
|
+
Dynamic: license-file
|
|
41
|
+
|
|
42
|
+
# django-multitenant-middleware
|
|
43
|
+
|
|
44
|
+
`django-multitenant-middleware` is a Django package that provides multi-tenant support for both **HTTP** and **WebSocket (Channels)** requests. It resolves tenants dynamically based on subdomains, hostnames, or custom headers.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install django-multitenant-middleware
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Settings Configuration (HTTP)
|
|
57
|
+
|
|
58
|
+
1. Define your tenant model in `settings.py`:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
CHANNELS_MULTITENANT_TENANT_MODEL = "tenants.Client"
|
|
62
|
+
|
|
63
|
+
# Optional: Custom resolver class and args
|
|
64
|
+
CHANNELS_MULTITENANT_RESOLVER_CLASS = None # defaults to FlexibleSubdomainTenantResolver
|
|
65
|
+
CHANNELS_MULTITENANT_RESOLVER_ARGS = {"base_domain": BASE_DOMAIN}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
2. Add the middleware to your Django `MIDDLEWARE` list:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
MIDDLEWARE = [
|
|
72
|
+
# Other middleware...
|
|
73
|
+
"channels_multitenant.http_middleware.TenantHTTPMiddleware",
|
|
74
|
+
]
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
> The middleware automatically sets `request.tenant` and `request.tenant_context`.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## WebSocket Integration (ASGI)
|
|
82
|
+
|
|
83
|
+
1. In `asgi.py`:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
import os
|
|
87
|
+
import django
|
|
88
|
+
from channels.auth import AuthMiddlewareStack
|
|
89
|
+
from channels.routing import ProtocolTypeRouter, URLRouter
|
|
90
|
+
from django.core.asgi import get_asgi_application
|
|
91
|
+
|
|
92
|
+
from channels_multitenant.ws_middleware import TenantWebSocketMiddleware
|
|
93
|
+
from channels_multitenant.resolvers import FlexibleSubdomainTenantResolver
|
|
94
|
+
from tenants.models import Client
|
|
95
|
+
from core.websocket.routing import websocket_urlpatterns
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
2. Setup ASGI:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings")
|
|
102
|
+
django.setup()
|
|
103
|
+
django_asgi_app = get_asgi_application()
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
3. Configure tenant resolver:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
resolver = FlexibleSubdomainTenantResolver(
|
|
110
|
+
base_domain="app.example.com", # your base domain
|
|
111
|
+
separators=("-", ".")
|
|
112
|
+
)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
4. Wrap WebSocket routes:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
application = ProtocolTypeRouter({
|
|
119
|
+
"http": django_asgi_app,
|
|
120
|
+
"websocket": TenantWebSocketMiddleware(
|
|
121
|
+
AuthMiddlewareStack(
|
|
122
|
+
URLRouter(websocket_urlpatterns)
|
|
123
|
+
),
|
|
124
|
+
tenant_model=Client, # your tenant model
|
|
125
|
+
resolver=resolver,
|
|
126
|
+
),
|
|
127
|
+
})
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Tenant Resolvers
|
|
133
|
+
|
|
134
|
+
* **FlexibleSubdomainTenantResolver**: Extracts tenant from subdomain prefixes
|
|
135
|
+
Example: `tenant1-app.example.com → tenant1`
|
|
136
|
+
|
|
137
|
+
* **HeaderTenantResolver**: Extracts tenant from a custom HTTP header
|
|
138
|
+
Example: `X-Tenant-ID: tenant1 → tenant1`
|
|
139
|
+
|
|
140
|
+
* **Custom Resolver**: Subclass `BaseTenantResolver` to implement your logic
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from channels_multitenant.resolvers import BaseTenantResolver
|
|
144
|
+
|
|
145
|
+
class MyCustomResolver(BaseTenantResolver):
|
|
146
|
+
def resolve(self, scope) -> str | None:
|
|
147
|
+
# Implement your custom logic
|
|
148
|
+
return "my_tenant"
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## TenantFetcher (Optional Helper)
|
|
154
|
+
|
|
155
|
+
`TenantFetcher` provides async caching for WebSocket tenants:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
from channels_multitenant.tenant_fetcher import TenantFetcher
|
|
159
|
+
from tenants.models import Client
|
|
160
|
+
|
|
161
|
+
fetcher = TenantFetcher(Client)
|
|
162
|
+
tenant = await fetcher.get_tenant("tenant1")
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
* Caches tenants (default 5 minutes)
|
|
166
|
+
* Async-ready for Channels
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Notes
|
|
171
|
+
|
|
172
|
+
* Ensure `CHANNELS_MULTITENANT_TENANT_MODEL` points to your tenant model.
|
|
173
|
+
* Missing or invalid settings will raise `ImproperlyConfigured`.
|
|
174
|
+
* Compatible with Django 4.x+ and Channels 3.x+.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
django-multitenant-middleware/__init__.py
|
|
6
|
+
django-multitenant-middleware/exceptions.py
|
|
7
|
+
django-multitenant-middleware/http_middleware.py
|
|
8
|
+
django-multitenant-middleware/resolvers.py
|
|
9
|
+
django-multitenant-middleware/tenant_fetcher.py
|
|
10
|
+
django-multitenant-middleware/ws_middleware.py
|
|
11
|
+
django_multitenant_middleware.egg-info/PKG-INFO
|
|
12
|
+
django_multitenant_middleware.egg-info/SOURCES.txt
|
|
13
|
+
django_multitenant_middleware.egg-info/dependency_links.txt
|
|
14
|
+
django_multitenant_middleware.egg-info/requires.txt
|
|
15
|
+
django_multitenant_middleware.egg-info/top_level.txt
|
|
16
|
+
tests/test_middleware.py
|
django_multitenant_middleware-0.1.0/django_multitenant_middleware.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "django-multitenant-middleware"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "HTTP + WebSocket middleware for multi-tenant Django projects"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Vipul Manohar Raut", email = "vipulraut38@gmail.com" }
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
license = { file = "LICENSE" }
|
|
17
|
+
|
|
18
|
+
keywords = ["django", "multi-tenant", "middleware", "channels", "asgi"]
|
|
19
|
+
|
|
20
|
+
dependencies = [
|
|
21
|
+
"Django>=4.2",
|
|
22
|
+
"django-tenants>=3.4"
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Development Status :: 4 - Beta",
|
|
27
|
+
"Framework :: Django",
|
|
28
|
+
"License :: OSI Approved :: MIT License",
|
|
29
|
+
"Programming Language :: Python :: 3",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Operating System :: OS Independent"
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/LazyEpul/django-multitenant-middleware"
|