django-cross-tenancy 0.1.0a1__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_cross_tenancy-0.1.0a1/LICENSE +21 -0
- django_cross_tenancy-0.1.0a1/PKG-INFO +208 -0
- django_cross_tenancy-0.1.0a1/README.md +185 -0
- django_cross_tenancy-0.1.0a1/pyproject.toml +49 -0
- django_cross_tenancy-0.1.0a1/pyproject.toml.orig +43 -0
- django_cross_tenancy-0.1.0a1/src/django_cross_tenancy/__init__.py +1 -0
- django_cross_tenancy-0.1.0a1/src/django_cross_tenancy/admin.py +165 -0
- django_cross_tenancy-0.1.0a1/src/django_cross_tenancy/apps.py +7 -0
- django_cross_tenancy-0.1.0a1/src/django_cross_tenancy/conf.py +30 -0
- django_cross_tenancy-0.1.0a1/src/django_cross_tenancy/context.py +60 -0
- django_cross_tenancy-0.1.0a1/src/django_cross_tenancy/locale/cs/LC_MESSAGES/django.mo +0 -0
- django_cross_tenancy-0.1.0a1/src/django_cross_tenancy/locale/cs/LC_MESSAGES/django.po +74 -0
- django_cross_tenancy-0.1.0a1/src/django_cross_tenancy/middleware.py +12 -0
- django_cross_tenancy-0.1.0a1/src/django_cross_tenancy/models.py +304 -0
- django_cross_tenancy-0.1.0a1/src/django_cross_tenancy/py.typed +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Miroslav Zvolský
|
|
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,208 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-cross-tenancy
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Shared-database multi-tenancy for Django with controlled cross-tenant access
|
|
5
|
+
Keywords: django,multi-tenancy,tenant,saas,row-level,permissions
|
|
6
|
+
Author: Miroslav Zvolský
|
|
7
|
+
Author-email: Miroslav Zvolský <zvolsky@seznam.cz>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: Django
|
|
12
|
+
Classifier: Framework :: Django :: 5.2
|
|
13
|
+
Classifier: Framework :: Django :: 6.0
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Dist: django>=5.2
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# django-cross-tenancy
|
|
25
|
+
|
|
26
|
+
Shared-database multi-tenancy for Django with **controlled cross-tenant access**.
|
|
27
|
+
|
|
28
|
+
Schema-per-tenant libraries (such as django-tenants) isolate tenants completely. Some applications need tenants
|
|
29
|
+
that are mostly separate but sometimes reach into each other: a relative helps manage another household, a borrowed
|
|
30
|
+
item is visible to the borrower, a consultant reads one client's data. django-cross-tenancy keeps all tenants in one
|
|
31
|
+
database and decides access per record with plain querysets that you can extend.
|
|
32
|
+
|
|
33
|
+
> Status: alpha (0.1.0a1). The API may change before 1.0.
|
|
34
|
+
|
|
35
|
+
## Concepts
|
|
36
|
+
|
|
37
|
+
- **Tenant**: your concrete model (household, company, team …) subclassing `AbstractTenant`.
|
|
38
|
+
- **Membership** (`AbstractMembership`): a user belongs to a tenant with a role: `guest` (view), `member`
|
|
39
|
+
(view and change), `admin` (also manages the tenant and its members).
|
|
40
|
+
- **Grant** (`AbstractTenantGrant`, optional): a user outside the tenant may view, or view and change, all its
|
|
41
|
+
records without being a member.
|
|
42
|
+
- **Tenant-owned record** (`TenantModel`): has a `tenant` foreign key and `created_by`. A new record without
|
|
43
|
+
a tenant gets the current user's active tenant; a tenant set explicitly is kept.
|
|
44
|
+
- **Access** is a method of the queryset, called with the user explicitly: `user_accessible(user)` (view),
|
|
45
|
+
`user_editable(user)` (change). Override `access_q(user, level)` to widen the reach of a model.
|
|
46
|
+
|
|
47
|
+
The default manager is never filtered implicitly: admin, forms, migrations, `dumpdata` and background tasks keep
|
|
48
|
+
working, and every filtered read names its user.
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
```console
|
|
53
|
+
pip install django-cross-tenancy
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
INSTALLED_APPS = [..., 'django_cross_tenancy', ...]
|
|
58
|
+
MIDDLEWARE = [..., 'django.contrib.auth.middleware.AuthenticationMiddleware',
|
|
59
|
+
'django_cross_tenancy.middleware.CurrentTenantMiddleware', ...]
|
|
60
|
+
CROSS_TENANCY_TENANT_MODEL = 'core.Household'
|
|
61
|
+
CROSS_TENANCY_USER_TENANT_FIELD = 'current_tenant' # the default: user attribute with the active tenant
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from django_cross_tenancy.models import AbstractMembership, AbstractTenant, AbstractTenantGrant
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Household(AbstractTenant):
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Membership(AbstractMembership): # reverse name on the tenant: memberships
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class HouseholdGrant(AbstractTenantGrant): # reverse name on the tenant: grants
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class User(AbstractUser):
|
|
81
|
+
current_tenant = models.ForeignKey(Household, models.SET_NULL, null=True, blank=True, related_name='+')
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Tenant-owned models and overlaps
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from django_cross_tenancy.models import VIEW, TenantModel, TenantQuerySet, TenantUniqueConstraint, tenants_for
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ToolQuerySet(TenantQuerySet):
|
|
91
|
+
def access_q(self, user, level):
|
|
92
|
+
q = super().access_q(user, level)
|
|
93
|
+
if level == VIEW: # a lent tool is visible (read only) to the borrowing household
|
|
94
|
+
lent = Loan.objects.filter(borrower__in=tenants_for(user, VIEW), returned=False)
|
|
95
|
+
q |= Q(pk__in=lent.values('tool'))
|
|
96
|
+
return q
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Tool(TenantModel):
|
|
100
|
+
name = models.CharField(max_length=100)
|
|
101
|
+
|
|
102
|
+
objects = ToolQuerySet.as_manager()
|
|
103
|
+
|
|
104
|
+
class Meta:
|
|
105
|
+
constraints = [TenantUniqueConstraint(fields=['name'], name='tool_unique_name')] # per tenant
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Loan(TenantModel): # tenant = the lender
|
|
109
|
+
tool = models.ForeignKey(Tool, models.CASCADE)
|
|
110
|
+
borrower = models.ForeignKey(Household, models.CASCADE, related_name='+')
|
|
111
|
+
returned = models.BooleanField(default=False)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
Tool.objects.user_accessible(request.user) # own, granted and borrowed tools
|
|
116
|
+
Tool.objects.user_editable(request.user) # own and granted with change
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Prefer `pk__in` subqueries in `access_q` over joins: the result then needs no `distinct()`.
|
|
120
|
+
|
|
121
|
+
Outside a request (tasks, commands, shell) set the tenant for new records explicitly:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from django_cross_tenancy.context import use_tenant
|
|
125
|
+
|
|
126
|
+
with use_tenant(household, user):
|
|
127
|
+
Tool.objects.create(name='drill')
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`bulk_create` bypasses `save()`: set `tenant` yourself.
|
|
131
|
+
|
|
132
|
+
## Global and private records in one table
|
|
133
|
+
|
|
134
|
+
A code list can hold global records shared by everyone and private records each tenant adds:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
class MeterType(TenantModel):
|
|
138
|
+
global_and_private = True # tenant becomes nullable: null = global record
|
|
139
|
+
|
|
140
|
+
name = models.CharField(max_length=100)
|
|
141
|
+
|
|
142
|
+
class Meta:
|
|
143
|
+
constraints = [TenantUniqueConstraint(fields=['name'], name='metertype_unique_name')]
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
- `user_accessible(user)` returns the tenant's records **and** the global ones (`tenant IS NULL`); only models with
|
|
147
|
+
`global_and_private = True` pay for the extra condition.
|
|
148
|
+
- Only superusers change or delete global records.
|
|
149
|
+
- New records: ordinary users always create in their active tenant; a superuser chooses in the admin form between
|
|
150
|
+
the active tenant and an empty value (global). In code, `use_tenant(None)` (or a superuser's request) keeps
|
|
151
|
+
a record without a tenant global; ordinary `TenantModel`s still refuse to be saved without a tenant.
|
|
152
|
+
|
|
153
|
+
## Unique values: `TenantUniqueConstraint`
|
|
154
|
+
|
|
155
|
+
`TenantUniqueConstraint(fields=[...], name=...)` keeps values unique among the records a user sees together:
|
|
156
|
+
|
|
157
|
+
- within one tenant: no tenant has "Gas meter" twice,
|
|
158
|
+
- with `global_and_private`, also between a tenant's records and the global ones: a private record may not repeat
|
|
159
|
+
a global one, a global record may not repeat another global one or any private one,
|
|
160
|
+
- two tenants may each have a record with the same values ("Workshop gas meter" in both).
|
|
161
|
+
|
|
162
|
+
Don't list `tenant` in `fields`; the constraint adds it. On PostgreSQL the database enforces it as an exclusion
|
|
163
|
+
constraint (the tenant as an integer range, null = unbounded, so a global record overlaps every tenant), which
|
|
164
|
+
needs the `btree_gist` extension created first:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
from django.contrib.postgres.operations import BtreeGistExtension
|
|
168
|
+
|
|
169
|
+
class Migration(migrations.Migration):
|
|
170
|
+
operations = [BtreeGistExtension(), ...] # before the migration that adds the constraint
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Validation (admin forms, `full_clean`) checks the same rule with clear messages, also when the form leaves `tenant`
|
|
174
|
+
out (the admin mixin sets the tenant before validation). Other databases get no constraint in the schema and rely on
|
|
175
|
+
validation only. Like `unique`, null values never clash.
|
|
176
|
+
|
|
177
|
+
## Admin
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
from django_cross_tenancy.admin import ManagedTenantAdminMixin, TenantAdminMixin, TenantManagerInlineMixin
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@admin.register(Tool)
|
|
184
|
+
class ToolAdmin(TenantAdminMixin, admin.ModelAdmin):
|
|
185
|
+
...
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
- `TenantAdminMixin`: list and object permissions follow `user_accessible` / `user_editable`; new records go to the
|
|
189
|
+
user's active tenant (inline rows to their parent's tenant); relation choices are narrowed to records the user may
|
|
190
|
+
see.
|
|
191
|
+
For `global_and_private` models, superusers get the tenant field with two choices (the record's or the active
|
|
192
|
+
tenant, or empty = global); other users see it read only.
|
|
193
|
+
- `ManagedTenantAdminMixin`: for the tenant model; users see their tenants, only administrators change them, the
|
|
194
|
+
creator of a tenant becomes its administrator.
|
|
195
|
+
- `TenantManagerInlineMixin`: memberships and grants inlines, editable by the tenant's administrators only.
|
|
196
|
+
|
|
197
|
+
The mixins only narrow what the admin allows: model permissions still apply.
|
|
198
|
+
|
|
199
|
+
## Development
|
|
200
|
+
|
|
201
|
+
```console
|
|
202
|
+
uv run --package django-cross-tenancy pytest
|
|
203
|
+
CROSS_TENANCY_TEST_POSTGRES=cross_tenancy uv run --package django-cross-tenancy pytest # PostgreSQL (PGHOST, PGUSER …)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## License
|
|
207
|
+
|
|
208
|
+
MIT
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# django-cross-tenancy
|
|
2
|
+
|
|
3
|
+
Shared-database multi-tenancy for Django with **controlled cross-tenant access**.
|
|
4
|
+
|
|
5
|
+
Schema-per-tenant libraries (such as django-tenants) isolate tenants completely. Some applications need tenants
|
|
6
|
+
that are mostly separate but sometimes reach into each other: a relative helps manage another household, a borrowed
|
|
7
|
+
item is visible to the borrower, a consultant reads one client's data. django-cross-tenancy keeps all tenants in one
|
|
8
|
+
database and decides access per record with plain querysets that you can extend.
|
|
9
|
+
|
|
10
|
+
> Status: alpha (0.1.0a1). The API may change before 1.0.
|
|
11
|
+
|
|
12
|
+
## Concepts
|
|
13
|
+
|
|
14
|
+
- **Tenant**: your concrete model (household, company, team …) subclassing `AbstractTenant`.
|
|
15
|
+
- **Membership** (`AbstractMembership`): a user belongs to a tenant with a role: `guest` (view), `member`
|
|
16
|
+
(view and change), `admin` (also manages the tenant and its members).
|
|
17
|
+
- **Grant** (`AbstractTenantGrant`, optional): a user outside the tenant may view, or view and change, all its
|
|
18
|
+
records without being a member.
|
|
19
|
+
- **Tenant-owned record** (`TenantModel`): has a `tenant` foreign key and `created_by`. A new record without
|
|
20
|
+
a tenant gets the current user's active tenant; a tenant set explicitly is kept.
|
|
21
|
+
- **Access** is a method of the queryset, called with the user explicitly: `user_accessible(user)` (view),
|
|
22
|
+
`user_editable(user)` (change). Override `access_q(user, level)` to widen the reach of a model.
|
|
23
|
+
|
|
24
|
+
The default manager is never filtered implicitly: admin, forms, migrations, `dumpdata` and background tasks keep
|
|
25
|
+
working, and every filtered read names its user.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```console
|
|
30
|
+
pip install django-cross-tenancy
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
INSTALLED_APPS = [..., 'django_cross_tenancy', ...]
|
|
35
|
+
MIDDLEWARE = [..., 'django.contrib.auth.middleware.AuthenticationMiddleware',
|
|
36
|
+
'django_cross_tenancy.middleware.CurrentTenantMiddleware', ...]
|
|
37
|
+
CROSS_TENANCY_TENANT_MODEL = 'core.Household'
|
|
38
|
+
CROSS_TENANCY_USER_TENANT_FIELD = 'current_tenant' # the default: user attribute with the active tenant
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from django_cross_tenancy.models import AbstractMembership, AbstractTenant, AbstractTenantGrant
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Household(AbstractTenant):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Membership(AbstractMembership): # reverse name on the tenant: memberships
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class HouseholdGrant(AbstractTenantGrant): # reverse name on the tenant: grants
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class User(AbstractUser):
|
|
58
|
+
current_tenant = models.ForeignKey(Household, models.SET_NULL, null=True, blank=True, related_name='+')
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Tenant-owned models and overlaps
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from django_cross_tenancy.models import VIEW, TenantModel, TenantQuerySet, TenantUniqueConstraint, tenants_for
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ToolQuerySet(TenantQuerySet):
|
|
68
|
+
def access_q(self, user, level):
|
|
69
|
+
q = super().access_q(user, level)
|
|
70
|
+
if level == VIEW: # a lent tool is visible (read only) to the borrowing household
|
|
71
|
+
lent = Loan.objects.filter(borrower__in=tenants_for(user, VIEW), returned=False)
|
|
72
|
+
q |= Q(pk__in=lent.values('tool'))
|
|
73
|
+
return q
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Tool(TenantModel):
|
|
77
|
+
name = models.CharField(max_length=100)
|
|
78
|
+
|
|
79
|
+
objects = ToolQuerySet.as_manager()
|
|
80
|
+
|
|
81
|
+
class Meta:
|
|
82
|
+
constraints = [TenantUniqueConstraint(fields=['name'], name='tool_unique_name')] # per tenant
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Loan(TenantModel): # tenant = the lender
|
|
86
|
+
tool = models.ForeignKey(Tool, models.CASCADE)
|
|
87
|
+
borrower = models.ForeignKey(Household, models.CASCADE, related_name='+')
|
|
88
|
+
returned = models.BooleanField(default=False)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
Tool.objects.user_accessible(request.user) # own, granted and borrowed tools
|
|
93
|
+
Tool.objects.user_editable(request.user) # own and granted with change
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Prefer `pk__in` subqueries in `access_q` over joins: the result then needs no `distinct()`.
|
|
97
|
+
|
|
98
|
+
Outside a request (tasks, commands, shell) set the tenant for new records explicitly:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from django_cross_tenancy.context import use_tenant
|
|
102
|
+
|
|
103
|
+
with use_tenant(household, user):
|
|
104
|
+
Tool.objects.create(name='drill')
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`bulk_create` bypasses `save()`: set `tenant` yourself.
|
|
108
|
+
|
|
109
|
+
## Global and private records in one table
|
|
110
|
+
|
|
111
|
+
A code list can hold global records shared by everyone and private records each tenant adds:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
class MeterType(TenantModel):
|
|
115
|
+
global_and_private = True # tenant becomes nullable: null = global record
|
|
116
|
+
|
|
117
|
+
name = models.CharField(max_length=100)
|
|
118
|
+
|
|
119
|
+
class Meta:
|
|
120
|
+
constraints = [TenantUniqueConstraint(fields=['name'], name='metertype_unique_name')]
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
- `user_accessible(user)` returns the tenant's records **and** the global ones (`tenant IS NULL`); only models with
|
|
124
|
+
`global_and_private = True` pay for the extra condition.
|
|
125
|
+
- Only superusers change or delete global records.
|
|
126
|
+
- New records: ordinary users always create in their active tenant; a superuser chooses in the admin form between
|
|
127
|
+
the active tenant and an empty value (global). In code, `use_tenant(None)` (or a superuser's request) keeps
|
|
128
|
+
a record without a tenant global; ordinary `TenantModel`s still refuse to be saved without a tenant.
|
|
129
|
+
|
|
130
|
+
## Unique values: `TenantUniqueConstraint`
|
|
131
|
+
|
|
132
|
+
`TenantUniqueConstraint(fields=[...], name=...)` keeps values unique among the records a user sees together:
|
|
133
|
+
|
|
134
|
+
- within one tenant: no tenant has "Gas meter" twice,
|
|
135
|
+
- with `global_and_private`, also between a tenant's records and the global ones: a private record may not repeat
|
|
136
|
+
a global one, a global record may not repeat another global one or any private one,
|
|
137
|
+
- two tenants may each have a record with the same values ("Workshop gas meter" in both).
|
|
138
|
+
|
|
139
|
+
Don't list `tenant` in `fields`; the constraint adds it. On PostgreSQL the database enforces it as an exclusion
|
|
140
|
+
constraint (the tenant as an integer range, null = unbounded, so a global record overlaps every tenant), which
|
|
141
|
+
needs the `btree_gist` extension created first:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from django.contrib.postgres.operations import BtreeGistExtension
|
|
145
|
+
|
|
146
|
+
class Migration(migrations.Migration):
|
|
147
|
+
operations = [BtreeGistExtension(), ...] # before the migration that adds the constraint
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Validation (admin forms, `full_clean`) checks the same rule with clear messages, also when the form leaves `tenant`
|
|
151
|
+
out (the admin mixin sets the tenant before validation). Other databases get no constraint in the schema and rely on
|
|
152
|
+
validation only. Like `unique`, null values never clash.
|
|
153
|
+
|
|
154
|
+
## Admin
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from django_cross_tenancy.admin import ManagedTenantAdminMixin, TenantAdminMixin, TenantManagerInlineMixin
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@admin.register(Tool)
|
|
161
|
+
class ToolAdmin(TenantAdminMixin, admin.ModelAdmin):
|
|
162
|
+
...
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
- `TenantAdminMixin`: list and object permissions follow `user_accessible` / `user_editable`; new records go to the
|
|
166
|
+
user's active tenant (inline rows to their parent's tenant); relation choices are narrowed to records the user may
|
|
167
|
+
see.
|
|
168
|
+
For `global_and_private` models, superusers get the tenant field with two choices (the record's or the active
|
|
169
|
+
tenant, or empty = global); other users see it read only.
|
|
170
|
+
- `ManagedTenantAdminMixin`: for the tenant model; users see their tenants, only administrators change them, the
|
|
171
|
+
creator of a tenant becomes its administrator.
|
|
172
|
+
- `TenantManagerInlineMixin`: memberships and grants inlines, editable by the tenant's administrators only.
|
|
173
|
+
|
|
174
|
+
The mixins only narrow what the admin allows: model permissions still apply.
|
|
175
|
+
|
|
176
|
+
## Development
|
|
177
|
+
|
|
178
|
+
```console
|
|
179
|
+
uv run --package django-cross-tenancy pytest
|
|
180
|
+
CROSS_TENANCY_TEST_POSTGRES=cross_tenancy uv run --package django-cross-tenancy pytest # PostgreSQL (PGHOST, PGUSER …)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## License
|
|
184
|
+
|
|
185
|
+
MIT
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "django-cross-tenancy"
|
|
3
|
+
version = "0.1.0a1"
|
|
4
|
+
description = "Shared-database multi-tenancy for Django with controlled cross-tenant access"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
dependencies = ["django>=5.2"]
|
|
10
|
+
keywords = [
|
|
11
|
+
"django",
|
|
12
|
+
"multi-tenancy",
|
|
13
|
+
"tenant",
|
|
14
|
+
"saas",
|
|
15
|
+
"row-level",
|
|
16
|
+
"permissions",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 3 - Alpha",
|
|
20
|
+
"Framework :: Django",
|
|
21
|
+
"Framework :: Django :: 5.2",
|
|
22
|
+
"Framework :: Django :: 6.0",
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
"Operating System :: OS Independent",
|
|
25
|
+
"Programming Language :: Python :: 3",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Topic :: Software Development :: Libraries",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[[project.authors]]
|
|
32
|
+
name = "Miroslav Zvolský"
|
|
33
|
+
email = "zvolsky@seznam.cz"
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = [
|
|
37
|
+
"psycopg>=3.2",
|
|
38
|
+
"pytest>=8",
|
|
39
|
+
"pytest-django>=4.9",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["uv_build>=0.12.13,<0.13.0"]
|
|
44
|
+
build-backend = "uv_build"
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
DJANGO_SETTINGS_MODULE = "tests.settings"
|
|
48
|
+
pythonpath = ["."]
|
|
49
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "django-cross-tenancy"
|
|
3
|
+
version = "0.1.0a1"
|
|
4
|
+
description = "Shared-database multi-tenancy for Django with controlled cross-tenant access"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Miroslav Zvolský", email = "zvolsky@seznam.cz" }
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"django>=5.2",
|
|
14
|
+
]
|
|
15
|
+
keywords = ["django", "multi-tenancy", "tenant", "saas", "row-level", "permissions"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Framework :: Django",
|
|
19
|
+
"Framework :: Django :: 5.2",
|
|
20
|
+
"Framework :: Django :: 6.0",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Software Development :: Libraries",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = [
|
|
31
|
+
"psycopg>=3.2",
|
|
32
|
+
"pytest>=8",
|
|
33
|
+
"pytest-django>=4.9",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["uv_build>=0.12.13,<0.13.0"]
|
|
38
|
+
build-backend = "uv_build"
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
DJANGO_SETTINGS_MODULE = "tests.settings"
|
|
42
|
+
pythonpath = ["."]
|
|
43
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared-database multi-tenancy for Django with controlled cross-tenant access."""
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Admin mixins (put them before ``admin.ModelAdmin`` / the inline class). They only narrow what the base class allows:
|
|
3
|
+
model permissions still apply.
|
|
4
|
+
|
|
5
|
+
- ``TenantAdminMixin``: tenant-owned records (``TenantModel``); also for inlines of such records. With
|
|
6
|
+
``global_and_private`` models a superuser chooses between the active tenant and a global record (no tenant);
|
|
7
|
+
other users see the tenant read only.
|
|
8
|
+
- ``ManagedTenantAdminMixin``: the tenant model itself (users see their tenants, administrators change them)
|
|
9
|
+
- ``TenantManagerInlineMixin``: inlines of the tenant (memberships, grants) editable only by its administrators
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from django.core.exceptions import PermissionDenied
|
|
13
|
+
|
|
14
|
+
from .conf import get_tenant_model, user_current_tenant
|
|
15
|
+
from .context import use_request
|
|
16
|
+
from .models import MANAGE, VIEW, CHANGE, Role, TenantModel, has_tenant_access, tenants_for
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _narrow_choices(formfield, request):
|
|
20
|
+
"""Choices of a relation only among records the user may see (else a foreign id could be submitted)"""
|
|
21
|
+
queryset = getattr(formfield, 'queryset', None)
|
|
22
|
+
if queryset is not None and hasattr(queryset, 'user_accessible'):
|
|
23
|
+
formfield.queryset = queryset.user_accessible(request.user)
|
|
24
|
+
return formfield
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _ChoicesMixin:
|
|
28
|
+
def formfield_for_foreignkey(self, db_field, request, **kwargs):
|
|
29
|
+
return _narrow_choices(super().formfield_for_foreignkey(db_field, request, **kwargs), request)
|
|
30
|
+
|
|
31
|
+
def formfield_for_manytomany(self, db_field, request, **kwargs):
|
|
32
|
+
return _narrow_choices(super().formfield_for_manytomany(db_field, request, **kwargs), request)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TenantAdminMixin(_ChoicesMixin):
|
|
36
|
+
def get_queryset(self, request):
|
|
37
|
+
return super().get_queryset(request).user_accessible(request.user)
|
|
38
|
+
|
|
39
|
+
def _reaches(self, request, obj, method):
|
|
40
|
+
return getattr(self.model._default_manager.all(), method)(request.user).filter(pk=obj.pk).exists()
|
|
41
|
+
|
|
42
|
+
def has_view_permission(self, request, obj=None):
|
|
43
|
+
return super().has_view_permission(request, obj) and (
|
|
44
|
+
obj is None or self._reaches(request, obj, 'user_accessible'))
|
|
45
|
+
|
|
46
|
+
def has_change_permission(self, request, obj=None):
|
|
47
|
+
return super().has_change_permission(request, obj) and (
|
|
48
|
+
obj is None or self._reaches(request, obj, 'user_editable'))
|
|
49
|
+
|
|
50
|
+
def has_delete_permission(self, request, obj=None):
|
|
51
|
+
return super().has_delete_permission(request, obj) and (
|
|
52
|
+
obj is None or self._reaches(request, obj, 'user_editable'))
|
|
53
|
+
|
|
54
|
+
def has_add_permission(self, request, *args):
|
|
55
|
+
if not super().has_add_permission(request, *args):
|
|
56
|
+
return False
|
|
57
|
+
parent = args[0] if args else None # inlines get the parent record: new rows join its tenant
|
|
58
|
+
if parent is not None and hasattr(type(parent)._default_manager.all(), 'user_editable'):
|
|
59
|
+
return type(parent)._default_manager.all().user_editable(request.user).filter(pk=parent.pk).exists()
|
|
60
|
+
return self.add_tenant(request) is not None or self._chooses_global(request)
|
|
61
|
+
|
|
62
|
+
def add_tenant(self, request):
|
|
63
|
+
"""Tenant for a new record: the user's active tenant, if they may change its records"""
|
|
64
|
+
tenant = user_current_tenant(request.user)
|
|
65
|
+
return tenant if has_tenant_access(request.user, tenant, CHANGE) else None
|
|
66
|
+
|
|
67
|
+
def _chooses_global(self, request):
|
|
68
|
+
return getattr(self.model, 'global_and_private', False) and request.user.is_superuser
|
|
69
|
+
|
|
70
|
+
def get_readonly_fields(self, request, obj=None):
|
|
71
|
+
readonly = super().get_readonly_fields(request, obj)
|
|
72
|
+
if getattr(self.model, 'global_and_private', False) and not request.user.is_superuser:
|
|
73
|
+
return (*readonly, 'tenant')
|
|
74
|
+
return readonly
|
|
75
|
+
|
|
76
|
+
def get_form(self, request, obj=None, change=False, **kwargs):
|
|
77
|
+
form = super().get_form(request, obj, change, **kwargs)
|
|
78
|
+
field = form.base_fields.get('tenant')
|
|
79
|
+
if field is not None and self._chooses_global(request):
|
|
80
|
+
# two states: the record's own tenant (new record: the active one), or empty = global
|
|
81
|
+
tenant_id = obj.tenant_id if obj is not None and obj.tenant_id else getattr(
|
|
82
|
+
user_current_tenant(request.user), 'pk', None)
|
|
83
|
+
field.queryset = get_tenant_model()._default_manager.filter(pk=tenant_id)
|
|
84
|
+
field.required = False
|
|
85
|
+
if obj is None and issubclass(self.model, TenantModel):
|
|
86
|
+
# the tenant before validation, so unique constraints with tenant check the right records
|
|
87
|
+
# (get_form builds a new class on every call: patching it touches no other form)
|
|
88
|
+
post_clean = form._post_clean
|
|
89
|
+
|
|
90
|
+
def _post_clean(form_self):
|
|
91
|
+
self.assign_tenant(request, form_self.instance)
|
|
92
|
+
post_clean(form_self)
|
|
93
|
+
|
|
94
|
+
form._post_clean = _post_clean
|
|
95
|
+
return form
|
|
96
|
+
|
|
97
|
+
def assign_tenant(self, request, obj):
|
|
98
|
+
"""
|
|
99
|
+
Tenant of a new record: other users always create in their active tenant; a superuser's choice is kept
|
|
100
|
+
(empty = global). False when there is no tenant to use.
|
|
101
|
+
"""
|
|
102
|
+
if not request.user.is_superuser or (obj.tenant_id is None and not obj.global_and_private):
|
|
103
|
+
tenant = self.add_tenant(request)
|
|
104
|
+
if tenant is None:
|
|
105
|
+
return False
|
|
106
|
+
obj.tenant = tenant
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
def save_model(self, request, obj, form, change):
|
|
110
|
+
if not change and isinstance(obj, TenantModel):
|
|
111
|
+
if not self.assign_tenant(request, obj):
|
|
112
|
+
raise PermissionDenied
|
|
113
|
+
if obj.created_by_id is None:
|
|
114
|
+
obj.created_by = request.user
|
|
115
|
+
with use_request(request): # TenantModel.save sees this user even without CurrentTenantMiddleware
|
|
116
|
+
super().save_model(request, obj, form, change)
|
|
117
|
+
|
|
118
|
+
def save_formset(self, request, form, formset, change):
|
|
119
|
+
# nested records belong to the parent's tenant, not to the user's active one
|
|
120
|
+
parent_tenant_id = getattr(form.instance, 'tenant_id', None)
|
|
121
|
+
for inline_form in formset.forms:
|
|
122
|
+
instance = inline_form.instance
|
|
123
|
+
if isinstance(instance, TenantModel) and instance._state.adding:
|
|
124
|
+
if instance.tenant_id is None and parent_tenant_id is not None:
|
|
125
|
+
instance.tenant_id = parent_tenant_id
|
|
126
|
+
if instance.created_by_id is None:
|
|
127
|
+
instance.created_by = request.user
|
|
128
|
+
super().save_formset(request, form, formset, change)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ManagedTenantAdminMixin(_ChoicesMixin):
|
|
132
|
+
def get_queryset(self, request):
|
|
133
|
+
queryset = super().get_queryset(request)
|
|
134
|
+
if request.user.is_superuser:
|
|
135
|
+
return queryset
|
|
136
|
+
return queryset.filter(pk__in=tenants_for(request.user, VIEW))
|
|
137
|
+
|
|
138
|
+
def has_change_permission(self, request, obj=None):
|
|
139
|
+
return super().has_change_permission(request, obj) and (
|
|
140
|
+
obj is None or has_tenant_access(request.user, obj, MANAGE))
|
|
141
|
+
|
|
142
|
+
def has_delete_permission(self, request, obj=None):
|
|
143
|
+
return super().has_delete_permission(request, obj) and (
|
|
144
|
+
obj is None or has_tenant_access(request.user, obj, MANAGE))
|
|
145
|
+
|
|
146
|
+
def save_model(self, request, obj, form, change):
|
|
147
|
+
super().save_model(request, obj, form, change)
|
|
148
|
+
if not change: # whoever creates a tenant administers it
|
|
149
|
+
obj.memberships.get_or_create(user=request.user, defaults={'role': Role.ADMIN})
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class TenantManagerInlineMixin:
|
|
153
|
+
"""For inlines whose parent is the tenant (memberships, grants)"""
|
|
154
|
+
|
|
155
|
+
def _manages(self, request, obj):
|
|
156
|
+
return obj is None or has_tenant_access(request.user, obj, MANAGE)
|
|
157
|
+
|
|
158
|
+
def has_add_permission(self, request, obj=None):
|
|
159
|
+
return super().has_add_permission(request, obj) and self._manages(request, obj)
|
|
160
|
+
|
|
161
|
+
def has_change_permission(self, request, obj=None):
|
|
162
|
+
return super().has_change_permission(request, obj) and self._manages(request, obj)
|
|
163
|
+
|
|
164
|
+
def has_delete_permission(self, request, obj=None):
|
|
165
|
+
return super().has_delete_permission(request, obj) and self._manages(request, obj)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Settings:
|
|
3
|
+
|
|
4
|
+
- ``CROSS_TENANCY_TENANT_MODEL`` (required): ``'app_label.ModelName'`` of the project's concrete tenant model
|
|
5
|
+
(a subclass of ``AbstractTenant``), like ``AUTH_USER_MODEL``.
|
|
6
|
+
- ``CROSS_TENANCY_USER_TENANT_FIELD`` (default ``'current_tenant'``): attribute of the user holding the tenant that
|
|
7
|
+
new records are created in (the user's active tenant).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from django.apps import apps
|
|
11
|
+
from django.conf import settings
|
|
12
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def tenant_model_label():
|
|
16
|
+
try:
|
|
17
|
+
return settings.CROSS_TENANCY_TENANT_MODEL
|
|
18
|
+
except AttributeError:
|
|
19
|
+
raise ImproperlyConfigured('Set CROSS_TENANCY_TENANT_MODEL to the tenant model (app_label.ModelName)') from None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_tenant_model():
|
|
23
|
+
return apps.get_model(tenant_model_label(), require_ready=False)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def user_current_tenant(user):
|
|
27
|
+
"""The user's active tenant (None for anonymous users or when not set)"""
|
|
28
|
+
if user is None or not user.is_authenticated:
|
|
29
|
+
return None
|
|
30
|
+
return getattr(user, getattr(settings, 'CROSS_TENANCY_USER_TENANT_FIELD', 'current_tenant'), None)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The current user and tenant of the running code (request, task, shell). Used only to fill in defaults when records
|
|
3
|
+
are created (``TenantModel.save``), never to filter reads: reading always names the user explicitly
|
|
4
|
+
(``user_accessible(user)``).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from contextvars import ContextVar
|
|
9
|
+
|
|
10
|
+
from .conf import user_current_tenant
|
|
11
|
+
|
|
12
|
+
_state = ContextVar('django_cross_tenancy_state', default=None)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _Explicit:
|
|
16
|
+
def __init__(self, tenant, user):
|
|
17
|
+
self.tenant = tenant
|
|
18
|
+
self.user = user
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@contextmanager
|
|
22
|
+
def use_tenant(tenant, user=None):
|
|
23
|
+
"""Create records in ``tenant`` (and as ``user``) inside the block: tasks, commands, shell, tests"""
|
|
24
|
+
token = _state.set(_Explicit(tenant, user))
|
|
25
|
+
try:
|
|
26
|
+
yield
|
|
27
|
+
finally:
|
|
28
|
+
_state.reset(token)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@contextmanager
|
|
32
|
+
def use_request(request):
|
|
33
|
+
"""The request's user and their active tenant, read lazily (DRF authenticates only inside the view)"""
|
|
34
|
+
token = _state.set(request)
|
|
35
|
+
try:
|
|
36
|
+
yield
|
|
37
|
+
finally:
|
|
38
|
+
_state.reset(token)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def current_user():
|
|
42
|
+
state = _state.get()
|
|
43
|
+
user = getattr(state, 'user', None)
|
|
44
|
+
return user if user is not None and user.is_authenticated else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def current_tenant():
|
|
48
|
+
state = _state.get()
|
|
49
|
+
if isinstance(state, _Explicit):
|
|
50
|
+
return state.tenant
|
|
51
|
+
return user_current_tenant(current_user())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def global_allowed():
|
|
55
|
+
"""May a new record of a global_and_private model stay global? Inside use_tenant(None), or for a superuser"""
|
|
56
|
+
state = _state.get()
|
|
57
|
+
if isinstance(state, _Explicit):
|
|
58
|
+
return state.tenant is None
|
|
59
|
+
user = current_user()
|
|
60
|
+
return user is not None and user.is_superuser
|
|
Binary file
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
msgid ""
|
|
2
|
+
msgstr ""
|
|
3
|
+
"Project-Id-Version: django-cross-tenancy\n"
|
|
4
|
+
"Language: cs\n"
|
|
5
|
+
"MIME-Version: 1.0\n"
|
|
6
|
+
"Content-Type: text/plain; charset=UTF-8\n"
|
|
7
|
+
"Content-Transfer-Encoding: 8bit\n"
|
|
8
|
+
"Plural-Forms: nplurals=4; plural=(n == 1 && n % 1 == 0) ? 0 : (n >= 2 && n <= 4 && n % 1 == 0) ? 1: (n % 1 != 0 ) ? 2 : 3;\n"
|
|
9
|
+
|
|
10
|
+
msgid "Cross tenancy"
|
|
11
|
+
msgstr "Sdílení mezi tenanty"
|
|
12
|
+
|
|
13
|
+
msgid "administrator"
|
|
14
|
+
msgstr "správce"
|
|
15
|
+
|
|
16
|
+
msgid "member"
|
|
17
|
+
msgstr "člen"
|
|
18
|
+
|
|
19
|
+
msgid "guest"
|
|
20
|
+
msgstr "host"
|
|
21
|
+
|
|
22
|
+
msgid "view"
|
|
23
|
+
msgstr "čtení"
|
|
24
|
+
|
|
25
|
+
msgid "view and change"
|
|
26
|
+
msgstr "čtení a úpravy"
|
|
27
|
+
|
|
28
|
+
msgid "name"
|
|
29
|
+
msgstr "název"
|
|
30
|
+
|
|
31
|
+
msgid "created"
|
|
32
|
+
msgstr "vytvořeno"
|
|
33
|
+
|
|
34
|
+
msgid "tenant"
|
|
35
|
+
msgstr "tenant"
|
|
36
|
+
|
|
37
|
+
msgid "tenants"
|
|
38
|
+
msgstr "tenanti"
|
|
39
|
+
|
|
40
|
+
msgid "membership"
|
|
41
|
+
msgstr "členství"
|
|
42
|
+
|
|
43
|
+
msgid "memberships"
|
|
44
|
+
msgstr "členství"
|
|
45
|
+
|
|
46
|
+
msgid "user"
|
|
47
|
+
msgstr "uživatel"
|
|
48
|
+
|
|
49
|
+
msgid "role"
|
|
50
|
+
msgstr "role"
|
|
51
|
+
|
|
52
|
+
msgid "tenant access"
|
|
53
|
+
msgstr "přístup"
|
|
54
|
+
|
|
55
|
+
msgid "tenant accesses"
|
|
56
|
+
msgstr "přístupy"
|
|
57
|
+
|
|
58
|
+
msgid "access"
|
|
59
|
+
msgstr "přístup"
|
|
60
|
+
|
|
61
|
+
msgid "created by"
|
|
62
|
+
msgstr "autor"
|
|
63
|
+
|
|
64
|
+
#, python-format
|
|
65
|
+
msgid "The record cannot be saved: %(tenant)s is missing."
|
|
66
|
+
msgstr "Záznam nelze uložit, chybí %(tenant)s."
|
|
67
|
+
|
|
68
|
+
#, python-format
|
|
69
|
+
msgid "%(model_name)s with this %(field_labels)s already exists as a shared record."
|
|
70
|
+
msgstr "%(model_name)s s touto hodnotou pole %(field_labels)s už existuje jako společný záznam."
|
|
71
|
+
|
|
72
|
+
#, python-format
|
|
73
|
+
msgid "%(model_name)s with this %(field_labels)s already exists as a private record (%(tenants)s)."
|
|
74
|
+
msgstr "%(model_name)s s touto hodnotou pole %(field_labels)s už existuje jako soukromý záznam (%(tenants)s)."
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .context import use_request
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CurrentTenantMiddleware:
|
|
5
|
+
"""The request's user and active tenant become the defaults for new records (after AuthenticationMiddleware)"""
|
|
6
|
+
|
|
7
|
+
def __init__(self, get_response):
|
|
8
|
+
self.get_response = get_response
|
|
9
|
+
|
|
10
|
+
def __call__(self, request):
|
|
11
|
+
with use_request(request):
|
|
12
|
+
return self.get_response(request)
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tenants share one database. Every tenant-owned record has a ``tenant`` foreign key; a user reaches a tenant's records
|
|
3
|
+
through a membership (role) or a grant (access to a whole tenant without being its member). A model can widen the
|
|
4
|
+
reach by overriding ``TenantQuerySet.access_q`` (e.g. a lent item is visible to the borrower).
|
|
5
|
+
|
|
6
|
+
Access levels: ``VIEW`` (read), ``CHANGE`` (read and write), ``MANAGE`` (administer the tenant and its members).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from django.conf import settings
|
|
10
|
+
from django.core.exceptions import NON_FIELD_ERRORS, FieldDoesNotExist, ValidationError
|
|
11
|
+
from django.db import DEFAULT_DB_ALIAS, models
|
|
12
|
+
from django.db.models import Q
|
|
13
|
+
from django.db.models.signals import class_prepared
|
|
14
|
+
from django.utils.functional import lazy
|
|
15
|
+
from django.utils.text import capfirst, get_text_list
|
|
16
|
+
from django.utils.translation import gettext_lazy as _
|
|
17
|
+
|
|
18
|
+
from .conf import get_tenant_model, tenant_model_label
|
|
19
|
+
from .context import current_tenant, current_user, global_allowed
|
|
20
|
+
|
|
21
|
+
VIEW, CHANGE, MANAGE = 'view', 'change', 'manage'
|
|
22
|
+
LEVELS = (VIEW, CHANGE, MANAGE)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _tenant_verbose_name():
|
|
26
|
+
return str(get_tenant_model()._meta.verbose_name)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# labels of tenant fields follow the project's tenant model (e.g. "household"), evaluated when displayed
|
|
30
|
+
tenant_verbose_name = lazy(_tenant_verbose_name, str)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Role(models.TextChoices):
|
|
34
|
+
ADMIN = 'admin', _('administrator')
|
|
35
|
+
MEMBER = 'member', _('member')
|
|
36
|
+
GUEST = 'guest', _('guest')
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class GrantLevel(models.TextChoices):
|
|
40
|
+
VIEW = VIEW, _('view')
|
|
41
|
+
CHANGE = CHANGE, _('view and change')
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
ROLES_FOR = {
|
|
45
|
+
VIEW: (Role.ADMIN, Role.MEMBER, Role.GUEST),
|
|
46
|
+
CHANGE: (Role.ADMIN, Role.MEMBER),
|
|
47
|
+
MANAGE: (Role.ADMIN,),
|
|
48
|
+
}
|
|
49
|
+
GRANT_LEVELS_FOR = {
|
|
50
|
+
VIEW: (GrantLevel.VIEW, GrantLevel.CHANGE),
|
|
51
|
+
CHANGE: (GrantLevel.CHANGE,),
|
|
52
|
+
MANAGE: (),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AbstractTenant(models.Model):
|
|
57
|
+
name = models.CharField(_('name'), max_length=200)
|
|
58
|
+
created = models.DateTimeField(_('created'), auto_now_add=True)
|
|
59
|
+
|
|
60
|
+
class Meta:
|
|
61
|
+
abstract = True
|
|
62
|
+
verbose_name = _('tenant')
|
|
63
|
+
verbose_name_plural = _('tenants')
|
|
64
|
+
|
|
65
|
+
def __str__(self):
|
|
66
|
+
return self.name
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class AbstractMembership(models.Model):
|
|
70
|
+
"""A user belongs to a tenant; the role sets the level (guest: view, member: change, admin: manage)"""
|
|
71
|
+
|
|
72
|
+
tenant = models.ForeignKey(tenant_model_label(), models.CASCADE, related_name='memberships',
|
|
73
|
+
verbose_name=tenant_verbose_name())
|
|
74
|
+
user = models.ForeignKey(settings.AUTH_USER_MODEL, models.CASCADE, related_name='tenant_memberships',
|
|
75
|
+
verbose_name=_('user'))
|
|
76
|
+
role = models.CharField(_('role'), max_length=20, choices=Role.choices, default=Role.MEMBER)
|
|
77
|
+
|
|
78
|
+
class Meta:
|
|
79
|
+
abstract = True
|
|
80
|
+
verbose_name = _('membership')
|
|
81
|
+
verbose_name_plural = _('memberships')
|
|
82
|
+
constraints = [
|
|
83
|
+
models.UniqueConstraint(fields=['tenant', 'user'], name='%(app_label)s_%(class)s_unique'),
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
def __str__(self):
|
|
87
|
+
return f'{self.user} – {self.tenant}'
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class AbstractTenantGrant(models.Model):
|
|
91
|
+
"""A user outside the tenant may view (or change) all its records, without being a member (optional model)"""
|
|
92
|
+
|
|
93
|
+
tenant = models.ForeignKey(tenant_model_label(), models.CASCADE, related_name='grants',
|
|
94
|
+
verbose_name=tenant_verbose_name())
|
|
95
|
+
user = models.ForeignKey(settings.AUTH_USER_MODEL, models.CASCADE, related_name='tenant_grants',
|
|
96
|
+
verbose_name=_('user'))
|
|
97
|
+
level = models.CharField(_('access'), max_length=20, choices=GrantLevel.choices, default=GrantLevel.VIEW)
|
|
98
|
+
|
|
99
|
+
class Meta:
|
|
100
|
+
abstract = True
|
|
101
|
+
verbose_name = _('tenant access')
|
|
102
|
+
verbose_name_plural = _('tenant accesses')
|
|
103
|
+
constraints = [
|
|
104
|
+
models.UniqueConstraint(fields=['tenant', 'user'], name='%(app_label)s_%(class)s_unique'),
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
def __str__(self):
|
|
108
|
+
return f'{self.user} → {self.tenant}'
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _related_model(Model, name):
|
|
112
|
+
try:
|
|
113
|
+
return Model._meta.get_field(name).related_model
|
|
114
|
+
except FieldDoesNotExist:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def tenants_for(user, level=VIEW):
|
|
119
|
+
"""Ids of tenants the user reaches at ``level`` (a subquery for ``__in``); superusers get no extra tenants here"""
|
|
120
|
+
Tenant = get_tenant_model()
|
|
121
|
+
if user is None or not user.is_authenticated:
|
|
122
|
+
return Tenant.objects.none().values('pk')
|
|
123
|
+
Membership = _related_model(Tenant, 'memberships')
|
|
124
|
+
q = Q(pk__in=Membership.objects.filter(user=user, role__in=ROLES_FOR[level]).values('tenant'))
|
|
125
|
+
Grant = _related_model(Tenant, 'grants')
|
|
126
|
+
if Grant is not None and GRANT_LEVELS_FOR[level]:
|
|
127
|
+
q |= Q(pk__in=Grant.objects.filter(user=user, level__in=GRANT_LEVELS_FOR[level]).values('tenant'))
|
|
128
|
+
return Tenant.objects.filter(q).values('pk')
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def has_tenant_access(user, tenant, level=VIEW):
|
|
132
|
+
if tenant is None or user is None or not user.is_authenticated:
|
|
133
|
+
return False
|
|
134
|
+
return user.is_superuser or tenants_for(user, level).filter(pk=tenant.pk).exists()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class TenantQuerySet(models.QuerySet):
|
|
138
|
+
"""
|
|
139
|
+
Records a user may see or change. Superusers reach everything. Override ``access_q`` to widen (or narrow) the
|
|
140
|
+
reach of a model; prefer ``pk__in`` subqueries over joins there, so the result needs no ``distinct()``.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def access_q(self, user, level):
|
|
144
|
+
q = Q(tenant__in=tenants_for(user, level))
|
|
145
|
+
if level == VIEW and getattr(self.model, 'global_and_private', False):
|
|
146
|
+
q |= Q(tenant__isnull=True) # global records: everyone reads them, only superusers change them
|
|
147
|
+
return q
|
|
148
|
+
|
|
149
|
+
def user_accessible(self, user):
|
|
150
|
+
return self._for_user(user, VIEW)
|
|
151
|
+
|
|
152
|
+
def user_editable(self, user):
|
|
153
|
+
return self._for_user(user, CHANGE)
|
|
154
|
+
|
|
155
|
+
def _for_user(self, user, level):
|
|
156
|
+
if user is None or not user.is_authenticated:
|
|
157
|
+
return self.none()
|
|
158
|
+
if user.is_superuser:
|
|
159
|
+
return self
|
|
160
|
+
return self.filter(self.access_q(user, level))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class TenantModel(models.Model):
|
|
164
|
+
"""
|
|
165
|
+
A record owned by a tenant. A new record without a tenant gets the current one (``CurrentTenantMiddleware``,
|
|
166
|
+
``use_tenant``); a tenant set explicitly is kept. ``bulk_create`` bypasses ``save``: set the tenant yourself.
|
|
167
|
+
|
|
168
|
+
``global_and_private = True``: the table also holds global records (``tenant`` is null), which every user reads
|
|
169
|
+
along with their tenant's private records. ``tenant`` then becomes nullable and editable (superusers choose,
|
|
170
|
+
other users always create in their active tenant). A new record without a tenant stays global only for
|
|
171
|
+
a superuser or inside ``use_tenant(None)``.
|
|
172
|
+
|
|
173
|
+
Unique values within a tenant (and, with global records, across them too): ``TenantUniqueConstraint``.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
global_and_private = False
|
|
177
|
+
|
|
178
|
+
tenant = models.ForeignKey(tenant_model_label(), models.PROTECT, editable=False,
|
|
179
|
+
verbose_name=tenant_verbose_name())
|
|
180
|
+
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, models.SET_NULL, null=True, blank=True, editable=False,
|
|
181
|
+
related_name='+', verbose_name=_('created by'))
|
|
182
|
+
|
|
183
|
+
objects = TenantQuerySet.as_manager()
|
|
184
|
+
|
|
185
|
+
class Meta:
|
|
186
|
+
abstract = True
|
|
187
|
+
|
|
188
|
+
def save(self, *args, **kwargs):
|
|
189
|
+
if self._state.adding:
|
|
190
|
+
if self.tenant_id is None and not (self.global_and_private and global_allowed()):
|
|
191
|
+
tenant = current_tenant()
|
|
192
|
+
if tenant is None:
|
|
193
|
+
raise ValidationError(_('The record cannot be saved: %(tenant)s is missing.'),
|
|
194
|
+
params={'tenant': tenant_verbose_name()})
|
|
195
|
+
self.tenant = tenant
|
|
196
|
+
if self.created_by_id is None:
|
|
197
|
+
self.created_by = current_user()
|
|
198
|
+
super().save(*args, **kwargs)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class TenantUniqueConstraint(models.BaseConstraint):
|
|
202
|
+
"""
|
|
203
|
+
``fields`` unique among the records a user sees together: within one tenant, and for ``global_and_private``
|
|
204
|
+
models also between a tenant's records and the global ones (a private "Gas meter" next to a global "Gas meter"
|
|
205
|
+
is refused, as is a second global one or a global one repeating any private one). Two tenants may each have
|
|
206
|
+
a record with the same values.
|
|
207
|
+
|
|
208
|
+
PostgreSQL enforces it as an exclusion constraint (the tenant as a range, null = unbounded, overlapping any
|
|
209
|
+
tenant), which needs the ``btree_gist`` extension: put ``BtreeGistExtension()`` in a migration before it. Other
|
|
210
|
+
databases get no constraint in the schema and rely on validation (forms, ``full_clean``), which also gives
|
|
211
|
+
clear messages and works when the form leaves ``tenant`` out (the admin mixin sets it before validation).
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
def __init__(self, *, fields, name, violation_error_code=None, violation_error_message=None):
|
|
215
|
+
if not fields:
|
|
216
|
+
raise ValueError('TenantUniqueConstraint needs at least one field.')
|
|
217
|
+
self.fields = tuple(fields)
|
|
218
|
+
super().__init__(name=name, violation_error_code=violation_error_code,
|
|
219
|
+
violation_error_message=violation_error_message)
|
|
220
|
+
|
|
221
|
+
def _exclude_sql(self, model, schema_editor):
|
|
222
|
+
quote = schema_editor.quote_name
|
|
223
|
+
tenant = quote(model._meta.get_field('tenant').column)
|
|
224
|
+
elements = [f'{quote(model._meta.get_field(name).column)} WITH =' for name in self.fields]
|
|
225
|
+
elements.append(f"int8range({tenant}, {tenant}, '[]') WITH &&")
|
|
226
|
+
return f'CONSTRAINT {quote(self.name)} EXCLUDE USING gist ({", ".join(elements)})'
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def _supported(schema_editor):
|
|
230
|
+
return schema_editor.connection.vendor == 'postgresql'
|
|
231
|
+
|
|
232
|
+
def constraint_sql(self, model, schema_editor):
|
|
233
|
+
return self._exclude_sql(model, schema_editor) if self._supported(schema_editor) else None
|
|
234
|
+
|
|
235
|
+
def create_sql(self, model, schema_editor):
|
|
236
|
+
if not self._supported(schema_editor):
|
|
237
|
+
return None
|
|
238
|
+
table = schema_editor.quote_name(model._meta.db_table)
|
|
239
|
+
return f'ALTER TABLE {table} ADD {self._exclude_sql(model, schema_editor)}'
|
|
240
|
+
|
|
241
|
+
def remove_sql(self, model, schema_editor):
|
|
242
|
+
if not self._supported(schema_editor):
|
|
243
|
+
return None
|
|
244
|
+
quote = schema_editor.quote_name
|
|
245
|
+
return f'ALTER TABLE {quote(model._meta.db_table)} DROP CONSTRAINT {quote(self.name)}'
|
|
246
|
+
|
|
247
|
+
def validate(self, model, instance, exclude=None, using=DEFAULT_DB_ALIAS):
|
|
248
|
+
if exclude and set(self.fields) & set(exclude):
|
|
249
|
+
return
|
|
250
|
+
fields = [model._meta.get_field(name) for name in self.fields]
|
|
251
|
+
values = {field.attname: getattr(instance, field.attname) for field in fields}
|
|
252
|
+
if any(value is None for value in values.values()):
|
|
253
|
+
return # like unique: null values never clash
|
|
254
|
+
tenant_id = instance.tenant_id
|
|
255
|
+
if tenant_id is None and not getattr(model, 'global_and_private', False):
|
|
256
|
+
return # the tenant is assigned on save
|
|
257
|
+
clashes = model._default_manager.using(using).filter(**values)
|
|
258
|
+
if not instance._state.adding and instance.pk is not None:
|
|
259
|
+
clashes = clashes.exclude(pk=instance.pk)
|
|
260
|
+
same = clashes.filter(tenant=tenant_id) if tenant_id is not None else clashes.filter(tenant__isnull=True)
|
|
261
|
+
if same.exists():
|
|
262
|
+
raise instance.unique_error_message(model, self.fields)
|
|
263
|
+
params = {
|
|
264
|
+
'model_name': capfirst(model._meta.verbose_name),
|
|
265
|
+
'field_labels': get_text_list([capfirst(field.verbose_name) for field in fields], _('and')),
|
|
266
|
+
}
|
|
267
|
+
if tenant_id is not None:
|
|
268
|
+
if not clashes.filter(tenant__isnull=True).exists():
|
|
269
|
+
return
|
|
270
|
+
error = ValidationError(_('%(model_name)s with this %(field_labels)s already exists as a shared record.'),
|
|
271
|
+
code='unique_with_global', params=params)
|
|
272
|
+
else:
|
|
273
|
+
owners = get_tenant_model()._default_manager.using(using).filter(pk__in=clashes.values('tenant'))
|
|
274
|
+
tenants = [str(tenant) for tenant in owners.order_by('pk')[:5]]
|
|
275
|
+
if not tenants:
|
|
276
|
+
return
|
|
277
|
+
error = ValidationError(
|
|
278
|
+
_('%(model_name)s with this %(field_labels)s already exists as a private record (%(tenants)s).'),
|
|
279
|
+
code='unique_with_global', params={**params, 'tenants': ', '.join(tenants)})
|
|
280
|
+
raise ValidationError({self.fields[0] if len(self.fields) == 1 else NON_FIELD_ERRORS: [error]})
|
|
281
|
+
|
|
282
|
+
def __eq__(self, other):
|
|
283
|
+
if isinstance(other, TenantUniqueConstraint):
|
|
284
|
+
return (self.name, self.fields, self.violation_error_code, self.violation_error_message) == (
|
|
285
|
+
other.name, other.fields, other.violation_error_code, other.violation_error_message)
|
|
286
|
+
return super().__eq__(other)
|
|
287
|
+
|
|
288
|
+
def __repr__(self):
|
|
289
|
+
return f'<{self.__class__.__qualname__}: fields={self.fields!r} name={self.name!r}>'
|
|
290
|
+
|
|
291
|
+
def deconstruct(self):
|
|
292
|
+
path, args, kwargs = super().deconstruct()
|
|
293
|
+
kwargs['fields'] = self.fields
|
|
294
|
+
return path, args, kwargs
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _allow_global_records(sender, **kwargs):
|
|
298
|
+
"""global_and_private models: a nullable tenant that superusers may edit (each model has its own field copy)"""
|
|
299
|
+
if issubclass(sender, TenantModel) and sender.global_and_private and not sender._meta.abstract:
|
|
300
|
+
field = sender._meta.get_field('tenant')
|
|
301
|
+
field.null = field.blank = field.editable = True
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class_prepared.connect(_allow_global_records)
|
|
File without changes
|