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.
@@ -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,7 @@
1
+ from django.apps import AppConfig
2
+ from django.utils.translation import gettext_lazy as _
3
+
4
+
5
+ class CrossTenancyConfig(AppConfig):
6
+ name = 'django_cross_tenancy'
7
+ verbose_name = _('Cross tenancy')
@@ -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
@@ -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)